diff --git a/.env b/.env new file mode 100644 index 0000000000000000000000000000000000000000..5d1ab743ad87ac8ae126ab7770fe8e9c488c4fec --- /dev/null +++ b/.env @@ -0,0 +1,11 @@ +# .env +SEA_LION_API_KEY=sk-Pgz55LWbB_WJM57hVKHl2Q +GEMINI_API_KEY=AIzaSyBBKtn1m2WUJnPXwxvwbJb4UE6K8eTs3j4 +NGROK_AUTHTOKEN=31ooUO8hf5RHq33GbvQUU2cClDj_7pFWFaGLkzf6HSUmCBmVc + +# Setting up AWS credentials for temporary access +AWS_ACCESS_KEY_ID=ASIAYXX3CBWNEDOJS3TI +AWS_SECRET_ACCESS_KEY=QXOgQW0M1+D4Out3ayMdEzEtdhfZ/JOvEXbfrGDF +AWS_SESSION_TOKEN=IQoJb3JpZ2luX2VjEN///////////wEaCXVzLWVhc3QtMSJGMEQCIA9ZY7I0eFkL9LHJcDhSUfJeaZdoWNv4wNlnicrZ3ZNHAiAfgDcWN6FS7RH2xH0lk8kg+ozWj8lhWccRCwYKoK2rFyqNAwhIEAAaDDYwMDc0ODE5OTMyMiIMIaoSNf4lU4GN+XYVKuoCw7xlObfhM7xfIWpwu3isKfK6thpPmOQvEPqyMiP+MbXsnzrZz2Ayu2vbCsnM16PIiUpE9RTDM8Gt7+wSY0vsZOTidtn9oJJr/l67PZ/GUVRrxydPcKtAbaxDOcmsAkCzIA69oCtJ6KZ20YNUaO14pMxF0mor1VpIJosF231IxKsgDfyxYuEPAfDfa49qn1gFCJuiAOTwJikQGyA0Fpol9ZP+ykV3T2CxHeBe7xrfKmN9MwnG7oMuL6nuXSOqZQQe37GbRXGDEeWjsOvD4YkfWj04xJNwGt0dt7ZtAPdVy1Cqrsnr7JZn7d1r0ns+w1MFIpARjkzhUvhl5hO6Ml0IFOfqGWJ4w0fnHC224txp+65xVPIxT167FucVdqeO5NCcz19I8rHQF7Jx8m58HZuTI9N8bm7DnntRlHHEpLU345nm9vqZhASzIxkSNOKZLud8dgiMOW8h4it9lsSWZgcEns+5z36uvdrRj9YwvZ7hxQY6pwHcpUEFpAip5W9+oRuufPpMp82vsqSzMEzklPePKicUPtQCVs9bpimAaW8bcCVG51/kf4PkDfH8L/ScJQAe29diPI3jrbI36F9LFxJk8aozc824Lrvsx+17UgGCRb3vycdiqSk5B+oJ88l4aFN6fn9NM5PDxTo+S3hcj4njrY4EHteN5zj/BIhN/gfUysrPVcsZnZ30Ni0TiR4RhUzXGGC1CcBcWFzzhQ== +AWS_ROLE_ARN=arn:aws:iam::600748199322:role/SageMakerExecutionRole +AWS_DEFAULT_REGION=ap-southeast-2 \ No newline at end of file diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..dab9a4e17afd2ef39d90ccb0b40ef2786fe77422 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,35 +1,35 @@ -*.7z filter=lfs diff=lfs merge=lfs -text -*.arrow filter=lfs diff=lfs merge=lfs -text -*.bin filter=lfs diff=lfs merge=lfs -text -*.bz2 filter=lfs diff=lfs merge=lfs -text -*.ckpt filter=lfs diff=lfs merge=lfs -text -*.ftz filter=lfs diff=lfs merge=lfs -text -*.gz filter=lfs diff=lfs merge=lfs -text -*.h5 filter=lfs diff=lfs merge=lfs -text -*.joblib filter=lfs diff=lfs merge=lfs -text -*.lfs.* filter=lfs diff=lfs merge=lfs -text -*.mlmodel filter=lfs diff=lfs merge=lfs -text -*.model filter=lfs diff=lfs merge=lfs -text -*.msgpack filter=lfs diff=lfs merge=lfs -text -*.npy filter=lfs diff=lfs merge=lfs -text -*.npz filter=lfs diff=lfs merge=lfs -text -*.onnx filter=lfs diff=lfs merge=lfs -text -*.ot filter=lfs diff=lfs merge=lfs -text -*.parquet filter=lfs diff=lfs merge=lfs -text -*.pb filter=lfs diff=lfs merge=lfs -text -*.pickle filter=lfs diff=lfs merge=lfs -text -*.pkl filter=lfs diff=lfs merge=lfs -text -*.pt filter=lfs diff=lfs merge=lfs -text -*.pth filter=lfs diff=lfs merge=lfs -text -*.rar filter=lfs diff=lfs merge=lfs -text -*.safetensors filter=lfs diff=lfs merge=lfs -text -saved_model/**/* filter=lfs diff=lfs merge=lfs -text -*.tar.* filter=lfs diff=lfs merge=lfs -text -*.tar filter=lfs diff=lfs merge=lfs -text -*.tflite filter=lfs diff=lfs merge=lfs -text -*.tgz filter=lfs diff=lfs merge=lfs -text -*.wasm filter=lfs diff=lfs merge=lfs -text -*.xz filter=lfs diff=lfs merge=lfs -text -*.zip filter=lfs diff=lfs merge=lfs -text -*.zst filter=lfs diff=lfs merge=lfs -text -*tfevents* filter=lfs diff=lfs merge=lfs -text +*.7z filter=lfs diff=lfs merge=lfs -text +*.arrow filter=lfs diff=lfs merge=lfs -text +*.bin filter=lfs diff=lfs merge=lfs -text +*.bz2 filter=lfs diff=lfs merge=lfs -text +*.ckpt filter=lfs diff=lfs merge=lfs -text +*.ftz filter=lfs diff=lfs merge=lfs -text +*.gz filter=lfs diff=lfs merge=lfs -text +*.h5 filter=lfs diff=lfs merge=lfs -text +*.joblib filter=lfs diff=lfs merge=lfs -text +*.lfs.* filter=lfs diff=lfs merge=lfs -text +*.mlmodel filter=lfs diff=lfs merge=lfs -text +*.model filter=lfs diff=lfs merge=lfs -text +*.msgpack filter=lfs diff=lfs merge=lfs -text +*.npy filter=lfs diff=lfs merge=lfs -text +*.npz filter=lfs diff=lfs merge=lfs -text +*.onnx filter=lfs diff=lfs merge=lfs -text +*.ot filter=lfs diff=lfs merge=lfs -text +*.parquet filter=lfs diff=lfs merge=lfs -text +*.pb filter=lfs diff=lfs merge=lfs -text +*.pickle filter=lfs diff=lfs merge=lfs -text +*.pkl filter=lfs diff=lfs merge=lfs -text +*.pt filter=lfs diff=lfs merge=lfs -text +*.pth filter=lfs diff=lfs merge=lfs -text +*.rar filter=lfs diff=lfs merge=lfs -text +*.safetensors filter=lfs diff=lfs merge=lfs -text +saved_model/**/* filter=lfs diff=lfs merge=lfs -text +*.tar.* filter=lfs diff=lfs merge=lfs -text +*.tar filter=lfs diff=lfs merge=lfs -text +*.tflite filter=lfs diff=lfs merge=lfs -text +*.tgz filter=lfs diff=lfs merge=lfs -text +*.wasm filter=lfs diff=lfs merge=lfs -text +*.xz filter=lfs diff=lfs merge=lfs -text +*.zip filter=lfs diff=lfs merge=lfs -text +*.zst filter=lfs diff=lfs merge=lfs -text +*tfevents* filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..71193dbbd26c1fc2202bf1a9ddbe913ef85ba4c2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,206 @@ +*.pem +*.db +logs +*.json +.env +.gradio/ +data +exported_sessions +*.csv +*.jsonl +radar_outputs + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# UV +# Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +#uv.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/latest/usage/project/#working-with-version-control +.pdm.toml +.pdm-python +.pdm-build/ + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +# Abstra +# Abstra is an AI-powered process automation framework. +# Ignore directories containing user credentials, local state, and settings. +# Learn more at https://abstra.io/docs +.abstra/ + +# Visual Studio Code +# Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore +# that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore +# and can be added to the global gitignore or merged into this file. However, if you prefer, +# you could uncomment the following to ignore the enitre vscode folder +# .vscode/ + +# Ruff stuff: +.ruff_cache/ + +# PyPI configuration file +.pypirc + +# Cursor +# Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to +# exclude from AI features like autocomplete and code analysis. Recommended for sensitive data +# refer to https://docs.cursor.com/context/ignore-files +.cursorignore +.cursorindexingignore \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..29f81d812f3e768fa89638d1f72920dbfd1413a8 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/README.md b/README.md index c451be3c24845f1f9c85b56118fc249f3a1ec8f5..d93668ec9cdb8f20ab37ea7243dd0e53d1eed803 100644 --- a/README.md +++ b/README.md @@ -1,14 +1,132 @@ ---- -title: KaLLaM Demo -emoji: 🐠 -colorFrom: yellow -colorTo: yellow -sdk: gradio -sdk_version: 5.46.0 -app_file: app.py -pinned: false -license: apache-2.0 -short_description: 'PAN-SEA AI DEVELOPER CHALLENGE 2025 Round 2: Develop Deploya' ---- - -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# KaLLaM - Motivational-Therapeutic Advisor + +KaLLaM is a bilingual (Thai/English) multi-agent assistant designed for physical and mental-health conversations. It orchestrates specialized agents (Supervisor, Doctor, Psychologist, Translator, Summarizer), persists state in SQLite, and exposes Gradio front-ends alongside data and can use evaluation tooling for model psychological skill benchmark. +Finalist in PAN-SEA AI DEVELOPER CHALLENGE 2025 Round 2: Develop Deployable Solutions & Pitch + +## Highlights +- Multi-agent orchestration that routes requests to domain specialists. +- Thai/English support backed by SEA-Lion translation services. +- Conversation persistence with export utilities for downstream analysis. +- Ready-to-run Gradio demo and developer interfaces. +- Evaluation scripts for MISC/BiMISC-style coding pipelines. + +## Requirements +- Python 3.10 or newer (3.11+ recommended; Docker/App Runner images use 3.11). +- pip, virtualenv (or equivalent), and Git for local development. +- Access tokens for the external models you plan to call (SEA-Lion, Google Gemini, optional OpenAI or AWS Bedrock). + +## Quick Start (Local) +1. Clone the repository and switch into it. +2. Create and activate a virtual environment: + ```powershell + python -m venv .venv + .venv\Scripts\Activate.ps1 + ``` + ```bash + python -m venv .venv + source .venv/bin/activate + ``` +3. Install dependencies (editable mode keeps imports pointing at `src/`): + ```bash + python -m pip install --upgrade pip setuptools wheel + pip install -e .[dev] + ``` +4. Create a `.env` file at the project root (see the next section) and populate the keys you have access to. +5. Launch one of the Gradio apps: + ```bash + python gui/chatbot_demo.py # bilingual demo UI + python gui/chatbot_dev_app.py # Thai-first developer UI + ``` + +The Gradio server binds to http://127.0.0.1:7860 by default; override via `GRADIO_SERVER_NAME` and `GRADIO_SERVER_PORT`. + +## Environment Configuration +Configuration is loaded with `python-dotenv`, so any variables in `.env` are available at runtime. Define only the secrets relevant to the agents you intend to use. + +**Core** +- `SEA_LION_API_KEY` *or* (`SEA_LION_GATEWAY_URL` + `SEA_LION_GATEWAY_TOKEN`) for SEA-Lion access. +- `SEA_LION_BASE_URL` (optional; defaults to `https://api.sea-lion.ai/v1`). +- `SEA_LION_MODEL_ID` to override the default SEA-Lion model. +- `GEMINI_API_KEY` for Doctor/Psychologist English responses. + +**Optional integrations** +- `OPENAI_API_KEY` if you enable any OpenAI-backed tooling via `strands-agents`. +- `AWS_REGION` (and optionally `AWS_DEFAULT_REGION`) plus temporary credentials (`AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_SESSION_TOKEN`) when running Bedrock-backed flows. +- `AWS_ROLE_ARN` if you assume roles for Bedrock access. +- `NGROK_AUTHTOKEN` when tunnelling Gradio externally. +- `TAVILY_API_KEY` if you wire in search or retrieval plugins. + +Example scaffold: +```env +SEA_LION_API_KEY=your-sea-lion-token +SEA_LION_MODEL_ID=aisingapore/Gemma-SEA-LION-v4-27B-IT +GEMINI_API_KEY=your-gemini-key +OPENAI_API_KEY=sk-your-openai-key +AWS_REGION=ap-southeast-2 +# AWS_ACCESS_KEY_ID=... +# AWS_SECRET_ACCESS_KEY=... +# AWS_SESSION_TOKEN=... +``` +Keep `.env` out of version control and rotate credentials regularly. You can validate temporary AWS credentials with `python test_credentials.py`. + +## Running and Persistence +- Conversations, summaries, and metadata persist to `chatbot_data.db` (SQLite). The schema is created automatically on first run. +- Export session transcripts with `ChatbotManager.export_session_json()`; JSON files land in `exported_sessions/`. +- Logs are emitted per agent into `logs/` (daily files) and to stdout. + +## Docker +Build and run the containerised Gradio app: +```bash +docker build -t kallam . +docker run --rm -p 8080:8080 --env-file .env kallam +``` +Environment variables are read at runtime; use `--env-file` or `-e` flags to provide the required keys. Override the entry script with `APP_FILE`, for example `-e APP_FILE=gui/chatbot_dev_app.py`. + +## AWS App Runner +The repo ships with `apprunner.yaml` for AWS App Runner's managed Python 3.11 runtime. +1. Push the code to a connected repository (GitHub or CodeCommit) or supply an archive. +2. In the App Runner console choose **Source code** -> **Managed runtime** and upload/select `apprunner.yaml`. +3. Configure AWS Secrets Manager references for the environment variables listed under `run.env` (SEA-Lion, Gemini, OpenAI, Ngrok, etc.). +4. Deploy. App Runner exposes the Gradio UI on the service URL and honours the `$PORT` variable (defaults to 8080). + +For fully containerised deployments on App Runner, ECS, or EKS, build the Docker image and supply the same environment variables. + +## Project Layout +``` +project-root/ +|-- src/kallam/ +| |-- app/ # ChatbotManager facade +| |-- domain/agents/ # Supervisor, Doctor, Psychologist, Translator, Summarizer, Orchestrator +| |-- infra/ # SQLite stores, exporter, token counter +| `-- infrastructure/ # Shared SEA-Lion configuration helpers +|-- gui/ # Gradio demo and developer apps +|-- scripts/ # Data prep and evaluation utilities +|-- data/ # Sample datasets (gemini, human, orchestrated, SEA-Lion) +|-- exported_sessions/ # JSON exports created at runtime +|-- logs/ # Runtime logs (generated) +|-- Dockerfile +|-- apprunner.yaml +|-- test_credentials.py +`-- README.md +``` + +## Development Tooling +- Run tests: `pytest -q` +- Lint: `ruff check src` +- Type-check: `mypy src` +- Token usage: see `src/kallam/infra/token_counter.py` +- Supervisor/translator fallbacks log warnings if credentials are missing. + +## Scripts and Evaluation +The `scripts/` directory includes: +- `eng_silver_misc_coder.py` and `thai_silver_misc_coder.py` for SEA-Lion powered coding pipelines. +- `model_evaluator.py` plus preprocessing and visualisation helpers (`ex_data_preprocessor.py`, `in_data_preprocessor.py`, `visualizer.ipynb`). + +## Note: +### Proporsal +Refer to KaLLaM Proporsal.pdf for more information of the project +### Citation +See `Citation.md` for references and datasets. +### License +Apache License 2.0. Refer to `LICENSE` for full terms. + diff --git a/gui/chatbot_demo.py b/gui/chatbot_demo.py new file mode 100644 index 0000000000000000000000000000000000000000..ae00aafc1b298009137be132cc21e698d6c2e68f --- /dev/null +++ b/gui/chatbot_demo.py @@ -0,0 +1,672 @@ +# chatbot_demo.py +import gradio as gr +import logging +from datetime import datetime +from typing import List, Tuple, Optional +import os +import socket + +from kallam.app.chatbot_manager import ChatbotManager + +mgr = ChatbotManager(log_level="INFO") +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# INLINE SVG for icons +CABBAGE_SVG = """ + + + + + + + + + + + + + + +""" + +# ----------------------- +# Core handlers +# ----------------------- +def _session_status(session_id: str) -> str: + """Get current session status using mgr.get_session()""" + if not session_id: + return "🔴 **No Active Session** - Click **New Session** to start" + + try: + # Use same method as simple app + now = datetime.now() + s = mgr.get_session(session_id) or {} + ts = s.get("timestamp", now.strftime("%d %b %Y | %I:%M %p")) + model = s.get("model_used", "Orchestrated SEA-Lion") + total = s.get("total_messages", 0) + saved_memories = s.get("saved_memories") or "General consultation" + + return f""" +🟢 **Session:** `{session_id[:8]}...` +🏥 **Profile:** {saved_memories[:50]}{"..." if len(saved_memories) > 50 else ""} +📅 **Created:** {ts} +💬 **Messages:** {total} +🤖 **Model:** {model} +""".strip() + except Exception as e: + logger.error(f"Error getting session status: {e}") + return f"❌ **Error loading session:** {session_id[:8]}..." + +def start_new_session(health_profile: str = ""): + """Create new session using mgr - same as simple app""" + try: + sid = mgr.start_session(saved_memories=health_profile.strip() or None) + status = _session_status(sid) + + # Initial welcome message + welcome_msg = { + "role": "assistant", + "content": """Hello! I'm KaLLaM 🌿, your caring AI health advisor 💖 + +I can communicate in both **Thai** and **English**. I'm here to support your health and well-being with personalized advice. How are you feeling today? 😊 + +สวัสดีค่ะ! ฉันชื่อกะหล่ำ 🌿 เป็นที่ปรึกษาด้านสุขภาพ AI ที่จะคอยดูแลคุณ 💖 ฉันสามารถสื่อสารได้ทั้งภาษาไทยและภาษาอังกฤษ วันนี้รู้สึกยังไงบ้างคะ? 😊""" + } + + history = [welcome_msg] + result_msg = f"✅ **New Session Created Successfully!**\n\n🆔 Session ID: `{sid}`" + if health_profile.strip(): + result_msg += f"\n🏥 **Health Profile:** Applied successfully" + + return sid, history, "", status, result_msg + except Exception as e: + logger.error(f"Error creating new session: {e}") + return "", [], "", "❌ **Failed to create session**", f"❌ **Error:** {e}" + +def send_message(user_msg: str, history: list, session_id: str): + """Send message using mgr - same as simple app""" + # Defensive: auto-create session if missing (same as simple app) + if not session_id: + logger.warning("No session found, auto-creating...") + sid, history, _, status, _ = start_new_session("") + history.append({"role": "assistant", "content": "🔄 **New session created automatically.** You can now continue chatting!"}) + return history, "", sid, status + + if not user_msg.strip(): + return history, "", session_id, _session_status(session_id) + + try: + # Add user message + history = history + [{"role": "user", "content": user_msg}] + + # Get bot response using mgr (same as simple app) + bot_response = mgr.handle_message( + session_id=session_id, + user_message=user_msg + ) + + # Add bot response + history = history + [{"role": "assistant", "content": bot_response}] + + return history, "", session_id, _session_status(session_id) + + except Exception as e: + logger.error(f"Error processing message: {e}") + error_msg = {"role": "assistant", "content": f"❌ **Error:** Unable to process your message. Please try again.\n\nDetails: {e}"} + history = history + [error_msg] + return history, "", session_id, _session_status(session_id) + +def update_health_profile(session_id: str, health_profile: str): + """Update health profile for current session using mgr's database access""" + if not session_id: + return "❌ **No active session**", _session_status(session_id) + + if not health_profile.strip(): + return "❌ **Please provide health information**", _session_status(session_id) + + try: + # Use mgr's database path (same pattern as simple app would use) + from kallam.infra.db import sqlite_conn + with sqlite_conn(str(mgr.db_path)) as conn: + conn.execute( + "UPDATE sessions SET saved_memories = ?, last_activity = ? WHERE session_id = ?", + (health_profile.strip(), datetime.now().isoformat(), session_id), + ) + + result = f"✅ **Health Profile Updated Successfully!**\n\n📝 **Updated Information:** {health_profile.strip()[:100]}{'...' if len(health_profile.strip()) > 100 else ''}" + return result, _session_status(session_id) + + except Exception as e: + logger.error(f"Error updating health profile: {e}") + return f"❌ **Error updating profile:** {e}", _session_status(session_id) + +def clear_session(session_id: str): + """Clear current session using mgr""" + if not session_id: + return "", [], "", "🔴 **No active session to clear**", "❌ **No active session**" + + try: + # Check if mgr has delete_session method, otherwise handle gracefully + if hasattr(mgr, 'delete_session'): + mgr.delete_session(session_id) + else: + # Fallback: just clear the session data if method doesn't exist + logger.warning("delete_session method not available, clearing session state only") + + return "", [], "", "🔴 **Session cleared - Create new session to continue**", f"✅ **Session `{session_id[:8]}...` cleared successfully**" + except Exception as e: + logger.error(f"Error clearing session: {e}") + return session_id, [], "", _session_status(session_id), f"❌ **Error clearing session:** {e}" + +def force_summary(session_id: str): + """Force summary using mgr (same as simple app)""" + if not session_id: + return "❌ No active session." + try: + if hasattr(mgr, 'summarize_session'): + s = mgr.summarize_session(session_id) + return f"📋 Summary updated:\n\n{s}" + else: + return "❌ Summarize function not available." + except Exception as e: + return f"❌ Failed to summarize: {e}" + +def lock_inputs(): + """Lock inputs during processing (same as simple app)""" + return gr.update(interactive=False), gr.update(interactive=False) + +def unlock_inputs(): + """Unlock inputs after processing (same as simple app)""" + return gr.update(interactive=True), gr.update(interactive=True) + +# ----------------------- +# UI with improved architecture and greenish cream styling - LIGHT MODE DEFAULT +# ----------------------- +def create_app() -> gr.Blocks: + # Enhanced CSS with greenish cream color scheme, fixed positioning, and light mode defaults + custom_css = """ + :root { + --kallam-primary: #659435; + --kallam-secondary: #5ea0bd; + --kallam-accent: #b8aa54; + --kallam-light: #f8fdf5; + --kallam-dark: #2d3748; + --kallam-cream: #f5f7f0; + --kallam-green-cream: #e8f4e0; + --kallam-border-cream: #d4e8c7; + --shadow-soft: 0 4px 6px -1px rgba(0, 0, 0, 0.1), 0 2px 4px -1px rgba(0, 0, 0, 0.06); + --shadow-medium: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05); + --border-radius: 12px; + --transition: all 0.3s ease; + } + + /* Force light mode styles - Override any dark mode defaults */ + body, .gradio-container, .app { + background-color: #ffffff !important; + color: #2d3748 !important; + } + + /* Ensure light backgrounds for all major containers */ + .block, .form, .gap { + background-color: #ffffff !important; + color: #2d3748 !important; + } + + /* Light mode for input elements */ + input, textarea, select { + background-color: #ffffff !important; + border: 1px solid #d1d5db !important; + color: #2d3748 !important; + } + + input:focus, textarea:focus, select:focus { + border-color: var(--kallam-primary) !important; + box-shadow: 0 0 0 3px rgba(101, 148, 53, 0.1) !important; + } + + /* Ensure dark mode styles don't override in light mode */ + html:not(.dark) .dark { + display: none !important; + } + + .gradio-container { + max-width: 100% !important; + width: 100% !important; + margin: 0 auto !important; + min-height: 100vh; + background-color: #ffffff !important; + } + + .main-layout { + display: flex !important; + min-height: calc(100vh - 2rem) !important; + gap: 1.5rem !important; + } + + .fixed-sidebar { + width: 320px !important; + min-width: 320px !important; + max-width: 320px !important; + background: #ffffff !important; + backdrop-filter: blur(10px) !important; + border-radius: var(--border-radius) !important; + border: 3px solid var(--kallam-primary) !important; + box-shadow: var(--shadow-soft) !important; + padding: 1.5rem !important; + height: fit-content !important; + position: sticky !important; + top: 1rem !important; + overflow: visible !important; + } + + .main-content { + flex: 1 !important; + min-width: 0 !important; + } + + .kallam-header { + background: linear-gradient(135deg, var(--kallam-secondary) 0%, var(--kallam-primary) 50%, var(--kallam-accent) 100%); + border-radius: var(--border-radius); + padding: 2rem; + margin-bottom: 1.5rem; + text-align: center; + box-shadow: var(--shadow-medium); + position: relative; + overflow: hidden; + } + + .kallam-header h1 { + color: white !important; + font-size: 2.5rem !important; + font-weight: 700 !important; + margin: 0 !important; + text-shadow: 0 2px 4px rgba(0,0,0,0.2); + position: relative; + z-index: 1; + } + + .kallam-subtitle { + color: rgba(255,255,255,0.9) !important; + font-size: 1.1rem !important; + margin-top: 0.5rem !important; + position: relative; + z-index: 1; + } + + .btn { + border-radius: 8px !important; + font-weight: 600 !important; + padding: 0.75rem 1.5rem !important; + transition: var(--transition) !important; + border: none !important; + box-shadow: var(--shadow-soft) !important; + cursor: pointer !important; + } + + .btn:hover { + transform: translateY(-2px) !important; + box-shadow: var(--shadow-medium) !important; + } + + .btn.btn-primary { + background: linear-gradient(135deg, var(--kallam-primary) 0%, var(--kallam-secondary) 100%) !important; + color: white !important; + } + + .btn.btn-secondary { + background: #f8f9fa !important; + color: #2d3748 !important; + border: 1px solid #d1d5db !important; + } + + .chat-container { + background: var(--kallam-green-cream) !important; + border-radius: var(--border-radius) !important; + border: 2px solid var(--kallam-border-cream) !important; + box-shadow: var(--shadow-medium) !important; + overflow: hidden !important; + } + + .session-status-container .markdown { + margin: 0 !important; + padding: 0 !important; + font-size: 0.85rem !important; + line-height: 1.4 !important; + overflow-wrap: break-word !important; + word-break: break-word !important; + } + + @media (max-width: 1200px) { + .main-layout { + flex-direction: column !important; + } + + .fixed-sidebar { + width: 100% !important; + min-width: 100% !important; + max-width: 100% !important; + position: static !important; + } + } + """ + + # Create a light theme with explicit light mode settings + light_theme = gr.themes.Soft( # type: ignore + primary_hue="green", + secondary_hue="blue", + neutral_hue="slate" + ).set( + # Force light mode colors + body_background_fill="white", + body_text_color="#2d3748", + background_fill_primary="white", + background_fill_secondary="#f8f9fa", + border_color_primary="#d1d5db", + border_color_accent="#659435", + button_primary_background_fill="#659435", + button_primary_text_color="white", + button_secondary_background_fill="#f8f9fa", + button_secondary_text_color="#2d3748" + ) + + with gr.Blocks( + title="🥬 KaLLaM - Thai Motivational Therapeutic Advisor", + theme=light_theme, + css=custom_css, + js=""" + function() { + // Force light mode on load by removing any dark classes and setting light preferences + document.documentElement.classList.remove('dark'); + document.body.classList.remove('dark'); + + // Set data attributes for light mode + document.documentElement.setAttribute('data-theme', 'light'); + + // Override any system preferences for dark mode + const style = document.createElement('style'); + style.textContent = ` + @media (prefers-color-scheme: dark) { + :root { + color-scheme: light !important; + } + body, .gradio-container { + background-color: white !important; + color: #2d3748 !important; + } + } + `; + document.head.appendChild(style); + } + """ + ) as app: + + # State management - same as simple app + session_id = gr.State(value="") + + # Header + gr.HTML(f""" +
+
+ {CABBAGE_SVG} +
+

KaLLaM

+

Thai Motivational Therapeutic Advisor

+
+
+
+ """) + + # Main layout + with gr.Row(elem_classes=["main-layout"]): + # Sidebar with enhanced styling + with gr.Column(scale=1, elem_classes=["fixed-sidebar"]): + gr.HTML(""" +
+

Controls

+

Manage session and health profile

+
+ """) + + with gr.Group(): + new_session_btn = gr.Button("➕ New Session", variant="primary", size="lg", elem_classes=["btn", "btn-primary"]) + health_profile_btn = gr.Button("👤 Custom Health Profile", variant="secondary", elem_classes=["btn", "btn-secondary"]) + clear_session_btn = gr.Button("🗑️ Clear Session", variant="secondary", elem_classes=["btn", "btn-secondary"]) + + # Hidden health profile section + with gr.Column(visible=False) as health_profile_section: + gr.HTML('

') + + health_context = gr.Textbox( + label="🏥 Patient's Health Information", + placeholder="e.g., Patient's name, age, medical conditions (high blood pressure, diabetes), current symptoms, medications, lifestyle factors, mental health status...", + lines=5, + max_lines=8, + info="This information helps KaLLaM provide more personalized and relevant health advice. All data is kept confidential within your session." + ) + + with gr.Row(): + update_profile_btn = gr.Button("💾 Update Health Profile", variant="primary", elem_classes=["btn", "btn-primary"]) + back_btn = gr.Button("⏪ Back", variant="secondary", elem_classes=["btn", "btn-secondary"]) + + gr.HTML('

') + + # Session status + session_status = gr.Markdown(value="🔄 **Initializing...**") + + # Main chat area + with gr.Column(scale=3, elem_classes=["main-content"]): + gr.HTML(""" +
+

💬 Health Consultation Chat

+

Chat with your AI health advisor in Thai or English

+
+ """) + + chatbot = gr.Chatbot( + label="Chat with KaLLaM", + height=500, + show_label=False, + type="messages", + elem_classes=["chat-container"] + ) + + with gr.Row(): + with gr.Column(scale=5): + msg = gr.Textbox( + label="Message", + placeholder="Ask about your health in Thai or English...", + lines=1, + max_lines=4, + show_label=False, + elem_classes=["chat-container"] + ) + with gr.Column(scale=1, min_width=120): + send_btn = gr.Button("➤", variant="primary", size="lg", elem_classes=["btn", "btn-primary"]) + + # Result display + result_display = gr.Markdown(visible=False) + + # Footer + gr.HTML(""" +
+
+ Built with ❤️ by: +
+ 👨‍💻 Nopnatee Trivoravong +
+ 📧 nopnatee.triv@gmail.com + + GitHub +
+
+ | +
+ 👨‍💻 Khamic Srisutrapon +
+ 📧 khamic.sk@gmail.com + + GitHub +
+
+ | +
+ 👩‍💻 Napas Siripala +
+ 📧 millynapas@gmail.com + + GitHub +
+
+
+
+ """) + + # ====== EVENT HANDLERS - Same pattern as simple app ====== + + # Auto-initialize on page load (same as simple app) + def _init(): + sid, history, _, status, note = start_new_session("") + return sid, history, status, note + + app.load( + fn=_init, + inputs=None, + outputs=[session_id, chatbot, session_status, result_display] + ) + + # New session + new_session_btn.click( + fn=lambda: start_new_session(""), + inputs=None, + outputs=[session_id, chatbot, msg, session_status, result_display] + ) + + # Show/hide health profile section + def show_health_profile(): + return gr.update(visible=True) + + def hide_health_profile(): + return gr.update(visible=False) + + health_profile_btn.click( + fn=show_health_profile, + outputs=[health_profile_section] + ) + + back_btn.click( + fn=hide_health_profile, + outputs=[health_profile_section] + ) + + # Update health profile + update_profile_btn.click( + fn=update_health_profile, + inputs=[session_id, health_context], + outputs=[result_display, session_status] + ).then( + fn=hide_health_profile, + outputs=[health_profile_section] + ) + + # Send message with lock/unlock pattern (inspired by simple app) + send_btn.click( + fn=lock_inputs, + inputs=None, + outputs=[send_btn, msg], + queue=False, # lock applies instantly + ).then( + fn=send_message, + inputs=[msg, chatbot, session_id], + outputs=[chatbot, msg, session_id, session_status], + ).then( + fn=unlock_inputs, + inputs=None, + outputs=[send_btn, msg], + queue=False, + ) + + # Enter/submit flow: same treatment + msg.submit( + fn=lock_inputs, + inputs=None, + outputs=[send_btn, msg], + queue=False, + ).then( + fn=send_message, + inputs=[msg, chatbot, session_id], + outputs=[chatbot, msg, session_id, session_status], + ).then( + fn=unlock_inputs, + inputs=None, + outputs=[send_btn, msg], + queue=False, + ) + + # Clear session + clear_session_btn.click( + fn=clear_session, + inputs=[session_id], + outputs=[session_id, chatbot, msg, session_status, result_display] + ) + + return app + +def main(): + app = create_app() + # Resolve bind address and port + server_name = os.getenv("GRADIO_SERVER_NAME", "0.0.0.0") + server_port = int(os.getenv("PORT", os.getenv("GRADIO_SERVER_PORT", 8080))) + + # Basic health log to confirm listening address + try: + hostname = socket.gethostname() + ip_addr = socket.gethostbyname(hostname) + except Exception: + hostname = "unknown" + ip_addr = "unknown" + + logger.info( + "Starting Gradio app | bind=%s:%s | host=%s ip=%s", + server_name, + server_port, + hostname, + ip_addr, + ) + logger.info( + "Env: PORT=%s GRADIO_SERVER_NAME=%s GRADIO_SERVER_PORT=%s", + os.getenv("PORT"), + os.getenv("GRADIO_SERVER_NAME"), + os.getenv("GRADIO_SERVER_PORT"), + ) + # Secrets presence check (mask values) + def _mask(v: str | None) -> str: + if not v: + return "" + return f"set(len={len(v)})" + logger.info( + "Secrets: SEA_LION_API_KEY=%s GEMINI_API_KEY=%s", + _mask(os.getenv("SEA_LION_API_KEY")), + _mask(os.getenv("GEMINI_API_KEY")), + ) + app.launch( + share=False, + server_name=server_name, # cloud: 0.0.0.0, local: 127.0.0.1 + server_port=server_port, # cloud: $PORT, local: 7860/8080 + debug=False, + show_error=True, + inbrowser=True + ) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/gui/chatbot_dev_app.py b/gui/chatbot_dev_app.py new file mode 100644 index 0000000000000000000000000000000000000000..22c1db48648d8088c516754fc33ed8901b1a04c8 --- /dev/null +++ b/gui/chatbot_dev_app.py @@ -0,0 +1,508 @@ +# chatbot_dev_app.py +import gradio as gr +import logging +import socket +from datetime import datetime +from typing import List, Tuple, Optional +import os + +from kallam.app.chatbot_manager import ChatbotManager +from kallam.infra.db import sqlite_conn # use the shared helper + +# ----------------------- +# Init +# ----------------------- +chatbot_manager = ChatbotManager(log_level="DEBUG") +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +class AppState: + def __init__(self): + self.current_session_id: str = "" + self.message_count: int = 0 + self.followup_note: str = "Request follow-up analysis..." + + def reset(self): + self.current_session_id = "" + self.message_count = 0 + +app_state = AppState() + +# ----------------------- +# Helpers +# ----------------------- +def _safe_latency_str(v) -> str: + try: + return f"{float(v):.1f}" + except Exception: + return "0.0" + +def _extract_stats_pack() -> tuple[dict, dict]: + # returns (session_info, stats_dict) + data = chatbot_manager.get_session_stats(app_state.current_session_id) # new shape + session_info = data.get("session_info", {}) if isinstance(data, dict) else {} + stats = data.get("stats", {}) if isinstance(data, dict) else {} + return session_info, stats + +def get_current_session_status() -> str: + if not app_state.current_session_id: + return "🔴 **ไม่มี Session ที่ใช้งาน** - กรุณาสร้าง Session ใหม่" + + try: + session_info, stats = _extract_stats_pack() + + avg_latency = _safe_latency_str(stats.get("avg_latency")) + saved_memories = session_info.get("saved_memories") or "ไม่ได้ระบุ" + return f""" +🟢 **Session ปัจจุบัน:** `{app_state.current_session_id}` +📅 **สร้างเมื่อ:** {session_info.get('timestamp', 'N/A')} +💬 **จำนวนข้อความ:** {stats.get('message_count', 0) or 0} ข้อความ +📋 **จำนวนสรุป:** {session_info.get('total_summaries', 0) or 0} ครั้ง +🏥 **สถานะกำหนดเอง:** {saved_memories} +⚡ **Latency เฉลี่ย:** {avg_latency} ms +🔧 **Model:** {session_info.get('model_used', 'N/A')} + """.strip() + except Exception as e: + logger.error(f"Error getting session status: {e}") + return f"❌ **Error:** ไม่สามารถโหลดข้อมูล Session {app_state.current_session_id}" + +def get_session_list() -> List[str]: + try: + sessions = chatbot_manager.list_sessions(active_only=True, limit=50) + opts = [] + for s in sessions: + saved = (s.get("saved_memories") or "ไม่ระบุ")[:20] + msgs = s.get("total_messages", 0) + sums = s.get("total_summaries", 0) + opts.append(f"{s['session_id']} | {msgs}💬 {sums}📋 | {saved}") + return opts or ["ไม่มี Session"] + except Exception as e: + logger.error(f"Error getting session list: {e}") + return ["Error loading sessions"] + +def extract_session_id(dropdown_value: str) -> Optional[str]: + if not dropdown_value or dropdown_value in ["ไม่มี Session", "Error loading sessions"]: + return None + return dropdown_value.split(" | ")[0] + +def refresh_session_list(): + sessions = get_session_list() + return gr.update(choices=sessions, value=sessions[0] if sessions else None) + +def create_new_session(saved_memories: str = "") -> Tuple[List, str, str, str, str]: + try: + sid = chatbot_manager.start_session(saved_memories=saved_memories or None) + app_state.current_session_id = sid + app_state.message_count = 0 + status = get_current_session_status() + result = f"✅ **สร้าง Session ใหม่สำเร็จ!**\n\n🆔 Session ID: `{sid}`" + return [], "", result, status, saved_memories + except Exception as e: + logger.error(f"Error creating new session: {e}") + return [], "", f"❌ **ไม่สามารถสร้าง Session ใหม่:** {e}", get_current_session_status(), "" + +def switch_session(dropdown_value: str) -> Tuple[List, str, str, str, str]: + sid = extract_session_id(dropdown_value) + if not sid: + return [], "", "❌ **Session ID ไม่ถูกต้อง**", get_current_session_status(), "" + + try: + session = chatbot_manager.get_session(sid) + if not session: + return [], "", f"❌ **ไม่พบ Session:** {sid}", get_current_session_status(), "" + app_state.current_session_id = sid + app_state.message_count = session.get("total_messages", 0) + + # use the new helper on manager (provided) + chat_history = chatbot_manager.get_original_chat_history(sid) + gr_history = [] + for m in chat_history: + role = m.get("role") + content = m.get("content", "") + if role == "user": + gr_history.append({"role": "user", "content": content}) + elif role == "assistant": + gr_history.append({"role": "assistant", "content": content}) + + status = get_current_session_status() + result = f"✅ **เปลี่ยน Session สำเร็จ!**\n\n🆔 Session ID: `{sid}`" + saved_memories = session.get("saved_memories", "") + return gr_history, "", result, status, saved_memories + except Exception as e: + logger.error(f"Error switching session: {e}") + return [], "", f"❌ **ไม่สามารถเปลี่ยน Session:** {e}", get_current_session_status(), "" + +def get_session_info() -> str: + if not app_state.current_session_id: + return "❌ **ไม่มี Session ที่ใช้งาน**" + try: + session_info, stats = _extract_stats_pack() + + latency_str = f"{float(stats.get('avg_latency') or 0):.2f}" + total_tokens_in = stats.get("total_tokens_in") or 0 + total_tokens_out = stats.get("total_tokens_out") or 0 + saved_memories = session_info.get("saved_memories") or "ไม่ได้ระบุ" + summarized_history = session_info.get("summarized_history") or "ยังไม่มีการสรุป" + + return f""" +## 📊 ข้อมูลรายละเอียด Session: `{app_state.current_session_id}` + +### 🔧 ข้อมูลพื้นฐาน +- **Session ID:** `{session_info.get('session_id', 'N/A')}` +- **สร้างเมื่อ:** {session_info.get('timestamp', 'N/A')} +- **ใช้งานล่าสุด:** {session_info.get('last_activity', 'N/A')} +- **Model:** {session_info.get('model_used', 'N/A')} +- **สถานะ:** {'🟢 Active' if session_info.get('is_active') else '🔴 Inactive'} + +### 🏥 ข้อมูลสถานะกำหนดเอง +- **สถานะกำหนดเอง:** {saved_memories} + +### 📈 สถิติการใช้งาน +- **จำนวนข้อความทั้งหมด:** {stats.get('message_count', 0) or 0} ข้อความ +- **จำนวนสรุป:** {session_info.get('total_summaries', 0) or 0} ครั้ง +- **Token Input รวม:** {total_tokens_in:,} tokens +- **Token Output รวม:** {total_tokens_out:,} tokens +- **Latency เฉลี่ย:** {latency_str} ms +- **ข้อความแรก:** {stats.get('first_message', 'N/A') or 'N/A'} +- **ข้อความล่าสุด:** {stats.get('last_message', 'N/A') or 'N/A'} + +### 📋 ประวัติการสรุป +{summarized_history} + """.strip() + except Exception as e: + logger.error(f"Error getting session info: {e}") + return f"❌ **Error:** {e}" + +def get_all_sessions_info() -> str: + try: + sessions = chatbot_manager.list_sessions(active_only=False, limit=20) + if not sessions: + return "📭 **ไม่มี Session ในระบบ**" + + parts = ["# 📁 ข้อมูล Session ทั้งหมด\n"] + for i, s in enumerate(sessions, 1): + status_icon = "🟢" if s.get("is_active") else "🔴" + saved = (s.get("saved_memories") or "ไม่ระบุ")[:30] + parts.append(f""" +## {i}. {status_icon} `{s['session_id']}` +- **สร้าง:** {s.get('timestamp', 'N/A')} +- **ใช้งานล่าสุด:** {s.get('last_activity', 'N/A')} +- **ข้อความ:** {s.get('total_messages', 0)} | **สรุป:** {s.get('total_summaries', 0)} +- **สภาวะ:** {saved} +- **Model:** {s.get('model_used', 'N/A')} + """.strip()) + return "\n\n".join(parts) + except Exception as e: + logger.error(f"Error getting all sessions info: {e}") + return f"❌ **Error:** {e}" + +def update_medical_saved_memories(saved_memories: str) -> Tuple[str, str]: + if not app_state.current_session_id: + return get_current_session_status(), "❌ **ไม่มี Session ที่ใช้งาน**" + if not saved_memories.strip(): + return get_current_session_status(), "❌ **กรุณาสถานะกำหนดเอง**" + + try: + with sqlite_conn(str(chatbot_manager.db_path)) as conn: + conn.execute( + "UPDATE sessions SET saved_memories = ?, last_activity = ? WHERE session_id = ?", + (saved_memories.strip(), datetime.now().isoformat(), app_state.current_session_id), + ) + status = get_current_session_status() + result = f"✅ **อัปเดตสถานะกำหนดเองสำเร็จ!**\n\n📝 **ข้อมูลใหม่:** {saved_memories.strip()}" + return status, result + except Exception as e: + logger.error(f"Error updating saved_memories: {e}") + return get_current_session_status(), f"❌ **ไม่สามารถอัปเดตสถานะกำหนดเอง:** {e}" + +def process_chat_message(user_message: str, history: List) -> Tuple[List, str]: + if not app_state.current_session_id: + history.append({"role": "assistant", "content": "❌ **กรุณาสร้าง Session ใหม่ก่อนใช้งาน**"}) + return history, "" + if not user_message.strip(): + return history, "" + + try: + history.append({"role": "user", "content": user_message}) + bot = chatbot_manager.handle_message( + session_id=app_state.current_session_id, + user_message=user_message, + ) + history.append({"role": "assistant", "content": bot}) + app_state.message_count += 2 + return history, "" + except Exception as e: + logger.error(f"Error processing chat message: {e}") + history.append({"role": "assistant", "content": f"❌ **ข้อผิดพลาด:** {e}"}) + return history, "" + +def generate_followup(history: List) -> List: + # No dedicated handle_followup in new manager. + # We just inject the follow-up note as a plain user turn. + if not app_state.current_session_id: + history.append({"role": "assistant", "content": "❌ **กรุณาสร้าง Session ใหม่ก่อนใช้งาน**"}) + return history + try: + note = app_state.followup_note + history.append({"role": "user", "content": note}) + bot = chatbot_manager.handle_message( + session_id=app_state.current_session_id, + user_message=note, + ) + history.append({"role": "assistant", "content": bot}) + app_state.message_count += 2 + return history + except Exception as e: + logger.error(f"Error generating follow-up: {e}") + history.append({"role": "assistant", "content": f"❌ **ไม่สามารถสร้างการวิเคราะห์:** {e}"}) + return history + +def force_update_summary() -> str: + if not app_state.current_session_id: + return "❌ **ไม่มี Session ที่ใช้งาน**" + try: + s = chatbot_manager.summarize_session(app_state.current_session_id) + return f"✅ **สรุปข้อมูลสำเร็จ!**\n\n📋 **สรุป:** {s}" + except Exception as e: + logger.error(f"Error forcing summary update: {e}") + return f"❌ **ไม่สามารถสรุปข้อมูล:** {e}" + +def clear_session() -> Tuple[List, str, str, str, str]: + if not app_state.current_session_id: + return [], "", "❌ **ไม่มี Session ที่ใช้งาน**", get_current_session_status(), "" + try: + old = app_state.current_session_id + chatbot_manager.delete_session(old) + app_state.reset() + return [], "", f"✅ **ลบ Session สำเร็จ!**\n\n🗑️ **Session ที่ลบ:** `{old}`", get_current_session_status(), "" + except Exception as e: + logger.error(f"Error clearing session: {e}") + return [], "", f"❌ **ไม่สามารถลบ Session:** {e}", get_current_session_status(), "" + +def clear_all_summaries() -> str: + if not app_state.current_session_id: + return "❌ **ไม่มี Session ที่ใช้งาน**" + try: + with sqlite_conn(str(chatbot_manager.db_path)) as conn: + conn.execute("DELETE FROM summaries WHERE session_id = ?", (app_state.current_session_id,)) + return f"✅ **ล้างสรุปสำเร็จ!**\n\n🗑️ **Session:** `{app_state.current_session_id}`" + except Exception as e: + logger.error(f"Error clearing summaries: {e}") + return f"❌ **ไม่สามารถล้างสรุป:** {e}" + +def export_session() -> str: + if not app_state.current_session_id: + return "❌ **ไม่มี Session ที่ใช้งาน**" + try: + chatbot_manager.export_session_json(app_state.current_session_id) + return "✅ **ส่งออกข้อมูลสำเร็จ!**" + except Exception as e: + logger.error(f"Error exporting session: {e}") + return f"❌ **ไม่สามารถส่งออกข้อมูล:** {e}" + +def export_all_sessions() -> str: + try: + chatbot_manager.export_all_sessions_json() + return "✅ **ส่งออกข้อมูลสำเร็จ!**" + except Exception as e: + logger.error(f"Error exporting session: {e}") + return f"❌ **ไม่สามารถส่งออกข้อมูล:** {e}" +# ----------------------- +# UI +# ----------------------- +def create_app() -> gr.Blocks: + custom_css = """ + .gradio-container { max-width: 1400px !important; margin: 0 auto !important; } + .tab-nav { background: linear-gradient(90deg, #667eea 0%, #764ba2 100%); } + .chat-container { border-radius: 10px; border: 1px solid #e0e0e0; box-shadow: 0 2px 8px rgba(0,0,0,0.1); } + .summary-box { border-radius: 8px; padding: 15px; margin: 10px 0; } + .session-info { border-radius: 8px; padding: 15px; margin: 10px 0; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; font-weight: 500; } + .saved_memories-box { border-radius: 8px; padding: 10px; margin: 5px 0; border: 1px solid #ddd; } + .stat-card { background: white; border-radius: 8px; padding: 15px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); border-left: 4px solid #28a745; } + .red-button { background-color: red !important; color: white !important; } + """ + + with gr.Blocks(title="🌟 DEMO ระบบจำลองการคุยกับลูกและให้คำแนะนำสำหรับผู้ปกครอง", + css=custom_css) as app: + + gr.Markdown(""" +# 🌟 ระบบจำลองการคุยกับลูกและให้คำแนะนำสำหรับผู้ปกครอง + +🔄 **สรุปอัตโนมัติ:** ทุก 10 ข้อความ +🔔 **วิเคราะห์บทสนทนา:** เรียกใช้ด้วยปุ่ม Follow-up +💾 **Session Management** +🏥 **Custom saved_memories** +📊 **Analytics** + """) + + session_status = gr.Markdown(value=get_current_session_status(), elem_classes=["session-info"]) + + gr.Markdown("## 🗂️ การจัดการ Session") + with gr.Row(): + with gr.Column(scale=3): + session_dropdown = gr.Dropdown( + choices=get_session_list(), + value=None, + label="🗒️ เลือก Session", + info="เลือก session ที่ต้องการเปลี่ยนไป", + ) + with gr.Column(scale=1): + refresh_btn = gr.Button("🔄 รีเฟรช", variant="primary") + switch_btn = gr.Button("🔀 โหลด Session", variant="secondary") + new_session_btn = gr.Button("➕ Session ใหม่", variant="secondary") + + with gr.Row(): + session_info_btn = gr.Button("👀 ข้อมูล Session", variant="secondary") + all_sessions_btn = gr.Button("📁 ดู Session ทั้งหมด", variant="secondary") + export_btn = gr.Button("📤 ส่งออกข้อมูลทั้งหมดเป็น.json (dev)", variant="secondary") + + with gr.Accordion("📊 ข้อมูลรายละเอียด Session", open=False): + session_result = gr.Markdown(value="**กำลังรอการอัปเดต...**", elem_classes=["summary-box"]) + session_info_display = gr.Markdown(value="", elem_classes=["summary-box"]) + + gr.Markdown("---") + gr.Markdown("## 🏥 การจัดการสถานะการสนทนา") + + with gr.Row(): + health_context = gr.Textbox( + label="🏥 ข้อมูลสถานะของการสนทนา", + placeholder="เช่น: ชื่อเด็ก, อายุ, พฤติกรรมที่อยากโฟกัส", + value="", + max_lines=5, lines=3, + info="ข้อมูลนี้จะถูกเก็บใน session และใช้ปรับแต่งบทสนทนา", + elem_classes=["saved_memories-box"], + ) + update_saved_memories_btn = gr.Button("💾 อัปเดตข้อมูล", variant="primary") + + gr.Markdown("---") + gr.Markdown("## 💬 แชทบอทจำลองการสนทนา") + + chatbot = gr.Chatbot( + label="💭 การสนทนากับ AI", + height=500, show_label=True, type="messages", + elem_classes=["chat-container"], avatar_images=("👤", "🤖") + ) + + with gr.Row(): + with gr.Column(scale=4): + msg = gr.Textbox( + label="💬 พิมพ์ข้อความของคุณ", + placeholder="พิมพ์คำถามหรือข้อมูล...", + lines=2, max_lines=8, + ) + with gr.Column(scale=1): + send_btn = gr.Button("📤 ส่งข้อความ", variant="primary") + followup_btn = gr.Button("🔔 สร้างการวิเคราะห์บทสนทนา", variant="secondary") + update_summary_btn = gr.Button("📋 บังคับสรุปแชท (dev)", variant="secondary") + + with gr.Row(): + clear_chat_btn = gr.Button("🗑️ ล้าง Session", variant="secondary") + clear_summary_btn = gr.Button("📝 ล้างสรุป", variant="secondary") + + # Small helpers for button UX + def set_button_loading(text): return gr.update(value=text, elem_classes=["red-button"], variant="stop") + def reset_button(text, variant): return gr.update(value=text, elem_classes=[], variant=variant) + + # Wiring + refresh_btn.click(fn=refresh_session_list, outputs=[session_dropdown]) + + switch_btn.click( + fn=switch_session, + inputs=[session_dropdown], + outputs=[chatbot, msg, session_result, session_status, health_context], + ) + + new_session_btn.click( + fn=create_new_session, + inputs=[health_context], + outputs=[chatbot, msg, session_result, session_status, health_context], + ) + + session_info_btn.click(fn=get_session_info, outputs=[session_info_display]) + all_sessions_btn.click(fn=get_all_sessions_info, outputs=[session_info_display]) + export_btn.click(fn=export_all_sessions, outputs=[session_info_display]) + export_btn.click(fn=lambda: set_button_loading("⏳ ประมวลผล..."), outputs=[export_btn]) \ + .then(fn=export_all_sessions) \ + .then(fn=lambda: reset_button("📤 ส่งออกข้อมูลทั้งหมดเป็น.json (dev)", variant="secondary"), outputs=[export_btn]) + + update_saved_memories_btn.click( + fn=update_medical_saved_memories, + inputs=[health_context], + outputs=[session_status, session_result], + ) + + send_btn.click(fn=lambda: set_button_loading("⏳ ประมวลผล..."), outputs=[send_btn]) \ + .then(fn=process_chat_message, inputs=[msg, chatbot], outputs=[chatbot, msg]) \ + .then(fn=lambda: reset_button("📤 ส่งข้อความ", "primary"), outputs=[send_btn]) + + msg.submit(fn=process_chat_message, inputs=[msg, chatbot], outputs=[chatbot, msg]) + + followup_btn.click(fn=lambda: set_button_loading("⏳ ประมวลผล..."), outputs=[followup_btn]) \ + .then(fn=generate_followup, inputs=[chatbot], outputs=[chatbot]) \ + .then(fn=lambda: reset_button("🔔 สร้างการวิเคราะห์บทสนทนา", "secondary"), outputs=[followup_btn]) + + update_summary_btn.click(fn=lambda: set_button_loading("⏳ กำลังสรุป..."), outputs=[update_summary_btn]) \ + .then(fn=force_update_summary, outputs=[session_result]) \ + .then(fn=lambda: reset_button("📋 บังคับสรุปแชท (dev)", "secondary"), outputs=[update_summary_btn]) + + clear_chat_btn.click(fn=lambda: set_button_loading("⏳ กำลังลบ..."), outputs=[clear_chat_btn]) \ + .then(fn=clear_session, outputs=[chatbot, msg, session_result, session_status, health_context]) \ + .then(fn=lambda: reset_button("🗑️ ล้าง Session", "secondary"), outputs=[clear_chat_btn]) + + clear_summary_btn.click(fn=lambda: set_button_loading("⏳ กำลังล้าง..."), outputs=[clear_summary_btn]) \ + .then(fn=clear_all_summaries, outputs=[session_result]) \ + .then(fn=lambda: reset_button("📝 ล้างสรุป", "secondary"), outputs=[clear_summary_btn]) + + return app + +def main(): + app = create_app() + # Resolve bind address and port + server_name = os.getenv("GRADIO_SERVER_NAME", "0.0.0.0") + server_port = int(os.getenv("PORT", os.getenv("GRADIO_SERVER_PORT", 8080))) + + # Basic health log to confirm listening address + try: + hostname = socket.gethostname() + ip_addr = socket.gethostbyname(hostname) + except Exception: + hostname = "unknown" + ip_addr = "unknown" + + logger.info( + "Starting Gradio app | bind=%s:%s | host=%s ip=%s", + server_name, + server_port, + hostname, + ip_addr, + ) + logger.info( + "Env: PORT=%s GRADIO_SERVER_NAME=%s GRADIO_SERVER_PORT=%s", + os.getenv("PORT"), + os.getenv("GRADIO_SERVER_NAME"), + os.getenv("GRADIO_SERVER_PORT"), + ) + # Secrets presence check (mask values) + def _mask(v: str | None) -> str: + if not v: + return "" + return f"set(len={len(v)})" + logger.info( + "Secrets: SEA_LION_API_KEY=%s GEMINI_API_KEY=%s", + _mask(os.getenv("SEA_LION_API_KEY")), + _mask(os.getenv("GEMINI_API_KEY")), + ) + + app.launch( + share=True, + server_name=server_name, # cloud: 0.0.0.0, local: 127.0.0.1 + server_port=server_port, # cloud: $PORT, local: 7860/8080 + debug=True, + show_error=True, + inbrowser=True, + ) + +if __name__ == "__main__": + main() diff --git a/gui/simple_gui.py b/gui/simple_gui.py new file mode 100644 index 0000000000000000000000000000000000000000..a08610241886c4e27fa7dc55888a2401830eea33 --- /dev/null +++ b/gui/simple_gui.py @@ -0,0 +1,156 @@ +# simple_chat_app.py +import gradio as gr +from datetime import datetime + +# Your existing manager +from kallam.app.chatbot_manager import ChatbotManager + +mgr = ChatbotManager(log_level="INFO") + +# ----------------------- +# Core handlers +# ----------------------- +def _session_status(session_id: str) -> str: + if not session_id: + return "🔴 No active session. Click **New Session**." + s = mgr.get_session(session_id) or {} + ts = s.get("timestamp", "N/A") + model = s.get("model_used", "N/A") + total = s.get("total_messages", 0) + return ( + f"🟢 **Session:** `{session_id}` \n" + f"📅 Created: {ts} \n" + f"🤖 Model: {model} \n" + f"💬 Messages: {total}" + ) + +def start_new_session(saved_memories: str = ""): + """Create a brand-new session and return clean UI state.""" + sid = mgr.start_session(saved_memories=saved_memories or None) + status = _session_status(sid) + history = [] # gr.Chatbot(messages) shape: list[dict(role, content)] + return sid, history, "", status, "✅ New session created." + +def send_message(user_msg: str, history: list, session_id: str): + """Append user turn, get bot reply, and return updated history.""" + if not session_id: + # Defensive: if somehow no session, auto-create one + sid, history, _, status, _ = start_new_session("") + history.append({"role": "assistant", "content": "New session spun up automatically. Proceed."}) + return history, "", sid, status + + if not user_msg.strip(): + return history, "", session_id, _session_status(session_id) + + # User turn + history = history + [{"role": "user", "content": user_msg}] + # Bot turn (your manager handles persistence) + bot = mgr.handle_message(session_id=session_id, user_message=user_msg) + history = history + [{"role": "assistant", "content": bot}] + + return history, "", session_id, _session_status(session_id) + +def force_summary(session_id: str): + if not session_id: + return "❌ No active session." + try: + s = mgr.summarize_session(session_id) + return f"📋 Summary updated:\n\n{s}" + except Exception as e: + return f"❌ Failed to summarize: {e}" + +def lock_inputs(): + # disable send + textbox + return gr.update(interactive=False), gr.update(interactive=False) + +def unlock_inputs(): + # re-enable send + textbox + return gr.update(interactive=True), gr.update(interactive=True) + +# ----------------------- +# Gradio app +# ----------------------- +def create_app(): + with gr.Blocks(title="Minimal Therapeutic Chat Sessions") as demo: + gr.Markdown("# Minimal Chat Sessions • clean and boring but try me.") + + session_id = gr.State(value="") + with gr.Row(): + status_md = gr.Markdown(value="🔄 Initializing session...") + saved_memories = gr.Textbox( + label="เนื้อหาเกี่ยวกับคุณ (optional)", + placeholder="กด ➕ session ใหม่เพื่อใช้งาน e.g., อายุ, เพศ, นิสัย", + lines=2, + ) + new_btn = gr.Button("➕ session ใหม่", variant="primary") + # summarize_btn = gr.Button("📋 Summarize", variant="secondary") + + chat = gr.Chatbot(label="Chat", type="messages", height=420) + with gr.Row(): + msg = gr.Textbox(label="Message box", placeholder="พิมพ์ข้อความ", lines=1, scale=9) + send = gr.Button("↩", variant="primary", scale=1, min_width=40) + + result_md = gr.Markdown(visible=True) + + # -------- wiring -------- + # On page load: create a fresh session + def _init(): + sid, history, _, status, note = start_new_session("") + return sid, history, status, note + demo.load(_init, inputs=None, outputs=[session_id, chat, status_md, result_md]) + + # New session button + def _new(saved): + sid, history, _, status, note = start_new_session(saved or "") + return sid, history, "", status, note + new_btn.click(_new, inputs=[saved_memories], outputs=[session_id, chat, msg, status_md, result_md]) + + # Click flow: lock -> send -> unlock + send.click( + fn=lock_inputs, + inputs=None, + outputs=[send, msg], + queue=False, # lock applies instantly + ).then( + fn=send_message, + inputs=[msg, chat, session_id], + outputs=[chat, msg, session_id, status_md], + ).then( + fn=unlock_inputs, + inputs=None, + outputs=[send, msg], + queue=False, + ) + + # Enter/submit flow: same treatment + msg.submit( + fn=lock_inputs, + inputs=None, + outputs=[send, msg], + queue=False, + ).then( + fn=send_message, + inputs=[msg, chat, session_id], + outputs=[chat, msg, session_id, status_md], + ).then( + fn=unlock_inputs, + inputs=None, + outputs=[send, msg], + queue=False, + ) + + return demo + +def main(): + app = create_app() + app.launch( + share=True, + server_name="0.0.0.0", + server_port=7860, + debug=False, + show_error=True, + inbrowser=True, + ) + +if __name__ == "__main__": + main() diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000000000000000000000000000000000000..a819c8a0a42f6f4ddac5fda4fe6f61fd787cf1e4 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,53 @@ +[build-system] +requires = ["setuptools>=69", "wheel"] +build-backend = "setuptools.build_meta" + +[project] +name = "kallam" +version = "0.1.1" +description = "KaLLaM: Multi-agent chatbot with orchestration, LLM evaluation, and SQLite persistence" +authors = [ + { name = "Koalar" } +] +readme = "README.md" +requires-python = ">=3.10" + +dependencies = [ + "python-dotenv>=1.0.1", # for load_dotenv + "strands-agents>=0.1.0", # strands Agent + BedrockModel + "strands-agents-tools", + "strands-agents-builder", + "strands-agents[openai]>=1.0.0", + "google-genai", # google cloud api + "openai>=1.40.0", + "boto3>=1.34.0", # AWS SDK (brings in botocore) + "numpy>=1.26.0", # numerical utils + "sentence-transformers>=2.6.0", # embeddings + "transformers>=4.40.0", # Hugging Face transformers + "gradio>=4.0.0", # UI + "pyngrok==7.3.0", # Hosting gradio interface + "matplotlib" +] + +[project.optional-dependencies] +dev = [ + "pytest>=7.0", # testing + "pytest-cov>=4.0", # coverage reports + "ruff>=0.4.0", # linting + "mypy>=1.10.0", # static typing +] + +[tool.setuptools] +package-dir = {"" = "src"} + +[tool.pytest.ini_options] +testpaths = ["tests"] +addopts = "-q" + +[tool.ruff] +line-length = 100 +target-version = "py310" + +[tool.mypy] +python_version = "3.10" +strict = true diff --git a/src/kallam.egg-info/PKG-INFO b/src/kallam.egg-info/PKG-INFO new file mode 100644 index 0000000000000000000000000000000000000000..7b95bce76ada3bb3bdc9671df82fd81289f70247 --- /dev/null +++ b/src/kallam.egg-info/PKG-INFO @@ -0,0 +1,174 @@ +Metadata-Version: 2.4 +Name: kallam +Version: 0.1.0 +Summary: KaLLaM: Multi-agent chatbot with orchestration, LLM evaluation, and SQLite persistence +Author: Koalar +Requires-Python: >=3.10 +Description-Content-Type: text/markdown +License-File: LICENSE +Requires-Dist: python-dotenv>=1.0.1 +Requires-Dist: strands-agents>=0.1.0 +Requires-Dist: strands-agents-tools +Requires-Dist: strands-agents-builder +Requires-Dist: strands-agents[openai]>=1.0.0 +Requires-Dist: google-genai +Requires-Dist: openai>=1.40.0 +Requires-Dist: boto3>=1.34.0 +Requires-Dist: numpy>=1.26.0 +Requires-Dist: sentence-transformers>=2.6.0 +Requires-Dist: transformers>=4.40.0 +Requires-Dist: gradio>=4.0.0 +Requires-Dist: pyngrok==7.3.0 +Requires-Dist: matplotlib +Provides-Extra: dev +Requires-Dist: pytest>=7.0; extra == "dev" +Requires-Dist: pytest-cov>=4.0; extra == "dev" +Requires-Dist: ruff>=0.4.0; extra == "dev" +Requires-Dist: mypy>=1.10.0; extra == "dev" +Dynamic: license-file + + +--- + +# KaLLaM – Motivational-Therapeutic Advisor + +> **Note to future stupid self**: You will forget everything. This file exists so you don’t scream at your computer in six months. Read it first. + +--- + +## 🚀 Quickstart + +1. **Create venv** + Windows (PowerShell): + + ```powershell + python -m venv .venv + .venv\Scripts\Activate.ps1 + ``` + + Linux/macOS: + + ```bash + python -m venv .venv + source .venv/bin/activate + ``` + +2. **Upgrade pip** + + ```bash + python -m pip install -U pip + ``` + +3. **Install project (runtime only)** + + ```bash + pip install -e . + ``` + +4. **Install project + dev tools (pytest, mypy, ruff)** + + ```bash + pip install -e .[dev] + ``` + +5. **Run tests** + + ```bash + pytest -q + ``` + +--- + +## 📂 Project layout (don’t mess this up) + +``` +project-root/ +├─ pyproject.toml # dependencies & config (editable mode uses src/) +├─ README.md # you are here +├─ src/ +│ └─ kallam/ +│ ├─ __init__.py +│ ├─ app/ # orchestrator wiring, chatbot manager +│ ├─ domain/ # agents, judges, orchestrator logic +│ └─ infra/ # db, llm clients, search, token counter +└─ tests/ # pytest lives here +``` + +* `app/` = entrypoint, wires everything. +* `domain/` = core logic (agents, judges, orchestrator rules). +* `infra/` = all the boring adapters (DB, APIs, token counting). +* `tests/` = if you don’t write them, you’ll break everything and blame Python. + +--- + +## 🔑 Environment variables + +Put these in `.env` at project root: + +``` +OPENAI_API_KEY=sk-... +SEA_LION_API_KEY=... +AWS_ROLE_ARN=... +AWS_DEFAULT_REGION=ap-southeast-2 +TAVILY_API_KEY=... +``` + +Load automatically via `python-dotenv`. + +--- + +## 🧪 Common commands + +* Run chatbot manager manually: + + ```bash + python -m kallam.app.chatbot_manager + ``` + +* Run lint: + + ```bash + ruff check src tests + ``` + +* Run type check: + + ```bash + mypy src + ``` + +* Export a session JSON (example): + + ```python + from kallam.app.chatbot_manager import ChatbotManager + mgr = ChatbotManager() + sid = mgr.start_session() + mgr.handle_message(sid, "hello world") + mgr.export_session_json(sid) + ``` + +--- + +## 🧹 Rules for survival + +* Always activate `.venv` before coding. +* Never `pip install` globally, always `pip install -e .[dev]` inside venv. +* If imports fail → you forgot editable install. Run `pip install -e .` again. +* If SQLite locks up → delete `*.db` files and start fresh. +* If you break `pyproject.toml` → copy it from git history, don’t wing it. + +--- + +## ☠️ Known pitfalls + +* **Windows error “source not recognized”** → you’re not on Linux. Use `.venv\Scripts\Activate.ps1`. +* **“No module named kallam”** → you didn’t install with `-e`. +* **Tests can’t import your code** → run `pytest` from project root, not inside `tests/`. +* **Pip complains about `[dev]`** → you typo’d in `pyproject.toml`. Fix the `[project.optional-dependencies]` block. + +--- + +That’s it. If you follow this, future you won’t rage-quit. + +--- + diff --git a/src/kallam.egg-info/SOURCES.txt b/src/kallam.egg-info/SOURCES.txt new file mode 100644 index 0000000000000000000000000000000000000000..810bf8a10695ff5c6aa7768d058d59a5758946a5 --- /dev/null +++ b/src/kallam.egg-info/SOURCES.txt @@ -0,0 +1,32 @@ +LICENSE +README.md +pyproject.toml +src/kallam/__init__.py +src/kallam.egg-info/PKG-INFO +src/kallam.egg-info/SOURCES.txt +src/kallam.egg-info/dependency_links.txt +src/kallam.egg-info/requires.txt +src/kallam.egg-info/top_level.txt +src/kallam/app/__init__.py +src/kallam/app/chatbot_manager.py +src/kallam/app/services/summarizer.py +src/kallam/domain/__init__.py +src/kallam/domain/agents/__init__.py +src/kallam/domain/agents/chatbot_prompt.py +src/kallam/domain/agents/doctor.py +src/kallam/domain/agents/orchestrator.py +src/kallam/domain/agents/psychologist.py +src/kallam/domain/agents/summarizer.py +src/kallam/domain/agents/supervisor.py +src/kallam/domain/agents/supervisor_bedrock.py +src/kallam/domain/agents/translator.py +src/kallam/domain/judges/__init__.py +src/kallam/infra/__init__.py +src/kallam/infra/config.py +src/kallam/infra/db.py +src/kallam/infra/exporter.py +src/kallam/infra/message_store.py +src/kallam/infra/session_store.py +src/kallam/infra/summary_store.py +src/kallam/infra/token_counter.py +src/kallam/interfaces/__init__.py \ No newline at end of file diff --git a/src/kallam.egg-info/dependency_links.txt b/src/kallam.egg-info/dependency_links.txt new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/src/kallam.egg-info/dependency_links.txt @@ -0,0 +1 @@ + diff --git a/src/kallam.egg-info/requires.txt b/src/kallam.egg-info/requires.txt new file mode 100644 index 0000000000000000000000000000000000000000..0ac2e0308ff7dde1fdc4ce8333e3d4baae4cce89 --- /dev/null +++ b/src/kallam.egg-info/requires.txt @@ -0,0 +1,20 @@ +python-dotenv>=1.0.1 +strands-agents>=0.1.0 +strands-agents-tools +strands-agents-builder +strands-agents[openai]>=1.0.0 +google-genai +openai>=1.40.0 +boto3>=1.34.0 +numpy>=1.26.0 +sentence-transformers>=2.6.0 +transformers>=4.40.0 +gradio>=4.0.0 +pyngrok==7.3.0 +matplotlib + +[dev] +pytest>=7.0 +pytest-cov>=4.0 +ruff>=0.4.0 +mypy>=1.10.0 diff --git a/src/kallam.egg-info/top_level.txt b/src/kallam.egg-info/top_level.txt new file mode 100644 index 0000000000000000000000000000000000000000..32e3c2b85c6b12119fa4b145f144b4d817215d84 --- /dev/null +++ b/src/kallam.egg-info/top_level.txt @@ -0,0 +1 @@ +kallam diff --git a/src/kallam/__init__.py b/src/kallam/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..ad8d94a82006bdb5bff948e8b76a9718878b8e9c --- /dev/null +++ b/src/kallam/__init__.py @@ -0,0 +1,4 @@ +"""Top-level package for KaLLaM.""" + +file = __file__ + diff --git a/src/kallam/__pycache__/__init__.cpython-311.pyc b/src/kallam/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c850d03e8c5a7008dd5ae335d6a3fd727fcd810e Binary files /dev/null and b/src/kallam/__pycache__/__init__.cpython-311.pyc differ diff --git a/src/kallam/app/__init__.py b/src/kallam/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/kallam/app/__pycache__/__init__.cpython-311.pyc b/src/kallam/app/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..73658b0ffc525ceba03e41088f8865e7a0fac5c3 Binary files /dev/null and b/src/kallam/app/__pycache__/__init__.cpython-311.pyc differ diff --git a/src/kallam/app/__pycache__/chatbot_manager.cpython-311.pyc b/src/kallam/app/__pycache__/chatbot_manager.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..2e7c31ef09662e4f3e9463b205fd3c4442aee620 Binary files /dev/null and b/src/kallam/app/__pycache__/chatbot_manager.cpython-311.pyc differ diff --git a/src/kallam/app/chatbot_manager.py b/src/kallam/app/chatbot_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..1dec42adefe0d00ec57a21db5a783459279ecc3c --- /dev/null +++ b/src/kallam/app/chatbot_manager.py @@ -0,0 +1,412 @@ +# src/your_pkg/app/chatbot_manager.py +from __future__ import annotations + +import json +import logging +import os +import threading +import time +import uuid +from dataclasses import dataclass +from datetime import datetime, timedelta +from pathlib import Path +from typing import Optional, Dict, List, Any +from functools import wraps +from contextvars import ContextVar + +# Keep your orchestrator import AS-IS to avoid ripples. +from kallam.domain.agents.orchestrator import Orchestrator + +from kallam.infra.session_store import SessionStore +from kallam.infra.message_store import MessageStore +from kallam.infra.summary_store import SummaryStore +from kallam.infra.exporter import JsonExporter +from kallam.infra.token_counter import TokenCounter +from kallam.infra.db import sqlite_conn # for the cleanup method + +# ----------------------------------------------------------------------------- +# Logging setup (configurable) +# ----------------------------------------------------------------------------- + +_request_id: ContextVar[str] = ContextVar("_request_id", default="-") + +class RequestIdFilter(logging.Filter): + def filter(self, record: logging.LogRecord) -> bool: + record.request_id = _request_id.get() + return True + +def _setup_logging(level: Optional[str] = None, json_mode: bool = False, logger_name: str = "kallam.chatbot"): + lvl = (level or os.getenv("LOG_LEVEL", "INFO")).upper() + root = logging.getLogger() + # Avoid duplicate handlers if constructed multiple times in REPL/tests + if not any(isinstance(h, logging.StreamHandler) for h in root.handlers): + handler = logging.StreamHandler() + if json_mode or os.getenv("LOG_JSON", "0") in {"1", "true", "True"}: + fmt = '{"ts":"%(asctime)s","lvl":"%(levelname)s","logger":"%(name)s","req":"%(request_id)s","msg":"%(message)s"}' + else: + fmt = "%(asctime)s | %(levelname)-7s | %(name)s | req=%(request_id)s | %(message)s" + handler.setFormatter(logging.Formatter(fmt)) + handler.addFilter(RequestIdFilter()) + root.addHandler(handler) + root.setLevel(lvl) + return logging.getLogger(logger_name) + +logger = _setup_logging() # default init; can be reconfigured via ChatbotManager args + +def _with_trace(level: int = logging.INFO): + """ + Decorator to visualize call sequence with timing and exceptions. + Uses the same request_id context if already set, or creates one. + """ + def deco(fn): + @wraps(fn) + def wrapper(self, *args, **kwargs): + rid = _request_id.get() + created_here = False + if rid == "-" and fn.__name__ in {"handle_message", "start_session"}: + rid = uuid.uuid4().hex[:8] + _request_id.set(rid) + created_here = True + logger.log(level, f"→ {fn.__name__}") + t0 = time.time() + try: + out = fn(self, *args, **kwargs) + dt = int((time.time() - t0) * 1000) + logger.log(level, f"← {fn.__name__} done in {dt} ms") + return out + except Exception: + logger.exception(f"✖ {fn.__name__} failed") + raise + finally: + # Reset the request id when we originated it here + if created_here: + _request_id.set("-") + return wrapper + return deco + + +EXPORT_FOLDER = "exported_sessions" + +@dataclass +class SessionStats: + message_count: int = 0 + total_tokens_in: int = 0 + total_tokens_out: int = 0 + avg_latency: float = 0.0 + first_message: Optional[str] = None + last_message: Optional[str] = None + + +class ChatbotManager: + """ + Backward-compatible facade. Same constructor and methods as your original class. + Under the hood we delegate to infra stores and the orchestrator. + """ + + def __init__(self, + db_path: str = "chatbot_data.db", + summarize_every_n_messages: int = 10, + message_limit: int = 10, + sunmmary_limit: int = 20, + chain_of_thoughts_limit: int = 5, + # logging knobs + log_level: Optional[str] = None, + log_json: bool = False, + log_name: str = "kallam.chatbot", + trace_level: int = logging.INFO): + if summarize_every_n_messages <= 0: + raise ValueError("summarize_every_n_messages must be positive") + if message_limit <= 0: + raise ValueError("message_limit must be positive") + + # Reconfigure logger per instance if caller wants + global logger + logger = _setup_logging(level=log_level, json_mode=log_json, logger_name=log_name) + self._trace_level = trace_level + + self.orchestrator = Orchestrator() + self.sum_every_n = summarize_every_n_messages + self.message_limit = message_limit + self.summary_limit = sunmmary_limit + self.chain_of_thoughts_limit = chain_of_thoughts_limit + self.db_path = Path(db_path) + self.lock = threading.RLock() + self.tokens = TokenCounter(capacity=1000) + + # wire infra + db_url = f"sqlite:///{self.db_path}" + self.sessions = SessionStore(db_url) + self.messages = MessageStore(db_url) + self.summaries = SummaryStore(db_url) + self.exporter = JsonExporter(db_url, out_dir=EXPORT_FOLDER) + + # ensure schema exists + self._ensure_schema() + + logger.info(f"ChatbotManager initialized with database: {self.db_path}") + + # ---------- schema bootstrap ---------- + @_with_trace() + def _ensure_schema(self) -> None: + ddl_sessions = """ + CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + timestamp TEXT NOT NULL, + last_activity TEXT NOT NULL, + saved_memories TEXT, + total_messages INTEGER DEFAULT 0, + total_user_messages INTEGER DEFAULT 0, + total_assistant_messages INTEGER DEFAULT 0, + total_summaries INTEGER DEFAULT 0, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + is_active BOOLEAN DEFAULT 1 + ); + """ + ddl_messages = """ + CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + message_id TEXT UNIQUE NOT NULL, + timestamp TEXT NOT NULL, + role TEXT NOT NULL CHECK(role IN ('user','assistant','system')), + content TEXT NOT NULL, + translated_content TEXT, + chain_of_thoughts TEXT, + tokens_input INTEGER DEFAULT 0, + tokens_output INTEGER DEFAULT 0, + latency_ms INTEGER, + flags TEXT, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(session_id) REFERENCES sessions(session_id) ON DELETE CASCADE + ); + """ + ddl_summaries = """ + CREATE TABLE IF NOT EXISTS summaries ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + session_id TEXT NOT NULL, + timestamp TEXT NOT NULL, + summary TEXT NOT NULL, + created_at TEXT DEFAULT CURRENT_TIMESTAMP, + FOREIGN KEY(session_id) REFERENCES sessions(session_id) ON DELETE CASCADE + ); + """ + idx = [ + "CREATE INDEX IF NOT EXISTS idx_messages_session_id ON messages(session_id)", + "CREATE INDEX IF NOT EXISTS idx_messages_timestamp ON messages(timestamp DESC)", + "CREATE INDEX IF NOT EXISTS idx_sessions_last_activity ON sessions(last_activity DESC)", + "CREATE INDEX IF NOT EXISTS idx_summaries_session_id ON summaries(session_id)", + "CREATE INDEX IF NOT EXISTS idx_messages_role ON messages(role)" + ] + with sqlite_conn(str(self.db_path)) as c: + c.execute(ddl_sessions) + c.execute(ddl_messages) + c.execute(ddl_summaries) + for q in idx: + c.execute(q) + + # ---------- validation/util ---------- + def _validate_inputs(self, **kwargs): + validators = { + 'user_message': lambda x: bool(x and str(x).strip()), + 'session_id': lambda x: bool(x), + 'role': lambda x: x in ('user', 'assistant', 'system'), + } + for k, v in kwargs.items(): + fn = validators.get(k) + if fn and not fn(v): + raise ValueError(f"Invalid {k}: {v}") + + # ---------- public API ---------- + @_with_trace() + def start_session(self, saved_memories: Optional[str] = None) -> str: + sid = self.sessions.create(saved_memories=saved_memories) + logger.debug(f"start_session: saved_memories_len={len(saved_memories or '')} session_id={sid}") + return sid + + @_with_trace() + def get_session(self, session_id: str) -> Optional[Dict[str, Any]]: + return self.sessions.get_meta(session_id) + + @_with_trace() + def list_sessions(self, active_only: bool = True, limit: int = 50) -> List[Dict[str, Any]]: + return self.sessions.list(active_only=active_only, limit=limit) + + @_with_trace() + def close_session(self, session_id: str) -> bool: + return self.sessions.close(session_id) + + @_with_trace() + def delete_session(self, session_id: str) -> bool: + return self.sessions.delete(session_id) + + @_with_trace() + def cleanup_old_sessions(self, days_old: int = 30) -> int: + if days_old <= 0: + raise ValueError("days_old must be positive") + cutoff = (datetime.now() - timedelta(days=days_old)).isoformat() + return self.sessions.cleanup_before(cutoff) + + @_with_trace() + def handle_message(self, session_id: str, user_message: str) -> str: + # Ensure one correlation id per request flow + if _request_id.get() == "-": + _request_id.set(uuid.uuid4().hex[:8]) + + self._validate_inputs(session_id=session_id, user_message=user_message) + with self.lock: + t0 = time.time() + + # ensure session exists + if not self.get_session(session_id): + raise ValueError(f"Session {session_id} not found") + + # fetch context + original_history = self.messages.get_original_history(session_id, limit=self.message_limit) + eng_history = self.messages.get_translated_history(session_id, limit=self.message_limit) + eng_summaries = self.summaries.list(session_id, limit=self.summary_limit) + chain = self.messages.get_reasoning_traces(session_id, limit=self.chain_of_thoughts_limit) + + meta = self.sessions.get_meta(session_id) or {} + memory_context = (meta.get("saved_memories") or "") if isinstance(meta, dict) else "" + + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "context pulled: history=%d summaries=%d chain=%d mem_len=%d", + len(eng_history or []), len(eng_summaries or []), len(chain or []), len(memory_context), + ) + + # flags and translation + flags = self._get_flags_dict(session_id, user_message) + if logger.isEnabledFor(logging.DEBUG): + # keep flags concise if large + short_flags = {k: (v if isinstance(v, (int, float, bool, str)) else "…") for k, v in (flags or {}).items()} + logger.debug(f"flags: {short_flags}") + + eng_msg = self.orchestrator.get_translation( + message=user_message, flags=flags, translation_type="forward" + ) + + # respond + response_commentary = self.orchestrator.get_commented_response( + original_history=original_history, + original_message=user_message, + eng_history=eng_history, + eng_message=eng_msg, + flags=flags, + chain_of_thoughts=chain, + memory_context=memory_context, # type: ignore + summarized_histories=eng_summaries, + ) + + bot_message = response_commentary["final_output"] + bot_eng = self.orchestrator.get_translation( + message=bot_message, flags=flags, translation_type="forward" + ) + latency_ms = int((time.time() - t0) * 1000) + + # persist + tok_user = self.tokens.count(user_message) + tok_bot = self.tokens.count(bot_message) + + self.messages.append_user(session_id, content=user_message, + translated=eng_msg, flags=flags, tokens_in=tok_user) + self.messages.append_assistant(session_id, content=bot_message, + translated=bot_eng, reasoning=response_commentary, + tokens_out=tok_bot) + + if logger.isEnabledFor(logging.DEBUG): + logger.debug( + "persisted: tokens_in=%d tokens_out=%d latency_ms=%d", tok_user, tok_bot, latency_ms + ) + + # summarize checkpoint + meta = self.sessions.get_meta(session_id) + if meta and (meta["total_user_messages"] % self.sum_every_n == 0) and (meta["total_user_messages"] > 0): + logger.log(self._trace_level, f"checkpoint: summarizing session {session_id}") + self.summarize_session(session_id) + + return bot_message + + @_with_trace() + def _get_flags_dict(self, session_id: str, user_message: str) -> Dict[str, Any]: + self._validate_inputs(session_id=session_id) + try: + # Build context Supervisor expects + chat_history = self.messages.get_translated_history(session_id, limit=self.message_limit) or [] + summaries = self.summaries.list(session_id, limit=self.message_limit) or [] + meta = self.sessions.get_meta(session_id) or {} + memory_context = (meta.get("saved_memories") or "") if isinstance(meta, dict) else "" + + flags = self.orchestrator.get_flags_from_supervisor( + chat_history=chat_history, + user_message=user_message, + memory_context=memory_context, + summarized_histories=summaries + ) + return flags + except Exception as e: + logger.warning(f"Failed to get flags from supervisor: {e}, using safe defaults") + # Safe defaults keep the pipeline alive + return {"language": "english", "doctor": False, "psychologist": False} + + @_with_trace() + def summarize_session(self, session_id: str) -> str: + eng_history = self.messages.get_translated_history(session_id, limit=self.message_limit) + if not eng_history: + raise ValueError("No chat history found for session") + eng_summaries = self.summaries.list(session_id) + eng_summary = self.orchestrator.summarize_history( + chat_history=eng_history, eng_summaries=eng_summaries + ) + self.summaries.add(session_id, eng_summary) # type: ignore + logger.debug("summary_len=%d total_summaries=%d", len(eng_summary or ""), len(eng_summaries or []) + 1) + return eng_summary # type: ignore + + @_with_trace() + def get_session_stats(self, session_id: str) -> dict: + stats, session = self.messages.aggregate_stats(session_id) + stats_dict = { + "message_count": stats.get("message_count") or 0, + "total_tokens_in": stats.get("total_tokens_in") or 0, + "total_tokens_out": stats.get("total_tokens_out") or 0, + "avg_latency": float(stats.get("avg_latency") or 0), + "first_message": stats.get("first_message"), + "last_message": stats.get("last_message"), + } + logger.debug("stats: %s", stats_dict) + return { + "session_info": session, # already a dict from MessageStore + "stats": stats_dict, # plain dict + } + + @_with_trace() + def get_original_chat_history(self, session_id: str, limit: int | None = None) -> list[dict]: + self._validate_inputs(session_id=session_id) + if limit is None: + limit = self.message_limit + + # Fallback: direct query + with sqlite_conn(str(self.db_path)) as c: + rows = c.execute( + """ + SELECT role, content, timestamp + FROM messages + WHERE session_id = ? + ORDER BY id ASC + LIMIT ? + """, + (session_id, limit), + ).fetchall() + return [dict(r) for r in rows] + + @_with_trace() + def export_session_json(self, session_id: str) -> str: + path = self.exporter.export_session_json(session_id) + logger.info(f"exported session to {path}") + return path + + @_with_trace() + def export_all_sessions_json(self) -> str: + path = self.exporter.export_all_sessions_json() + logger.info(f"exported session to {path}") + return path diff --git a/src/kallam/domain/__init__.py b/src/kallam/domain/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/kallam/domain/__pycache__/__init__.cpython-311.pyc b/src/kallam/domain/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7435f8255b9b07b3177d1dc8e3a14b44113ce8dc Binary files /dev/null and b/src/kallam/domain/__pycache__/__init__.cpython-311.pyc differ diff --git a/src/kallam/domain/agents/__init__.py b/src/kallam/domain/agents/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/kallam/domain/agents/__pycache__/Single_agent.cpython-311.pyc b/src/kallam/domain/agents/__pycache__/Single_agent.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a79ad7468892007d38a411882cc31fd95f7190bb Binary files /dev/null and b/src/kallam/domain/agents/__pycache__/Single_agent.cpython-311.pyc differ diff --git a/src/kallam/domain/agents/__pycache__/__init__.cpython-311.pyc b/src/kallam/domain/agents/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e3edd87315473ef9241af516eb551a4fbb58e097 Binary files /dev/null and b/src/kallam/domain/agents/__pycache__/__init__.cpython-311.pyc differ diff --git a/src/kallam/domain/agents/__pycache__/doctor.cpython-311.pyc b/src/kallam/domain/agents/__pycache__/doctor.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..455da25c325a4c9035857aefe487c94daccf7304 Binary files /dev/null and b/src/kallam/domain/agents/__pycache__/doctor.cpython-311.pyc differ diff --git a/src/kallam/domain/agents/__pycache__/orchestrator.cpython-311.pyc b/src/kallam/domain/agents/__pycache__/orchestrator.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..c86c31e0bdb276455800feddcfee0a26872f23aa Binary files /dev/null and b/src/kallam/domain/agents/__pycache__/orchestrator.cpython-311.pyc differ diff --git a/src/kallam/domain/agents/__pycache__/psychologist.cpython-311.pyc b/src/kallam/domain/agents/__pycache__/psychologist.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..d1274d4e4acd897fa1a590d22a0e1952c20ad2ee Binary files /dev/null and b/src/kallam/domain/agents/__pycache__/psychologist.cpython-311.pyc differ diff --git a/src/kallam/domain/agents/__pycache__/summarizer.cpython-311.pyc b/src/kallam/domain/agents/__pycache__/summarizer.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0730ad73153d52c336f5c0c48f6e0d57c17a1ba2 Binary files /dev/null and b/src/kallam/domain/agents/__pycache__/summarizer.cpython-311.pyc differ diff --git a/src/kallam/domain/agents/__pycache__/supervisor.cpython-311.pyc b/src/kallam/domain/agents/__pycache__/supervisor.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..f155fad8be4ebc2fdfde29cc5dfefd39cfd4ac3d Binary files /dev/null and b/src/kallam/domain/agents/__pycache__/supervisor.cpython-311.pyc differ diff --git a/src/kallam/domain/agents/__pycache__/translator.cpython-311.pyc b/src/kallam/domain/agents/__pycache__/translator.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8fe61e328ee5f2507362393448733ee8fa1ea819 Binary files /dev/null and b/src/kallam/domain/agents/__pycache__/translator.cpython-311.pyc differ diff --git a/src/kallam/domain/agents/doctor.py b/src/kallam/domain/agents/doctor.py new file mode 100644 index 0000000000000000000000000000000000000000..32e148fe7e36bf8e58a98de9173d36dda13ba695 --- /dev/null +++ b/src/kallam/domain/agents/doctor.py @@ -0,0 +1,215 @@ +import os +import logging +import requests +import re +from google import genai +from pathlib import Path +from datetime import datetime +from typing import List, Literal, Dict, Any, Optional + +from dotenv import load_dotenv +load_dotenv() + + +class DoctorAgent: + SeverityLevel = Literal["low", "moderate", "high", "emergency"] + RecommendationType = Literal["self_care", "consult_gp", "urgent_care", "emergency"] + + def __init__(self, log_level: int = logging.INFO): + self._setup_logging(log_level) + self._setup_api_clients() + + self.logger.info("Doctor Agent initialized successfully with Gemini API") + + self.system_prompt = """ +**Your Role:** +You are an expert doctor assisting medical personnel. You provide helpful medical information and guidance while being extremely careful about medical advice. + +**Core Rules:** +- Recommend consulting a healthcare professional for serious cases +- Advice specific diagnosis based on the context with variable confidence on each if there is any +- Keep your advice very concise and use medical keywords as it will be use only for advice for a expert medical personnel +- You only response based on the provided JSON format + +**Specific Task:** +- Assess symptom severity and provide appropriate recommendations +- Offer first aid guidance for emergency situations + +**Response Guidelines:** +- Recommend clarifying questions when needed +- Use clear, actionable for guidance +- Include appropriate medical disclaimers +- Use structured assessment approach +- Respond in the user's preferred language when specified +- Do not provide any reasons to your "Diagnosis" confidence + +**Output Format in JSON:** +{"Recommendations": [one or two most priority recommendation or note for medical personnel] +"Diagnosis 0-10 with Confidence": {[Disease | Symptom]: [Confidence 0-10]} +"Doctor Plan": [short plan for your future self and medical personnel]} +""" + + def _setup_logging(self, log_level: int) -> None: + log_dir = Path("logs") + log_dir.mkdir(exist_ok=True) + self.logger = logging.getLogger(f"{__name__}.DoctorAgent") + self.logger.setLevel(log_level) + if self.logger.handlers: + self.logger.handlers.clear() + file_handler = logging.FileHandler( + log_dir / f"doctor_{datetime.now().strftime('%Y%m%d')}.log", + encoding='utf-8' + ) + file_handler.setLevel(logging.DEBUG) + console_handler = logging.StreamHandler() + console_handler.setLevel(log_level) + formatter = logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s') + file_handler.setFormatter(formatter) + console_handler.setFormatter(formatter) + self.logger.addHandler(file_handler) + self.logger.addHandler(console_handler) + + def _setup_api_clients(self) -> None: + # Initialize Gemini if available; otherwise, degrade gracefully + raw_gemini_key = os.getenv("GEMINI_API_KEY", "") + self.gemini_api_key = raw_gemini_key.strip() + if raw_gemini_key and not self.gemini_api_key: + self.logger.warning("GEMINI_API_KEY contained only whitespace after stripping") + if self.gemini_api_key: + try: + self.gemini_client = genai.Client(api_key=self.gemini_api_key) + self.gemini_model_name = "gemini-2.5-flash-lite" + self.gemini_enabled = True + self.logger.info(f"Gemini API client initialized with model: {self.gemini_model_name}") + except Exception as e: + self.gemini_enabled = False + self.logger.error( + "Failed to initialize Gemini client (%s): %s", + e.__class__.__name__, + str(e), + ) + else: + self.gemini_enabled = False + self.logger.warning("GEMINI_API_KEY not set. DoctorAgent will return fallback responses.") + + def _format_prompt_gemini(self, message_context: str, medical_context: str = "") -> str: + now = datetime.now() + current_context = f""" +**Current Context:** +- Date/Time: {now.strftime("%Y-%m-%d %H:%M:%S")} +- Medical Context: {medical_context} +""" + + prompt = f"""{self.system_prompt} +{current_context} +**Patient Query:** {message_context} + +Please provide your medical guidance following the guidelines above.""" + + return prompt + + def _generate_response_gemini(self, prompt: str) -> str: + # If Gemini unavailable, return a safe fallback immediately + if not getattr(self, "gemini_enabled", False): + return "ขออภัยค่ะ ขณะนี้ไม่สามารถให้คำแนะนำทางการแพทย์เชิงลึกได้ กรุณาลองใหม่อีกครั้งหรือปรึกษาผู้เชี่ยวชาญค่ะ" + + try: + self.logger.debug(f"Sending prompt to Gemini API (length: {len(prompt)} chars)") + + response = self.gemini_client.models.generate_content( + model=self.gemini_model_name, + contents=[prompt], + ) + + response_text = response.text + + if response_text is None or (isinstance(response_text, str) and response_text.strip() == ""): + self.logger.error("Gemini API returned None or empty content") + return "ขออภัยค่ะ ไม่สามารถสร้างคำตอบได้ในขณะนี้" + + # Extract answer block if present + answer_match = re.search(r"```answer\s*(.*?)\s*```", response_text, re.DOTALL) + commentary = answer_match.group(1).strip() if answer_match else response_text.strip() + + self.logger.info(f"Generated medical response - Commentary: {len(commentary)} chars") + return commentary + + except Exception as e: + self.logger.error("Error generating Gemini response (%s): %s", e.__class__.__name__, str(e)) + return "ขออภัยค่ะ เกิดปัญหาในการเชื่อมต่อ กรุณาลองใหม่อีกครั้งค่ะ" + + def analyze(self, user_message: str, chat_history: List[Dict], chain_of_thoughts: str = "", summarized_histories: str = "") -> str: + """ + Main analyze method expected by orchestrator. + Returns a single commentary string. + """ + context_parts = [] + if summarized_histories: + context_parts.append(f"Patient History Summary: {summarized_histories}") + if chain_of_thoughts: + context_parts.append(f"Your Previous Recommendations: {chain_of_thoughts}") + + recent_context = [] + for msg in chat_history[-3:]: + if msg.get("role") == "user": + recent_context.append(f"Patient: {msg.get('content', '')}") + elif msg.get("role") == "assistant": + recent_context.append(f"Medical Personnel: {msg.get('content', '')}") + if recent_context: + context_parts.append("Recent Conversation:\n" + "\n".join(recent_context)) + + full_context = "\n\n".join(context_parts) if context_parts else "" + + message_context = f""" + +Current Patient Message: {user_message} +Available Context: +{full_context if full_context else "No previous context available"} +""" + + prompt = self._format_prompt_gemini(message_context=message_context) + print(prompt) + return self._generate_response_gemini(prompt) + + +if __name__ == "__main__": + # Minimal reproducible demo for DoctorAgent using existing analyze() method + + # 1) Create the agent + try: + doctor = DoctorAgent(log_level=logging.DEBUG) + except Exception as e: + print(f"[BOOT ERROR] Unable to start DoctorAgent: {e}") + raise SystemExit(1) + + # 2) Dummy chat history (what the user and assistant said earlier) + chat_history = [ + {"role": "user", "content": "Hi, I've been having some stomach issues lately."}, + {"role": "assistant", "content": "I'm sorry to hear about your stomach issues. Can you tell me more about the symptoms?"} + ] + + # 3) Chain of thoughts from previous analysis + chain_of_thoughts = "Patient reports digestive issues, need to assess severity and duration." + + # 4) Summarized patient history + summarized_histories = "Previous sessions: Patient is 25 y/o, works in high-stress environment, irregular eating habits, drinks 3-4 cups of coffee daily." + + # 5) Current user message about medical concern + user_message = "I've been having sharp stomach pains after eating, and I feel nauseous. It's been going on for about a week now." + + # ===== Test: Medical Analysis ===== + print("\n=== DOCTOR AGENT TEST ===") + print(f"User Message: {user_message}") + print(f"Chat History Length: {len(chat_history)}") + print(f"Context: {summarized_histories}") + print("\n=== MEDICAL ANALYSIS RESULT ===") + + medical_response = doctor.analyze( + user_message=user_message, + chat_history=chat_history, + chain_of_thoughts=chain_of_thoughts, + summarized_histories=summarized_histories + ) + + print(medical_response) + print("\n=== TEST COMPLETED ===") diff --git a/src/kallam/domain/agents/orchestrator.py b/src/kallam/domain/agents/orchestrator.py new file mode 100644 index 0000000000000000000000000000000000000000..57a8df872654d09fc8e488e04ed276cd46b43509 --- /dev/null +++ b/src/kallam/domain/agents/orchestrator.py @@ -0,0 +1,206 @@ +import json +import logging +from typing import Optional, Dict, Any, List +from functools import wraps +import time + +# Load agents +from .supervisor import SupervisorAgent +from .summarizer import SummarizerAgent +from .translator import TranslatorAgent +from .doctor import DoctorAgent +from .psychologist import PsychologistAgent + + +def _trace(level: int = logging.INFO): + """Lightweight enter/exit trace with timing; relies on parent handlers/format.""" + def deco(fn): + @wraps(fn) + def wrapper(self, *args, **kwargs): + self.logger.log(level, f"→ {fn.__name__}") + t0 = time.time() + try: + out = fn(self, *args, **kwargs) + dt = int((time.time() - t0) * 1000) + self.logger.log(level, f"← {fn.__name__} done in {dt} ms") + return out + except Exception: + self.logger.exception(f"✖ {fn.__name__} failed") + raise + return wrapper + return deco + + +class Orchestrator: + # ---------------------------------------------------------------------------------------------- + # Initialization + + def __init__(self, log_level: int | None = None, logger_name: str = "kallam.chatbot.orchestrator"): + """ + log_level: if provided, sets this logger's level; otherwise inherit from parent. + logger_name: child logger under the manager's logger hierarchy. + """ + self._setup_logging(log_level, logger_name) + + # Initialize available agents + self.supervisor = SupervisorAgent() + self.summarizer = SummarizerAgent() + self.translator = TranslatorAgent() + self.doctor = DoctorAgent() + self.psychologist = PsychologistAgent() + + # Optional config (model names, thresholds, etc.) + self.config = { + "default_model": "gpt-4o", + "translation_model": "gpt-4o", + "summarization_model": "gpt-4o", + "doctor_model": "gpt-4o", + "psychologist_model": "gpt-4o", + "similarity_threshold": 0.75, + "supported_languages": {"thai", "english"}, + "agents_language": "english" + } + + eff = logging.getLevelName(self.logger.getEffectiveLevel()) + self.logger.info(f"KaLLaM agents manager initialized. Effective log level: {eff}") + + def _setup_logging(self, log_level: int | None, logger_name: str) -> None: + """ + Use a child logger so we inherit handlers/formatters/filters (incl. request_id) + from the ChatbotManager root logger setup. We do NOT add handlers here. + """ + self.logger = logging.getLogger(logger_name) + # Inherit manager's handlers + self.logger.propagate = True + # If caller explicitly sets a level, respect it; otherwise leave unset so it inherits. + if log_level is not None: + self.logger.setLevel(log_level) + + # ---------------------------------------------------------------------------------------------- + # Supervisor & Routing + @_trace() + def get_flags_from_supervisor( + self, + chat_history: Optional[List[Dict[str, str]]] = None, + user_message: str = "", + memory_context: Optional[str] = "", + summarized_histories: Optional[List] = None + ) -> Dict[str, Any]: + self.logger.info("Getting flags from SupervisorAgent") + chat_history = chat_history or [] + summarized_histories = summarized_histories or [] + memory_context = memory_context or "" + + string_flags = self.supervisor.generate_feedback( + chat_history=chat_history, + user_message=user_message, + memory_context=memory_context, + task="flag", + summarized_histories=summarized_histories + ) + dict_flags = json.loads(string_flags) + self.logger.debug(f"Supervisor flags: {dict_flags}") + return dict_flags + + @_trace() + def get_translation(self, message: str, flags: dict, translation_type: str) -> str: + """Translate message based on flags and translation type.""" + try: + source_lang = flags.get("language") + default_target = self.config["agents_language"] + supported_langs = self.config["supported_languages"] + + if translation_type not in {"forward", "backward"}: + raise ValueError(f"Invalid translation type: {translation_type}. Allowed: 'forward', 'backward'") + + if source_lang is None: + self.logger.debug("No translation flag set, using original message") + return message + + if source_lang not in supported_langs: + supported = ", ".join(f"'{lang}'" for lang in supported_langs) + raise ValueError(f"Invalid translate flag: '{source_lang}'. Supported: {supported}") + + if translation_type == "forward": + target_lang = default_target if source_lang != default_target else source_lang + needs_translation = source_lang != default_target + else: + target_lang = source_lang if source_lang != default_target else default_target + needs_translation = source_lang != default_target + + if needs_translation: + self.logger.debug(f"Translating {translation_type}: '{source_lang}' -> '{target_lang}'") + return self.translator.get_translation(message=message, target=target_lang) + else: + self.logger.debug(f"Source '{source_lang}' same as target, using original message") + return message + + except ValueError: + raise + except Exception as e: + self.logger.error(f"Unexpected error in translation: {e}", exc_info=True) + raise RuntimeError( + "เกิดข้อผิดพลาดขณะแปล โปรดตรวจสอบให้แน่ใจว่าคุณใช้ภาษาที่รองรับ แล้วลองอีกครั้ง\n" + "An error occurred while translating. Please make sure you are using a supported language and try again." + ) + + @_trace() + def get_commented_response(self, + original_history: List[Dict[str, str]], + original_message: str, + eng_history: List[Dict[str, str]], + eng_message: str, + flags: Dict[str, Any], + chain_of_thoughts: List[Dict[str, str]], + memory_context: Optional[Dict[str, Any]], + summarized_histories: List[Dict[str, str]]) -> Dict[str, Any]: + + self.logger.info(f"Routing message: {eng_message} | Flags: {flags}") + + commentary = {} + + if flags.get("doctor"): # Dummy for now + self.logger.debug("Activating DoctorAgent") + commentary["doctor"] = self.doctor.analyze( + eng_message, eng_history, json.dumps(chain_of_thoughts, ensure_ascii=False), json.dumps(summarized_histories, ensure_ascii=False) + ) + + if flags.get("psychologist"): # Dummy for now + self.logger.debug("Activating PsychologistAgent") + commentary["psychologist"] = self.psychologist.analyze( + original_message, original_history, json.dumps(chain_of_thoughts, ensure_ascii=False), json.dumps(summarized_histories, ensure_ascii=False) + ) + + commentary["final_output"] = self.supervisor.generate_feedback( + chat_history=original_history, + user_message=original_message, + memory_context=json.dumps(memory_context) if memory_context else "", + task="finalize", + summarized_histories=summarized_histories, + commentary=commentary, + ) + self.logger.info("Routing complete. Returning results.") + return commentary + + def _merge_outputs(self, outputs: dict) -> str: + """ + Merge multiple agent responses into a single final string. + For now: concatenate. Later: ranking or weighting logic. + """ + final = [] + for agent, out in outputs.items(): + if agent != "final_output": + final.append(f"[{agent.upper()}]: {out}") + return "\n".join(final) + + @_trace() + def summarize_history(self, + chat_history: List[Dict[str, str]], + eng_summaries: List[Dict[str, str]]) -> Optional[str]: + try: + summary = self.summarizer.summarize(chat_history, eng_summaries) + self.logger.info("Summarization complete.") + except Exception as e: + self.logger.error(f"Error during summarization: {e}", exc_info=True) + summary = "Error during summarization." + return str(summary) diff --git a/src/kallam/domain/agents/psychologist.py b/src/kallam/domain/agents/psychologist.py new file mode 100644 index 0000000000000000000000000000000000000000..fc4defdcef1c917abbfc4e3e7ddd2da516af680b --- /dev/null +++ b/src/kallam/domain/agents/psychologist.py @@ -0,0 +1,620 @@ +import os +import json +import logging +import requests +import re +from google import genai +from pathlib import Path +from datetime import datetime +from typing import List, Literal, Dict, Any, Optional, Tuple + +from dotenv import load_dotenv +load_dotenv() + + +from kallam.infrastructure.sea_lion import load_sea_lion_settings, fingerprint_secret + + +class PsychologistAgent: + TherapyApproach = Literal["cbt", "dbt", "act", "motivational", "solution_focused", "mindfulness"] + CrisisLevel = Literal["none", "mild", "moderate", "severe", "emergency"] + + def __init__(self, log_level: int = logging.INFO): + self._setup_logging(log_level) + self._setup_api_clients() + self.logger.info(f"PsychologistAgent initialized successfully - Thai->SEA-Lion, English->Gemini") + + def _setup_logging(self, log_level: int) -> None: + """Setup logging configuration""" + log_dir = Path("logs") + log_dir.mkdir(exist_ok=True) + + self.logger = logging.getLogger(f"{__name__}.PsychologistAgent") + self.logger.setLevel(log_level) + + if self.logger.handlers: + self.logger.handlers.clear() + + file_handler = logging.FileHandler( + log_dir / f"psychologist_{datetime.now().strftime('%Y%m%d')}.log", + encoding='utf-8' + ) + file_handler.setLevel(logging.DEBUG) + + console_handler = logging.StreamHandler() + console_handler.setLevel(log_level) + + formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + file_handler.setFormatter(formatter) + console_handler.setFormatter(formatter) + + self.logger.addHandler(file_handler) + self.logger.addHandler(console_handler) + + def _setup_api_clients(self) -> None: + """Setup both API clients with graceful degradation.""" + # SEA-Lion API (Thai) + sea_settings = load_sea_lion_settings(default_model="aisingapore/Gemma-SEA-LION-v4-27B-IT") + self.sea_lion_base_url = sea_settings.base_url + self.sea_lion_token = sea_settings.token + self.sea_lion_model = sea_settings.model + self.sea_mode = sea_settings.mode + self.sea_enabled = self.sea_lion_token is not None + + if self.sea_enabled: + self.logger.info( + "SEA-Lion API client initialized (mode=%s, base_url=%s, model=%s, token_fp=%s)", + self.sea_mode, + self.sea_lion_base_url, + self.sea_lion_model, + fingerprint_secret(self.sea_lion_token), + ) + else: + self.logger.warning("SEA_LION credentials not set. Thai responses will use fallbacks.") + + # Gemini API (English) + self.gemini_api_key = os.getenv("GEMINI_API_KEY") + if self.gemini_api_key: + try: + self.gemini_client = genai.Client(api_key=self.gemini_api_key) + self.gemini_model_name = "gemini-2.5-flash-lite" + self.gemini_enabled = True + self.logger.info(f"Gemini API client initialized with model: {self.gemini_model_name}") + except Exception as e: + self.gemini_enabled = False + self.logger.error(f"Failed to initialize Gemini client: {str(e)}") + else: + self.gemini_enabled = False + self.logger.warning("GEMINI_API_KEY not set. English responses will use fallbacks.") + + ##############USE PROMPT ENGINERING LATER################# + def _detect_language(self, text: str) -> str: + """ + Detect if the text is primarily Thai or English + """ + # Count Thai characters (Unicode range for Thai) + thai_chars = len(re.findall(r'[\u0E00-\u0E7F]', text)) + # Count English characters (basic Latin letters) + english_chars = len(re.findall(r'[a-zA-Z]', text)) + + total_chars = thai_chars + english_chars + + if total_chars == 0: + # Default to English if no detectable characters + return 'english' + + thai_ratio = thai_chars / total_chars + + # If more than 10% Thai characters, consider it Thai + if thai_ratio > 0.1: + detected = 'thai' + else: + detected = 'english' + + self.logger.debug(f"Language detection: {detected} (Thai: {thai_chars}, English: {english_chars}, Ratio: {thai_ratio:.2f})") + return detected + ##############USE PROMPT ENGINERING LATER################# + + # ===== SEA-LION BLOCK (THAI) ===== + def _get_sealion_prompt(self) -> str: + """Thai therapeutic prompt for SEA-Lion""" + return """ +**บทบาทของคุณ:** +คุณเป็น “ที่ปรึกษาทางจิตวิทยาเชิง MI” สำหรับบุคลากรแพทย์ +เป้าหมายของคุณคือให้คำแนะนำในการตอบสนองของผู้สื่อสารทางการแพทย์เพื่อเพิ่มประสิทธิภาพในการสนทนาของผู้สื่อสารทางการแพทย์และผู้ใช้งาน + +**หลักการบังคับใช้:** +- ยึด MI Spirit: Collaboration, Evocation, Autonomy +- ใช้ OARS: Open questions, Affirmations, Reflections, Summaries +- ตั้งเป้าอัตราส่วน Reflection:Question ≈ 2:1 เมื่อเป็นไปได้ +- ถ้าข้อมูลไม่พอ: ขอเก็บข้อมูลด้วยคำถามปลายเปิด 1–2 ข้อ +- ใช้การตอบที่สั้นกระชับใจความ +- ใช้ภาษาไทยเท่านั้น (ยกเว้นคำทับศัพย์เช่น Motivational Interview) +- คุณให้คำแนะนำสำหรับการสื่อสารแบบ Motivational Interviewing (MI) บุคลาการทางการแพทย์เพื่อใช้ในการวินิจฉัยและรักษาอาการทางจิต +- ในกรณีฉุกเฉิน (ความคิดฆ่าตัวตาย การทำร้ายตนเอง โรคจิต) แนะนำให้แสวงหาความช่วยเหลือจากผู้เชี่ยวชาญฉุกเฉิน +- คุณตอบในรูปแบบของ JSON เท่านั้น + +**คู่มือ Motivational Interview (MI):** +1.จิตวิญญาณของ MI (MI Spirit) + - ความร่วมมือ (Collaboration) → บุคลากรทางการแพทย์ทำงานแบบหุ้นส่วน ไม่ใช่สั่งการหรือตำหนิ + - การกระตุ้น (Evocation) → กระตุ้นให้ผู้ป่วยพูดถึงเหตุผลและแรงจูงใจของตนเอง + - การเคารพสิทธิ์การตัดสินใจ (Autonomy) → ผู้ป่วยเป็นผู้เลือกแนวทาง ไม่ถูกบังคับหรือกดดัน +2.ทักษะหลัก OARS + - คำถามปลายเปิด (Open questions) → ใช้คำถามที่ชวนให้ขยายความ ไม่ใช่แค่ตอบ “ใช่/ไม่ใช่” + - การยืนยัน (Affirmations) → ชื่นชมจุดแข็ง ความตั้งใจ และความพยายามของผู้ป่วย + - การสะท้อน (Reflections) → ทวนสิ่งที่ผู้ป่วยพูด ทั้งแบบสั้น ๆ และเชิงลึก เพื่อแสดงความเข้าใจ + - การสรุป (Summaries) → ทบทวนประเด็นหลักเป็นระยะ เพื่อสร้างความชัดเจนและตอกย้ำความก้าวหน้า +3.การแยก Change Talk กับ Sustain Talk + - Change Talk: เมื่อผู้ป่วยพูดถึงความปรารถนา ความสามารถ เหตุผล หรือความมุ่งมั่นในการเปลี่ยนแปลง → ผู้ให้บริการควรจับประเด็นและเสริมแรง + - Sustain Talk: เมื่อผู้ป่วยแสดงความลังเลหรือคงเดิม → ควรสะท้อนอย่างเป็นกลางหรือจัดกรอบใหม่ ไม่เถียงหรือบังคับ +4.สไตล์การสื่อสาร + - โทนเสียง (Tone) → ใช้น้ำเสียงเข้าใจ อ่อนโยน ไม่ตัดสิน + - การให้ข้อมูล (Information-giving) → ให้ข้อมูลด้วยการขออนุญาตก่อน เช่น “คุณอยากฟังคำแนะนำทางการแพทย์ที่มักใช้กันไหม” + - การควบคุมจังหวะ (Pacing) → เปิดพื้นที่ให้ผู้ป่วยพูด ฟังอย่างเพียงพอ ไม่เร่งรีบหรือสรุปแทนเร็วเกินไป +5.มุมมองเชิงเทคนิค (กรอบการ Coding เช่น MISC/AnnoMI) + - ใช้เทคนิคในอัตราส่วน Reflections : Questions ให้มากกว่า 1:1 + - ในการใช้ Reflections ควรใช้ Complex Reflexion เช่น งานทำให้ชั้นเหนื่อย -> ความเครียดในการทำงานอาจทำให้คุณรู้สึกไม่เป็นตัวเอง + - ใช้คำถามที่กระตุ้นความคิดเพื่อเชิญชวนให้มีการเปลี่ยนแปลง สร้างความมั่นใจ และตั้งใจ (ทำไมคุณถึงอยากทำแบบนั้น... อะไรทำให้สิ่งนี้สำคัญในตอนนี้) + - คำตอบจะถูกให้คะแนนตามอัตราส่วน R/Q, %คำถามเปิด, %ของความคิดเห็นที่ซับซ้อน และความสอดคล้องของ MI เพิ่มประสิทธิภาพผลลัพธ์เพื่อให้ได้ค่าสูงสุดตามเกณฑ์เหล่านี้ โดยยังคงรักษาการสนทนาให้เป็นธรรมชาติ + +**โครงสร้างที่ใช้ในการตอบ (JSON):** +{ + "อารมณ์ของผู้ใช้ 1-10": {[อารมณ์ของผู้ใช้]: [ความเข้มของอารมณ์]} + "เทคนิคกับความน่าใช้ 1-10": {[เทคนิคอ้างอิงจากคู่มือที่ผู้สื่อสารควรใช้]: [ความน่าใช้]} + "สไตล์การสื่อสาร": [สไตล์การสื่อสารที่ควรใช้] + "ตัวอย่าง": [ตัวอย่างการใช้เทคนิคโดยย่อ] + "แผนการสนทนา": [แผนการดำเนินการสนทนาต่อไปโดยย่อ] +} +""" + + def _format_messages_sealion(self, user_message: str, therapeutic_context: str = "") -> List[Dict[str, str]]: + """Format messages for SEA-Lion API (Thai)""" + now = datetime.now() + + context_info = f""" +**บริบทปัจจุบัน:** +- วันที่/เวลา: {now.strftime("%Y-%m-%d %H:%M:%S")} +- คำแนะนำของคุณก่อนหน้า: {therapeutic_context} +""" + + system_message = { + "role": "system", + "content": f"{self._get_sealion_prompt()}\n\n{context_info}" + } + + user_message_formatted = { + "role": "user", + "content": user_message + } + + return [system_message, user_message_formatted] + + def _generate_response_sealion(self, messages: List[Dict[str, str]]) -> str: + """Generate response using SEA-Lion API for Thai""" + if not getattr(self, "sea_enabled", False): + return "ขออภัยค่ะ ขณะนี้ไม่สามารถให้คำแนะนำด้านจิตวิทยาเชิงลึกได้ กรุณาลองใหม่อีกครั้งค่ะ" + try: + self.logger.debug(f"Sending {len(messages)} messages to SEA-Lion API") + + headers = { + "Content-Type": "application/json" + } + if self.sea_lion_token: + headers["Authorization"] = f"Bearer {self.sea_lion_token}" + + payload = { + "model": self.sea_lion_model, + "messages": messages, + "chat_template_kwargs": { + "thinking_mode": "on" + }, + "max_tokens": 2000, + "temperature": 0, + "top_p": 1.0, + "frequency_penalty": 0.1, + "presence_penalty": 0.1 + } + + response = requests.post( + f"{self.sea_lion_base_url}/chat/completions", + headers=headers, + json=payload, + timeout=90 + ) + + response.raise_for_status() + response_data = response.json() + + if "choices" not in response_data or len(response_data["choices"]) == 0: + self.logger.error(f"Unexpected response structure: {response_data}") + return "ขออภัยค่ะ ไม่สามารถประมวลผลคำตอบได้ในขณะนี้" + + choice = response_data["choices"][0] + if "message" not in choice or "content" not in choice["message"]: + self.logger.error(f"Unexpected message structure: {choice}") + return "ขออภัยค่ะ ไม่สามารถประมวลผลคำตอบได้ในขณะนี้" + + raw_content = choice["message"]["content"] + + if raw_content is None or (isinstance(raw_content, str) and raw_content.strip() == ""): + self.logger.error("SEA-Lion API returned None or empty content") + return "ขออภัยค่ะ ไม่สามารถสร้างคำตอบได้ในขณะนี้" + + # Extract answer block + answer_match = re.search(r"```answer\s*(.*?)\s*```", raw_content, re.DOTALL) + final_answer = answer_match.group(1).strip() if answer_match else raw_content.strip() + + # Log thinking privately + thinking_match = re.search(r"```thinking\s*(.*?)\s*```", raw_content, re.DOTALL) + if thinking_match: + self.logger.debug(f"SEA-Lion thinking:\n{thinking_match.group(1).strip()}") + + self.logger.info(f"Received SEA-Lion response (length: {len(final_answer)} chars)") + return final_answer + + except requests.exceptions.RequestException as e: + status_code = getattr(getattr(e, 'response', None), 'status_code', None) + body_preview = None + if getattr(e, 'response', None) is not None: + try: + body_preview = e.response.text[:500] # type: ignore + except Exception: + body_preview = '' + request_url = getattr(getattr(e, 'request', None), 'url', None) + self.logger.error( + "SEA-Lion psychologist request failed (%s) url=%s status=%s body=%s message=%s", + e.__class__.__name__, + request_url, + status_code, + body_preview, + str(e), + ) + if status_code == 401: + self.logger.error( + "SEA-Lion psychologist authentication failed. mode=%s model=%s token_fp=%s base_url=%s", + getattr(self, "sea_mode", "unknown"), + getattr(self, "sea_lion_model", "unknown"), + fingerprint_secret(getattr(self, "sea_lion_token", None)), + getattr(self, "sea_lion_base_url", "unknown"), + ) + return "ขออภัยค่ะ เกิดปัญหาในการเชื่อมต่อ กรุณาลองใหม่อีกครั้งค่ะ" + except Exception as e: + self.logger.error(f"Error generating SEA-Lion response: {str(e)}") + return "ขออภัยค่ะ เกิดข้อผิดพลาดในระบบ" + + # ===== GEMINI BLOCK (ENGLISH) ===== + def _get_gemini_prompt(self) -> str: + """English therapeutic prompt for Gemini""" + return """ +**Your Role:** +You are a “Psychological Consultant” for a healthcare professional on Motivational Interview (MI) session. +Your goal is to provide guidance for the medical personnel on how to respond to improve the effectiveness of their conversations with healthcare professionals and users. + +**Core rules:** +- Adhere to the MI Spirit: Collaboration, Evocation, Autonomy +- Use OARS: Open questions, Affirmations, Reflections, Summaries +- Aim for a Reflection:Question ratio of ≈ 2:1 when possible +- If data is insufficient, collect data using 1–2 open-ended questions +- Use short, concise responses +- Use only English language +- In emergencies (suicidal ideation) Self-harm, psychosis) recommend to seek professional help in emergency situations. +- You respond in JSON format only. + +**Motivational Interview (MI) Guide:** +1. MI Spirit + - Collaboration → Healthcare professionals work in partnership, not in command or criticism. + - Evocation → Encourage the patient to discuss their own reasons and motivations. + - Autonomy → The patient chooses the path, not in force or pressure. +2. OARS Core Skills + - Open-ended questions → Use questions that encourage elaboration, not just “yes/no” answers. + - Affirmations → Appreciate the patient's strengths, determination, and efforts. + - Reflections → Briefly and in-depth recapitulate what the patient has said to demonstrate understanding. + - Summarization → Periodically review key points. To create clarity and reinforce progress +3. Distinguishing Change Talk from Sustain Talk + - Change Talk: When the patient discusses their desire, ability, reasons, or commitment to change → The provider should address the issue and reinforce it. + - Sustain Talk: When the patient expresses hesitation or persistence → Reflect neutrally or reframe the situation without argument or coercion. +4. Communication Style + - Tone → Use an understanding, gentle, and non-judgmental tone. + - Information-giving → Provide information by asking permission first, such as, "Would you like to hear commonly used medical advice?" + - Pacing → Allow the patient space to speak, listen adequately, without rushing or summarizing too quickly. +5. Technical Perspective (Coding Frameworks such as MISC/AnnoMI) + - Use a technique with a ratio of Reflections to Questions greater than 1:1. + - When using Reflections, use complex reflexes, such as, "Work is making me tired" -> "Work stress can make you feel out of place." + - Use thought-provoking questions to invite change, build confidence, and resolve ("Why do you want to do that?") What Makes This Important Now? + - Responses are scored based on R/Q ratio, % Open Questions, % Complex Comments, and MI Consistency. Optimize the results to maximize these criteria while maintaining a natural conversation. + + **Response Guidelines:** + - Use open-ended questions, empathetic tone, and validation. + - Provide psychoeducation and coping strategies. + - Include evidence-based interventions tailored to the client. + - Always respond in English. + - Always include crisis safety steps when risk is detected. + +**Response Structure (JSON):** +{ +"User Mood with Weight 1-10": {[User's Mood]: [Weight]} +"Techniques with Usability 1-10": {[Techniques Referenced from the MI Guide]: [Usability]} +"Communication Style": [recommended communication style] +"Example": [a brief example of using techniques] +"Conversation Plan": [a brief conversation action plan for your future self] +} +""" + + def _format_prompt_gemini(self, user_message: str, therapeutic_context: str = "") -> str: + """Format prompt for Gemini API (English)""" + now = datetime.now() + + context_info = f""" +**Current Context:** +- Date/Time: {now.strftime("%Y-%m-%d %H:%M:%S")} +- Your Previous Commentary: {therapeutic_context} +""" + + prompt = f"""{self._get_gemini_prompt()} + +{context_info} + +**Client Message:** {user_message} + +Please follow the guidance above.""" + + return prompt + + def _generate_response_gemini(self, prompt: str) -> str: + """Generate response using Gemini API for English""" + if not getattr(self, "gemini_enabled", False): + return "I’m unable to provide detailed psychological guidance right now. Please try again later." + try: + self.logger.debug(f"Sending prompt to Gemini API (length: {len(prompt)} chars)") + + response = self.gemini_client.models.generate_content( + model=self.gemini_model_name, + contents=[prompt], + ) + + response_text = response.text + + if response_text is None or (isinstance(response_text, str) and response_text.strip() == ""): + self.logger.error("Gemini API returned None or empty content") + return "I apologize, but I'm unable to generate a response at this time. Please try again later." + + self.logger.info(f"Received Gemini response (length: {len(response_text)} chars)") + return str(response_text).strip() + + except Exception as e: + self.logger.error(f"Error generating Gemini response: {str(e)}") + return "I apologize, but I'm experiencing technical difficulties. Please try again later, and if you're having thoughts of self-harm or are in crisis, please contact a mental health professional or emergency services immediately." + + # ===== ANALYSIS METHODS FOR ORCHESTRATOR ===== + def analyze(self, message: str, history: List[Dict[str, str]], chain_of_thoughts: str, summarized_histories: str) -> str: + """ + Analyze method expected by the orchestrator + Provides psychological analysis and therapeutic guidance + + Args: + message: The client's message to analyze + history: Chat history as list of message dictionaries + chain_of_thoughts: Chain of thoughts from previous processing + summarized_histories: Previously summarized conversation histories + + Returns: + Therapeutic analysis and guidance response + """ + self.logger.info("Starting psychological analysis") + self.logger.debug(f"Analyzing message: {message}") + self.logger.debug(f"History length: {len(history) if history else 0}") + self.logger.debug(f"Chain of thoughts length: {len(chain_of_thoughts) if chain_of_thoughts else 0}") + self.logger.debug(f"Summarized histories length: {len(summarized_histories) if summarized_histories else 0}") + + try: + # Build therapeutic context from history and chain of thoughts + context_parts = [] + + # Add recent conversation context + if history: + recent_messages = history[-3:] if len(history) > 3 else history # Last 3 messages + context_parts.append("Recent conversation context:") + for msg in recent_messages: + role = msg.get('role', 'unknown') + content = msg.get('content', '') + context_parts.append(f"- {role}: {content}") + + # Add chain of thoughts if available + if chain_of_thoughts: + context_parts.append("Previous analysis context:") + for thought in chain_of_thoughts[-2:]: # Last 2 thoughts + if isinstance(thought, dict): + content = thought.get('content', str(thought)) + else: + content = str(thought) + context_parts.append(f"- {content}") + + # Add summarized context if available + if summarized_histories: + context_parts.append("Historical context summary:") + for summary in summarized_histories[-1:]: # Most recent summary + if isinstance(summary, dict): + content = summary.get('content', str(summary)) + else: + content = str(summary) + context_parts.append(f"- {content}") + + therapeutic_context = "\n".join(context_parts) if context_parts else "New conversation session" + + # Use the main therapeutic guidance method + response = self.provide_therapeutic_guidance( + user_message=message, + therapeutic_context=therapeutic_context + ) + + self.logger.info("Psychological analysis completed successfully") + return response + + except Exception as e: + self.logger.error(f"Error in analyze method: {str(e)}") + # Return fallback based on detected language + try: + lang = self._detect_language(message) + if lang == 'thai': + return "ขออภัยค่ะ เกิดปัญหาทางเทคนิค หากมีความคิดฆ่าตัวตายหรืออยู่ในภาวะฉุกเฉิน กรุณาติดต่อนักจิตวิทยาหรือหน่วยงานฉุกเฉินทันที" + else: + return "I apologize for the technical issue. If you're having thoughts of self-harm or are in crisis, please contact a mental health professional or emergency services immediately." + except: + return "Technical difficulties. If in crisis, seek immediate professional help." + + # ===== MAIN OUTPUT METHODS ===== + def provide_therapeutic_guidance(self, user_message: str, therapeutic_context: str = "") -> str: + """ + Main method to provide psychological guidance with language-based API routing + + Args: + user_message: The client's message or concern + therapeutic_context: Additional context about the client's situation + + Returns: + Therapeutic response with guidance and support + """ + self.logger.info("Processing therapeutic guidance request") + self.logger.debug(f"User message: {user_message}") + self.logger.debug(f"Therapeutic context: {therapeutic_context}") + + try: + # Detect language + detected_language = self._detect_language(user_message) + self.logger.info(f"Detected language: {detected_language}") + + if detected_language == 'thai': + # Use SEA-Lion for Thai + messages = self._format_messages_sealion(user_message, therapeutic_context) + response = self._generate_response_sealion(messages) + else: + # Use Gemini for English + prompt = self._format_prompt_gemini(user_message, therapeutic_context) + response = self._generate_response_gemini(prompt) + + if response is None: + raise Exception(f"{detected_language} API returned None response") + + return response + + except Exception as e: + self.logger.error(f"Error in provide_therapeutic_guidance: {str(e)}") + # Return fallback based on detected language + try: + lang = self._detect_language(user_message) + if lang == 'thai': + return "ขออภัยค่ะ ระบบมีปัญหาชั่วคราว หากมีความคิดทำร้ายตัวเองหรืออยู่ในภาวะวิกฤต กรุณาติดต่อนักจิตวิทยาหรือหน่วยงานฉุกเฉินทันที" + else: + return "I apologize for the technical issue. If you're having thoughts of self-harm or are in crisis, please contact a mental health professional or emergency services immediately." + except: + return "Technical difficulties. If in crisis, seek immediate professional help." + + def get_health_status(self) -> Dict[str, Any]: + """Get current agent health status""" + status = { + "status": "healthy", + "language_routing": "thai->SEA-Lion, english->Gemini", + "sea_lion_configured": getattr(self, "sea_lion_token", None) is not None, + "gemini_configured": hasattr(self, 'gemini_api_key') and self.gemini_api_key, + "sea_lion_model": getattr(self, "sea_lion_model", "unknown"), + "gemini_model": self.gemini_model_name, + "timestamp": datetime.now().isoformat(), + "logging_enabled": True, + "log_level": self.logger.level, + "methods_available": ["analyze", "provide_therapeutic_guidance", "get_health_status"] + } + + self.logger.debug(f"Health status check: {status}") + return status + + +if __name__ == "__main__": + # Test the completed PsychologistAgent + try: + psychologist = PsychologistAgent(log_level=logging.DEBUG) + except Exception as e: + print(f"[BOOT ERROR] Unable to start PsychologistAgent: {e}") + raise SystemExit(1) + + # Test cases for both languages + test_cases = [ + { + "name": "Thai - Exam Anxiety", + "user_message": "หนูปวดหัวและกังวลเรื่องสอบค่ะ นอนไม่หลับและกังวลว่าจะสอบตก", + "therapeutic_context": "User: นักศึกษาอายุ 21 ปี ช่วงสอบกลางเทอม นอนน้อย (4-5 ชั่วโมง) ดื่มกาแฟมาก มีประวัติวิตกกังวลในช่วงความเครียดทางการศึกษา" + }, + { + "name": "English - Work Stress", + "user_message": "I've been feeling overwhelmed lately with work and personal life. Everything feels like too much and I can't cope.", + "therapeutic_context": "User: Working professional, recent job change, managing family responsibilities, seeking coping strategies." + }, + { + "name": "Thai - Relationship Issues", + "user_message": "ความสัมพันธ์กับแฟนมีปัญหามากค่ะ เราทะเลาะกันบ่อยๆ ไม่รู้จะแก้ไขยังไง", + "therapeutic_context": "User: อยู่ในความสัมพันธ์ที่มั่นคง มีปัญหาการสื่อสาร ขอคำแนะนำเรื่องความสัมพันธ์และการแก้ปัญหาความขัดแย้ง" + }, + { + "name": "English - Anxiety Management", + "user_message": "I keep having panic attacks and I don't know how to control them. It's affecting my daily life and work performance.", + "therapeutic_context": "User: Experiencing frequent panic attacks, seeking anxiety management techniques, work performance concerns." + } + ] + + # Test the analyze method specifically + print(f"\n{'='*60}") + print("TESTING ANALYZE METHOD (Required by Orchestrator)") + print(f"{'='*60}") + + test_message = "hello im kinda sad" + test_history = [ + {"role": "user", "content": "Hi there"}, + {"role": "assistant", "content": "Hello! How can I help you today?"}, + {"role": "user", "content": test_message} + ] + test_chain_of_thoughts = [ + {"step": "analysis", "content": "User expressing mild sadness, needs supportive guidance"}, + {"step": "routing", "content": "Psychological support required"} + ] + test_summarized_histories = [ + {"session": "previous", "content": "User has been dealing with some personal challenges"} + ] + + print(f"\n Test Message: {test_message}") + print(f" History: {len(test_history)} messages") + print(f" Chain of thoughts: {len(test_chain_of_thoughts)} items") + print(f" Summaries: {len(test_summarized_histories)} items") + + print(f"\n ANALYZE METHOD RESPONSE:") + print("-" * 50) + + analyze_response = psychologist.analyze(test_message, test_history, str(test_chain_of_thoughts), str(test_summarized_histories)) + print(analyze_response) + + # Run other tests + for i, test_case in enumerate(test_cases, 1): + print(f"\n{'='*60}") + print(f"TEST {i}: {test_case['name']}") + print(f"{'='*60}") + + print(f"\n User Message: {test_case['user_message']}") + print(f" Context: {test_case['therapeutic_context']}") + + print(f"\n PSYCHOLOGIST RESPONSE:") + print("-" * 50) + + response = psychologist.provide_therapeutic_guidance( + user_message=test_case['user_message'], + therapeutic_context=test_case['therapeutic_context'] + ) + + print(response) + print("\n" + "="*60) + + # Test health status + print(f"\n{'='*60}") + print("HEALTH STATUS CHECK") + print(f"{'='*60}") + status = psychologist.get_health_status() + print(json.dumps(status, indent=2, ensure_ascii=False)) diff --git a/src/kallam/domain/agents/summarizer.py b/src/kallam/domain/agents/summarizer.py new file mode 100644 index 0000000000000000000000000000000000000000..db7d2075f8d4548829e19d7c875879ed0d821f76 --- /dev/null +++ b/src/kallam/domain/agents/summarizer.py @@ -0,0 +1,157 @@ +import os +import json +import logging +import re +from google import genai +from pathlib import Path +from datetime import datetime +from typing import List, Dict, Optional + +from dotenv import load_dotenv +load_dotenv() + + +class SummarizerAgent: + def __init__(self, log_level: int = logging.INFO): + self._setup_logging(log_level) + self._setup_api_clients() + self.logger.info("Summarizer Agent initialized successfully") + + def _setup_logging(self, log_level: int) -> None: + self.logger = logging.getLogger(f"{__name__}.SummarizerAgent") + self.logger.setLevel(log_level) + if not self.logger.handlers: + handler = logging.StreamHandler() + handler.setFormatter(logging.Formatter('%(asctime)s - %(name)s - %(levelname)s - %(message)s')) + self.logger.addHandler(handler) + + def _setup_api_clients(self) -> None: + raw_gemini_key = os.getenv("GEMINI_API_KEY", "") + self.gemini_api_key = raw_gemini_key.strip() + if raw_gemini_key and not self.gemini_api_key: + self.logger.warning("GEMINI_API_KEY contained only whitespace after stripping") + if not self.gemini_api_key: + raise ValueError("GEMINI_API_KEY not found in environment variables") + + self.gemini_client = genai.Client(api_key=self.gemini_api_key) + self.gemini_model_name = "gemini-1.5-flash" + + def _generate_response(self, prompt: str) -> str: + try: + response = self.gemini_client.models.generate_content( + model=self.gemini_model_name, + contents=[prompt] + ) + return response.text.strip() if response.text else "Unable to generate summary" + except Exception as e: + self.logger.error("Error generating summary (%s): %s", e.__class__.__name__, str(e)) + return "Error generating summary" + + def _format_history(self, history: List[Dict[str, str]]) -> str: + if not history: + return "No conversation history" + + formatted = [] + for msg in history: + role = msg.get('role', 'unknown') + content = msg.get('content', '') + formatted.append(f"{role}: {content}") + return "\n".join(formatted) + + def summarize_conversation_history(self, response_history: List[Dict[str, str]], **kwargs) -> str: + """Summarize conversation history""" + formatted_history = self._format_history(response_history) + + prompt = f""" +**Your Role:** +you are a medical/psychological summarization assistant. + +**Core Rules:** +- Summarize the conversation focusing on key points and medical/health or psychological information: + +{formatted_history} + +Create a brief summary highlighting: +- Main topics discussed +- Any health concerns or symptoms mentioned +- Important advice or recommendations given +- Patient's emotional state or concerns + +Keep it concise and medically relevant.""" + + return self._generate_response(prompt) + + def summarize_medical_session(self, session_history: List[Dict[str, str]], **kwargs) -> str: + """Summarize a medical session""" + formatted_history = self._format_history(session_history) + + prompt = f"""Summarize this medical session: + +{formatted_history} + +Focus on: +- Chief complaints and symptoms +- Assessment and observations +- Treatment recommendations +- Follow-up requirements + +Keep it professional and structured for medical records.""" + + return self._generate_response(prompt) + + def summarize(self, chat_history: List[Dict[str, str]], existing_summaries: List[Dict[str, str]]) -> str: + """Main summarization method called by orchestrator""" + formatted_history = self._format_history(chat_history) + + # Include previous summaries if available + context = "" + if existing_summaries: + summaries_text = "\n".join([s.get('summary', s.get('content', '')) for s in existing_summaries]) + context = f"\nPrevious summaries:\n{summaries_text}\n" + + prompt = f"""Create a comprehensive summary of this conversation:{context} + +Recent conversation: +{formatted_history} + +Provide a summary that: +- Combines new information with previous context +- Highlights medical/psychological insights +- Notes patient progress or changes +- Maintains continuity of care focus + +Keep it concise but comprehensive.""" + + return self._generate_response(prompt) + + +if __name__ == "__main__": + # Test the Summarizer Agent + try: + summarizer = SummarizerAgent(log_level=logging.DEBUG) + + # Test conversation summary + print("=== TEST: CONVERSATION SUMMARY ===") + chat_history = [ + {"role": "user", "content": "I've been having headaches for the past week"}, + {"role": "assistant", "content": "Tell me more about these headaches - when do they occur and how severe are they?"}, + {"role": "user", "content": "They're usually worse in the afternoon, around 7/10 pain level"}, + {"role": "assistant", "content": "That sounds concerning. Have you noticed any triggers like stress, lack of sleep, or screen time?"} + ] + + summary = summarizer.summarize_conversation_history(response_history=chat_history) + print(summary) + + # Test medical session summary + print("\n=== TEST: MEDICAL SESSION SUMMARY ===") + session_summary = summarizer.summarize_medical_session(session_history=chat_history) + print(session_summary) + + # Test comprehensive summary (orchestrator method) + print("\n=== TEST: COMPREHENSIVE SUMMARY ===") + existing_summaries = [{"summary": "Patient reported initial headache symptoms"}] + comprehensive = summarizer.summarize(chat_history=chat_history, existing_summaries=existing_summaries) + print(comprehensive) + + except Exception as e: + print(f"Error testing Summarizer Agent: {e}") \ No newline at end of file diff --git a/src/kallam/domain/agents/supervisor.py b/src/kallam/domain/agents/supervisor.py new file mode 100644 index 0000000000000000000000000000000000000000..199b970e12311cd3a8521dd5335a67b8abefaa08 --- /dev/null +++ b/src/kallam/domain/agents/supervisor.py @@ -0,0 +1,520 @@ +from dotenv import load_dotenv +load_dotenv() +import logging +import requests +import re +import json +from pathlib import Path +from datetime import datetime + +from typing import Literal, Optional, Dict, Any, List +from kallam.infrastructure.sea_lion import load_sea_lion_settings, fingerprint_secret +from strands import Agent, tool + + +class SupervisorAgent: + def __init__(self, log_level: int = logging.INFO): + + self._setup_logging(log_level) + self._setup_api_clients() + + self.logger.info(f"KaLLaM chatbot initialized successfully using ") + + self.system_prompt = """ +**Your Role:** +You are "KaLLaM" or "กะหล่ำ" with a nickname "Kabby" You are a Thai, warm, friendly, female, doctor, psychiatrist, chatbot specializing in analyzing and improving patient's physical and mental health. +Your goal is to provide actionable guidance that motivates patients to take better care of themselves. + +**Core Rules:** +- You are the supervisor agent that handle multiple agents for the response. +- You **ALWAYS** respond with the same language as the user. +- Do not include introduction (except first interaction) or your thoughts to your final response. +- You can also be very serious up to the context of the conversation. +""" + + def _setup_logging(self, log_level: int) -> None: + """Setup logging configuration""" + # Create logs directory if it doesn't exist + log_dir = Path("logs") + log_dir.mkdir(exist_ok=True) + + # Setup logger + self.logger = logging.getLogger(f"{__name__}.KaLLaMChatbot") + self.logger.setLevel(log_level) + + # Remove existing handlers to avoid duplicates + if self.logger.handlers: + self.logger.handlers.clear() + + # File handler for detailed logs + file_handler = logging.FileHandler( + log_dir / f"kallam_{datetime.now().strftime('%Y%m%d')}.log", + encoding='utf-8' + ) + file_handler.setLevel(logging.DEBUG) + + # Console handler for immediate feedback + console_handler = logging.StreamHandler() + console_handler.setLevel(log_level) + + # Formatter + formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + file_handler.setFormatter(formatter) + console_handler.setFormatter(formatter) + + # Add handlers + self.logger.addHandler(file_handler) + self.logger.addHandler(console_handler) + + def _setup_api_clients(self) -> None: + """Setup API clients; do not hard-fail if env is missing.""" + settings = load_sea_lion_settings(default_model="aisingapore/Gemma-SEA-LION-v4-27B-IT") + self.sea_lion_base_url = settings.base_url + self.sea_lion_token = settings.token + self.sea_lion_model = settings.model + self.sea_lion_mode = settings.mode + self.api_enabled = self.sea_lion_token is not None + + if self.api_enabled: + self.logger.info( + "SEA-Lion client ready (mode=%s, base_url=%s, model=%s, token_fp=%s)", + self.sea_lion_mode, + self.sea_lion_base_url, + self.sea_lion_model, + fingerprint_secret(self.sea_lion_token), + ) + else: + # Keep running; downstream logic will use safe fallbacks + self.logger.warning( + "SEA_LION credentials not set. Supervisor will use safe fallback responses." + ) + + def _format_chat_history_for_sea_lion( + self, + chat_histories: List[Dict[str, str]], + user_message: str, + memory_context: str, + task: str, + summarized_histories: Optional[List] = None, + commentary: Optional[Dict[str, str]] = None, + ) -> List[Dict[str, str]]: + + # Create system message with context + now = datetime.now() + + # Build context information + context_info = f""" +**Current Context:** +- Date/Time: {now.strftime("%Y-%m-%d %H:%M:%S")} +- Persistent Memory: {memory_context} +""" + # Add summarized histories to context_info if available + if summarized_histories: + summarized_text = "\n".join([f"{m.get('role', 'unknown')}: {m.get('content', '')}" for m in summarized_histories]) + context_info += f"- Previous Session Summaries: {summarized_text}\n" + + if task == "flag": + system_message = { + "role": "system", + "content": f""" +{self.system_prompt} + +{context_info} + +**Previous Activated Agents Commentaries:** +{commentary} + +**Specific Task (strict):** +Return ONLY a single JSON object and nothing else. No intro, no markdown, no code fences. +- If the user reports physical symptoms, illnesses, treatments, or medications → activate **DoctorAgent**. +- If the user reports emotional struggles, thoughts, relationships, or psychological concerns → activate **PsychologistAgent**. +- Always respond according to the **Output Schema:**. + +**JSON Schema:** +{{ + "language": "english" | "thai", + "doctor": true | false, + "psychologist": true | false +}} + +**Rules:** +- If the user reports physical symptoms, illnesses, treatments, or medications → set "doctor": true +- If the user suggest any emotional struggles, thoughts, relationships, or psychological concerns → set "psychologist": true +- According to the previous commentaries the "psychologist" should be false only when the conversation clearly don't need psychologist anymore +- "language" MUST be exactly "english" or "thai" (lowercase) +- Both "doctor" and "psychologist" can be true if both aspects are present +- Do not include ANY text before or after the JSON object +- Do not use markdown code blocks or backticks +- Do not add explanations, commentary, or additional text +- Return ONLY the raw JSON object +""" + } + elif task == "finalize": + commentaries = f"{commentary}" if commentary else "" + context_info += f"- Commentary from each agents: {commentaries}\n" + system_message = { + "role": "system", + "content": f""" +{self.system_prompt} + +{context_info} + +**Specific Task:** +- You are a personal professional medical advisor. +- Read the given context and response throughly and only greet if there is no previous conversation record. +- Only reccommend immediate local professional help at the end of your response, if the conversation gets suicidal or very severe case. +- You are female so you use no "ครับ" + +**Respone Guide:** +- Keep your response very concise unless the user need more context and response. +- You have 1 questions limit per response. +- Your final response must be concise and short according to one most recommendation from the commentary of each agents (may or maynot given) as a sentence. +- If you are answering question or asking question do not include reflexion. +- You can response longer if the user is interested in the topic. +- Try response with emoji based on the context (try use only 0-1 per response only). +- If each response have less content encorage new relative topic with longer message. + +**Output Schema:** +[Your final response] +""" + } + + # Format messages + messages = [system_message] + + # Add full chat history (no truncation) + for msg in chat_histories: + # Ensure proper role mapping + role = msg.get('role', 'user') + if role not in ['user', 'assistant', 'system']: + role = 'user' if role != 'assistant' else 'assistant' + + messages.append({ + "role": role, + "content": msg.get('content', '') + }) + + # Add current user message + messages.append({ + "role": "user", + "content": user_message + }) + + return messages + + def _extract_and_validate_json(self, raw_content: str) -> str: + """Enhanced JSON extraction and validation with fallback""" + if not raw_content: + self.logger.warning("Empty response received, returning default JSON") + return '{"language": "english", "doctor": false, "psychologist": false}' + + # Remove thinking blocks first (if any) + content = raw_content + thinking_match = re.search(r"", content, re.DOTALL) + if thinking_match: + content = re.sub(r".*?\s*", "", content, flags=re.DOTALL).strip() + + # Remove markdown code blocks + content = re.sub(r'^```(?:json|JSON)?\s*', '', content, flags=re.MULTILINE) + content = re.sub(r'```\s*$', '', content, flags=re.MULTILINE) + content = content.strip() + + # Extract JSON object using multiple strategies + json_candidates = [] + + # Strategy 1: Find complete JSON objects + json_pattern = r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}' + matches = re.findall(json_pattern, content, re.DOTALL) + json_candidates.extend(matches) + + # Strategy 2: Look for specific schema pattern + schema_pattern = r'\{\s*"language"\s*:\s*"(?:english|thai)"\s*,\s*"doctor"\s*:\s*(?:true|false)\s*,\s*"psychologist"\s*:\s*(?:true|false)\s*\}' + schema_matches = re.findall(schema_pattern, content, re.IGNORECASE | re.DOTALL) + json_candidates.extend(schema_matches) + + # Strategy 3: If no complete JSON found, try to construct from parts + if not json_candidates: + language_match = re.search(r'"language"\s*:\s*"(english|thai)"', content, re.IGNORECASE) + doctor_match = re.search(r'"doctor"\s*:\s*(true|false)', content, re.IGNORECASE) + psychologist_match = re.search(r'"psychologist"\s*:\s*(true|false)', content, re.IGNORECASE) + + if language_match and doctor_match and psychologist_match: + constructed_json = f'{{"language": "{language_match.group(1).lower()}", "doctor": {doctor_match.group(1).lower()}, "psychologist": {psychologist_match.group(1).lower()}}}' + json_candidates.append(constructed_json) + + # Validate and return the first working JSON + for candidate in json_candidates: + try: + # Clean up the candidate + candidate = candidate.strip() + + # Parse to validate structure + parsed = json.loads(candidate) + + # Validate schema + if not isinstance(parsed, dict): + continue + + required_keys = {"language", "doctor", "psychologist"} + if not required_keys.issubset(parsed.keys()): + continue + + # Validate language field + if parsed["language"] not in ["english", "thai"]: + continue + + # Validate boolean fields + if not isinstance(parsed["doctor"], bool) or not isinstance(parsed["psychologist"], bool): + continue + + # If we reach here, the JSON is valid + self.logger.debug(f"Successfully validated JSON: {candidate}") + return candidate + + except json.JSONDecodeError as e: + self.logger.debug(f"JSON parse failed for candidate: {candidate[:100]}... Error: {e}") + continue + except Exception as e: + self.logger.debug(f"Validation failed for candidate: {candidate[:100]}... Error: {e}") + continue + + # If all strategies fail, return a safe default + self.logger.error("Could not extract valid JSON from response, using safe default") + return '{"language": "english", "doctor": false, "psychologist": false}' + + def _clean_json_response(self, raw_content: str) -> str: + """Legacy method - now delegates to enhanced extraction""" + return self._extract_and_validate_json(raw_content) + + def _generate_feedback_sea_lion(self, messages: List[Dict[str, str]], show_thinking: bool = False) -> str: + # If API is disabled, return conservative fallback immediately + if not getattr(self, "api_enabled", False): + is_flag_task = any("Return ONLY a single JSON object" in msg.get("content", "") + for msg in messages if msg.get("role") == "system") + if is_flag_task: + return '{"language": "english", "doctor": false, "psychologist": false}' + return "ขออภัยค่ะ การเชื่อมต่อมีปัญหาชั่วคราว กรุณาลองใหม่อีกครั้งค่ะ (Sorry, there is a temporary connection problem. Please try again later.)" + + try: + self.logger.debug(f"Sending {len(messages)} messages to SEA-Lion API") + + headers = { + "Content-Type": "application/json" + } + if self.sea_lion_token: + headers["Authorization"] = f"Bearer {self.sea_lion_token}" + + payload = { + "model": self.sea_lion_model, + "messages": messages, + "chat_template_kwargs": { + "thinking_mode": "off" + }, + "max_tokens": 2000, # for thinking and answering + "temperature": 0.4, + "top_p": 0.9, + "frequency_penalty": 0.4, # prevent repetition + "presence_penalty": 0.1 # Encourage new topics + } + + response = requests.post( + f"{self.sea_lion_base_url}/chat/completions", + headers=headers, + json=payload, + timeout=30 + ) + + response.raise_for_status() + response_data = response.json() + + # Check if response has expected structure + if "choices" not in response_data or len(response_data["choices"]) == 0: + self.logger.error(f"Unexpected response structure: {response_data}") + return f"Unexpected response structure: {response_data}. Please try again later." + + choice = response_data["choices"][0] + if "message" not in choice or "content" not in choice["message"]: + self.logger.error(f"Unexpected message structure: {choice}") + return f"Unexpected message structure: {choice}. Please try again later." + + raw_content = choice["message"]["content"] + + # Check if response is None or empty + if raw_content is None: + self.logger.error("SEA-Lion API returned None content") + return "SEA-Lion API returned None content" + + if isinstance(raw_content, str) and raw_content.strip() == "": + self.logger.error("SEA-Lion API returned empty content") + return "SEA-Lion API returned empty content" + + is_flag_task = any("Return ONLY a single JSON object" in msg.get("content", "") + for msg in messages if msg.get("role") == "system") + + if is_flag_task: + # Apply enhanced JSON extraction and validation for flag tasks + final_answer = self._extract_and_validate_json(raw_content) + self.logger.debug("Applied enhanced JSON extraction for flag task") + else: + # Handle thinking block for non-flag tasks + thinking_match = re.search(r"", raw_content, re.DOTALL) + if thinking_match: + if show_thinking: + # Keep the thinking block visible + final_answer = raw_content.strip() + self.logger.debug("Thinking block found and kept visible") + else: + # Remove everything up to and including + final_answer = re.sub(r".*?\s*", "", raw_content, flags=re.DOTALL).strip() + self.logger.debug("Thinking block found and removed from response") + else: + self.logger.debug("No thinking block found in response") + final_answer = raw_content.strip() + + # Log response information + self.logger.info(f"Received response from SEA-Lion API (raw length: {len(raw_content)} chars, final answer length: {len(final_answer)} chars)") + + return final_answer + + except requests.exceptions.RequestException as e: + status_code = getattr(getattr(e, "response", None), "status_code", None) + body_preview = None + if getattr(e, "response", None) is not None: + try: + body_preview = e.response.text[:500] # type: ignore + except Exception: + body_preview = "" + request_url = getattr(getattr(e, "request", None), "url", None) + self.logger.error( + "SEA-Lion request failed (%s) url=%s status=%s body=%s message=%s", + e.__class__.__name__, + request_url, + status_code, + body_preview, + str(e), + ) + if status_code == 401: + self.logger.error( + "SEA-Lion authentication failed. mode=%s model=%s token_fp=%s base_url=%s", + getattr(self, "sea_lion_mode", "unknown"), + getattr(self, "sea_lion_model", "unknown"), + fingerprint_secret(getattr(self, "sea_lion_token", None)), + getattr(self, "sea_lion_base_url", "unknown"), + ) + return "ขออภัยค่ะ เกิดปัญหาในการเชื่อมต่อ กรุณาลองใหม่อีกครั้งค่ะ (Sorry, there was a problem connecting. Please try again later.)" + except KeyError as e: + self.logger.error(f"Unexpected response format from SEA-Lion API: {str(e)}") + return "ขออภัยค่ะ เกิดข้อผิดพลาดในระบบ กรุณาลองใหม่อีกครั้งค่ะ (Sorry, there was an error in the system. Please try again.)" + except Exception as e: + self.logger.error(f"Error generating feedback from SEA-Lion API: {str(e)}") + return "ขออภัยค่ะ เกิดข้อผิดพลาดในระบบของ SEA-Lion กรุณาลองใหม่อีกครั้งค่ะ (Sorry, there was an error in the SEA-Lion system. Please try again later.)" + + def generate_feedback( + self, + chat_history: List[Dict[str, str]], + user_message: str, + memory_context: str, + task: str, + summarized_histories: Optional[List] = None, + commentary: Optional[Dict[str, str]] = None + ) -> str: + + self.logger.info("Processing chatbot response request") + self.logger.debug(f"User message: {user_message}") + self.logger.debug(f"Health status: {memory_context}") + self.logger.debug(f"Chat history length: {len(chat_history)}") + + try: + messages = self._format_chat_history_for_sea_lion(chat_history, user_message, memory_context, task, summarized_histories, commentary) + + response = self._generate_feedback_sea_lion(messages) + if response is None: + raise Exception("SEA-Lion API returned None response") + return response + except Exception as e: + self.logger.error(f"Error in _generate_feedback: {str(e)}") + # Return a fallback response instead of None + if task == "flag": + # For flag tasks, return a valid JSON structure + return '{"language": "english", "doctor": false, "psychologist": false}' + else: + return "ขออภัยค่ะ ไม่สามารถเชื่่อมต่อกับ SEA-Lion ได้ในปัจจุบัน กรุณาลองใหม่อีกครั้งค่ะ (Sorry, we cannot connect to SEA-Lion. Please try again.)" + + +if __name__ == "__main__": + # Enhanced demo with JSON validation testing + try: + sup = SupervisorAgent(log_level=logging.DEBUG) + except Exception as e: + print(f"[BOOT ERROR] Unable to start SupervisorAgent: {e}") + raise SystemExit(1) + + # Test cases for JSON validation + test_cases = [ + { + "name": "Medical + Psychological", + "message": "I have a headache and feel anxious about my exams.", + "expected": {"doctor": True, "psychologist": True} + }, + { + "name": "Thai Medical Only", + "message": "ปวดหัวมากครับ แล้วก็มีไข้ด้วย", + "expected": {"doctor": True, "psychologist": False, "language": "thai"} + }, + { + "name": "Psychological Only", + "message": "I'm feeling very stressed and worried about my future.", + "expected": {"doctor": False, "psychologist": True} + } + ] + + chat_history = [ + {"role": "user", "content": "Hi, I've been feeling tired lately."}, + {"role": "assistant", "content": "Thanks for sharing. How's your sleep and stress?"} + ] + memory_context = "User: 21 y/o student, midterm week, low sleep (4–5h), high caffeine, history of migraines." + + print("=== TESTING ENHANCED JSON SUPERVISOR ===\n") + + for i, test_case in enumerate(test_cases, 1): + print(f"Test {i}: {test_case['name']}") + print(f"Message: {test_case['message']}") + + flag_output = sup.generate_feedback( + chat_history=chat_history, + user_message=test_case['message'], + memory_context=memory_context, + task="flag" + ) + + print(f"JSON Output: {flag_output}") + + # Validate the JSON + try: + parsed = json.loads(flag_output) + print(f"Valid JSON structure") + print(f"Language: {parsed.get('language')}") + print(f"Doctor: {parsed.get('doctor')}") + print(f"Psychologist: {parsed.get('psychologist')}") + except json.JSONDecodeError as e: + print(f"Invalid JSON: {e}") + + print("-" * 50) + + # Test finalize task + print("\n=== TEST: FINALIZED RESPONSE ===") + commentary = { + "doctor": "Likely tension-type headache aggravated by stress and poor sleep. Suggest hydration, rest, OTC analgesic if not contraindicated.", + "psychologist": "Teach 4-7-8 breathing, short cognitive reframing for exam anxiety, and a 20-minute study-break cycle." + } + + final_output = sup.generate_feedback( + chat_history=chat_history, + user_message="I have a headache and feel anxious about my exams.", + memory_context=memory_context, + task="finalize", + commentary=commentary + ) + print(final_output) diff --git a/src/kallam/domain/agents/translator.py b/src/kallam/domain/agents/translator.py new file mode 100644 index 0000000000000000000000000000000000000000..50b9d30b6c04654dcaf569507f2c260bd3e6fd40 --- /dev/null +++ b/src/kallam/domain/agents/translator.py @@ -0,0 +1,296 @@ +import logging +import requests +import re +from pathlib import Path +from datetime import datetime +from typing import Dict, List, Optional + +from dotenv import load_dotenv +load_dotenv() + +from kallam.infrastructure.sea_lion import load_sea_lion_settings, fingerprint_secret + + +class TranslatorAgent: + """ + Handles Thai-English translation using SEA-Lion API. + """ + + def __init__(self, log_level: int = logging.INFO): + self._setup_logging(log_level) + self._setup_api_client() + + self.logger.info("Translator Agent initialized") + + # Core configuration + self.supported_languages = {"thai", "english"} + self.default_language = "english" + + def _setup_logging(self, log_level: int) -> None: + """Setup logging configuration""" + log_dir = Path("logs") + log_dir.mkdir(exist_ok=True) + + self.logger = logging.getLogger(f"{__name__}.TranslatorAgent") + self.logger.setLevel(log_level) + + if self.logger.handlers: + self.logger.handlers.clear() + + file_handler = logging.FileHandler( + log_dir / f"translator_{datetime.now().strftime('%Y%m%d')}.log", + encoding='utf-8' + ) + file_handler.setLevel(logging.DEBUG) + + console_handler = logging.StreamHandler() + console_handler.setLevel(log_level) + + formatter = logging.Formatter( + '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + ) + file_handler.setFormatter(formatter) + console_handler.setFormatter(formatter) + + self.logger.addHandler(file_handler) + self.logger.addHandler(console_handler) + + def _setup_api_client(self) -> None: + """Setup SEA-Lion API client; degrade gracefully if missing.""" + settings = load_sea_lion_settings(default_model="aisingapore/Gemma-SEA-LION-v4-27B-IT") + self.api_url = settings.base_url + self.api_key = settings.token + self.model_id = settings.model + self.api_mode = settings.mode + # Enable/disable external API usage based on token presence + self.enabled = self.api_key is not None + + if self.enabled: + self.logger.info( + "SEA-Lion API client initialized (mode=%s, base_url=%s, model=%s, token_fp=%s)", + self.api_mode, + self.api_url, + self.model_id, + fingerprint_secret(self.api_key), + ) + else: + # Do NOT raise: allow app to start and operate in passthrough mode + self.logger.warning( + "SEA_LION credentials not set. Translator will run in passthrough mode (no external calls)." + ) + + def _call_api(self, text: str, target_language: str) -> str: + """ + Simple API call to SEA-Lion for translation + + Args: + text: Text to translate + target_language: Target language ("thai" or "english") + + Returns: + Translated text or original on error + """ + # Short-circuit if API disabled + if not getattr(self, "enabled", False): + return text + + try: + # Build simple translation prompt + system_prompt = f""" +**Your Role:** +You are a translator for a chatbot which is used for medical and psychological help. + +**Core Rules:** +- Translate the given text to {target_language}. +- Provide ONLY the translation without quotes or explanations. +- Maintain medical/psychological terminology accuracy. +- For Thai: use appropriate polite forms. +- For English: use clear, professional language.""" + + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": f"Translate: {text}"} + ] + + headers = { + "Content-Type": "application/json" + } + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + + payload = { + "model": self.model_id, + "messages": messages, + "chat_template_kwargs": {"thinking_mode": "off"}, + "max_tokens": 2000, + "temperature": 0.1, # Low for consistency + "top_p": 0.9 + } + + response = requests.post( + f"{self.api_url}/chat/completions", + headers=headers, + json=payload, + timeout=30 + ) + + response.raise_for_status() + data = response.json() + + # Extract response + content = data["choices"][0]["message"]["content"] + + # Remove thinking blocks if present + if "```thinking" in content and "```answer" in content: + match = re.search(r"```answer\s*(.*?)\s*```", content, re.DOTALL) + if match: + content = match.group(1).strip() + elif "" in content: + content = re.sub(r".*?\s*", "", content, flags=re.DOTALL).strip() + + # Clean up quotes + content = content.strip() + if content.startswith('"') and content.endswith('"'): + content = content[1:-1] + + return content if content else text + + except requests.exceptions.RequestException as e: + status_code = getattr(getattr(e, 'response', None), 'status_code', None) + body_preview = None + if getattr(e, 'response', None) is not None: + try: + body_preview = e.response.text[:500] # type: ignore + except Exception: + body_preview = '' + request_url = getattr(getattr(e, 'request', None), 'url', None) + self.logger.error( + "SEA-Lion translation request failed (%s) url=%s status=%s body=%s message=%s", + e.__class__.__name__, + request_url, + status_code, + body_preview, + str(e), + ) + if status_code == 401: + self.logger.error( + "SEA-Lion translation authentication failed. mode=%s model=%s token_fp=%s base_url=%s", + getattr(self, "api_mode", "unknown"), + getattr(self, "model_id", "unknown"), + fingerprint_secret(getattr(self, "api_key", None)), + getattr(self, "api_url", "unknown"), + ) + return text + except Exception as e: + self.logger.error(f"Translation API error: {str(e)}") + return text # Return original on error + + # ===== PUBLIC INTERFACE (Used by Orchestrator) ===== + + def get_translation(self, message: str, target: str) -> str: + """ + Main translation method used by orchestrator + """ + # Validate target + if target not in self.supported_languages: + self.logger.warning(f"Unsupported language '{target}', returning original") + return message + + # Skip if empty + if not message or not message.strip(): + return message + + self.logger.debug(f"Translating to {target}: {message[:50]}...") + + translated = self._call_api(message, target) + + self.logger.debug(f"Translation complete: {translated[:50]}...") + + return translated + + def detect_language(self, message: str) -> str: + """ + Detect language of the message + """ + # Check for Thai characters + thai_chars = sum(1 for c in message if '\u0E00' <= c <= '\u0E7F') + total_chars = len(message.strip()) + + if total_chars == 0: + return "english" + + thai_ratio = thai_chars / total_chars + + # If more than 10% Thai characters, consider it Thai + if thai_ratio > 0.1: + return "thai" + else: + return "english" + + def translate_forward(self, message: str, source_language: str) -> str: + """ + Forward translation to English (for agent processing) + + Args: + message: Text in source language + source_language: Source language + + Returns: + English text + """ + if source_language == "english": + return message + + return self.get_translation(message, "english") + + def translate_backward(self, message: str, target_language: str) -> str: + """ + Backward translation from English to user's language + + Args: + message: English text + target_language: User's language + + Returns: + Text in user's language + """ + if target_language == "english": + return message + + return self.get_translation(message, target_language) + + +if __name__ == "__main__": + # Simple test + try: + translator = TranslatorAgent(log_level=logging.DEBUG) + + print("=== SIMPLIFIED TRANSLATOR TEST ===\n") + + # Test 1: Basic translations + test_cases = [ + ("ฉันปวดหัวมาก", "english"), + ("I have a headache", "thai"), + ("รู้สึกเครียดและกังวล", "english"), + ("Feeling anxious about my health", "thai") + ] + + for text, target in test_cases: + result = translator.get_translation(text, target) + print(f"{text[:30]:30} -> {target:7} -> {result}") + + # Test 2: Language detection + print("\n=== Language Detection ===") + texts = [ + "สวัสดีครับ", + "Hello there", + "ผมชื่อ John", + "I'm feeling ดีมาก" + ] + + for text in texts: + detected = translator.detect_language(text) + print(f"{text:20} -> {detected}") + + except Exception as e: + print(f"Test error: {e}") diff --git a/src/kallam/infra/__init__.py b/src/kallam/infra/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/src/kallam/infra/__pycache__/__init__.cpython-311.pyc b/src/kallam/infra/__pycache__/__init__.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8cf85c03088953a92d612caf2836af26828f7d10 Binary files /dev/null and b/src/kallam/infra/__pycache__/__init__.cpython-311.pyc differ diff --git a/src/kallam/infra/__pycache__/db.cpython-311.pyc b/src/kallam/infra/__pycache__/db.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..97a38057ddd5b9c2342a692e67e75b6a3f7b4d1d Binary files /dev/null and b/src/kallam/infra/__pycache__/db.cpython-311.pyc differ diff --git a/src/kallam/infra/__pycache__/exporter.cpython-311.pyc b/src/kallam/infra/__pycache__/exporter.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a39e46975808a5c65abae5cde17e53027d693da7 Binary files /dev/null and b/src/kallam/infra/__pycache__/exporter.cpython-311.pyc differ diff --git a/src/kallam/infra/__pycache__/message_store.cpython-311.pyc b/src/kallam/infra/__pycache__/message_store.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ad49c3653b971bef40eda84ff4843f81a3fa111a Binary files /dev/null and b/src/kallam/infra/__pycache__/message_store.cpython-311.pyc differ diff --git a/src/kallam/infra/__pycache__/session_store.cpython-311.pyc b/src/kallam/infra/__pycache__/session_store.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..683492ec572d13274d03c4fd41bb0082768ac9a3 Binary files /dev/null and b/src/kallam/infra/__pycache__/session_store.cpython-311.pyc differ diff --git a/src/kallam/infra/__pycache__/summary_store.cpython-311.pyc b/src/kallam/infra/__pycache__/summary_store.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..e10aeb095ab0588749c0e49144c43c66a7de7303 Binary files /dev/null and b/src/kallam/infra/__pycache__/summary_store.cpython-311.pyc differ diff --git a/src/kallam/infra/__pycache__/token_counter.cpython-311.pyc b/src/kallam/infra/__pycache__/token_counter.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7de9d285125b731c3154fc7302a0f47565d74897 Binary files /dev/null and b/src/kallam/infra/__pycache__/token_counter.cpython-311.pyc differ diff --git a/src/kallam/infra/db.py b/src/kallam/infra/db.py new file mode 100644 index 0000000000000000000000000000000000000000..6c28eaa5306c9b78938eea274127c36777b8ddca --- /dev/null +++ b/src/kallam/infra/db.py @@ -0,0 +1,18 @@ +# infra/db.py +from contextlib import contextmanager +import sqlite3 + +@contextmanager +def sqlite_conn(path: str): + conn = sqlite3.connect(path, timeout=30.0, check_same_thread=False) + try: + conn.execute("PRAGMA foreign_keys = ON") + conn.execute("PRAGMA journal_mode = WAL") + conn.row_factory = sqlite3.Row + yield conn + conn.commit() + except Exception: + conn.rollback() + raise + finally: + conn.close() diff --git a/src/kallam/infra/exporter.py b/src/kallam/infra/exporter.py new file mode 100644 index 0000000000000000000000000000000000000000..2715b2d28709e35c1a5c0de6deb7c117f3559c73 --- /dev/null +++ b/src/kallam/infra/exporter.py @@ -0,0 +1,46 @@ +# infra/exporter.py +import json +from pathlib import Path +from kallam.infra.db import sqlite_conn + +class JsonExporter: + def __init__(self, db_path: str, out_dir: str = "exported_sessions"): + self.db_path = db_path.replace("sqlite:///", "") + self.out_dir = Path(out_dir); self.out_dir.mkdir(parents=True, exist_ok=True) + + def export_session_json(self, session_id: str) -> str: + with sqlite_conn(self.db_path) as c: + s = c.execute("select * from sessions where session_id=?", (session_id,)).fetchone() + if not s: raise ValueError(f"Session {session_id} does not exist") + msgs = [dict(r) for r in c.execute("select * from messages where session_id=? order by id", (session_id,))] + sums = [dict(r) for r in c.execute("select * from summaries where session_id=? order by id", (session_id,))] + data = {"session_info": dict(s), "summaries": sums, "chat_history": msgs, + "export_metadata": {"exported_at_version": "2.1"}} + out = self.out_dir / f"{session_id}.json" + out.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8") + return str(out) + + def export_all_sessions_json(self) -> str: + """Export *all* sessions into a single JSON file.""" + all_data = [] + with sqlite_conn(self.db_path) as c: + session_ids = [row["session_id"] for row in c.execute("select session_id from sessions")] + for sid in session_ids: + s = c.execute("select * from sessions where session_id=?", (sid,)).fetchone() + msgs = [dict(r) for r in c.execute( + "select * from messages where session_id=? order by id", (sid,) + )] + sums = [dict(r) for r in c.execute( + "select * from summaries where session_id=? order by id", (sid,) + )] + data = { + "session_info": dict(s), + "summaries": sums, + "chat_history": msgs, + "export_metadata": {"exported_at_version": "2.1"}, + } + all_data.append(data) + + out = self.out_dir / "all_sessions.json" + out.write_text(json.dumps(all_data, ensure_ascii=False, indent=2), encoding="utf-8") + return str(out) \ No newline at end of file diff --git a/src/kallam/infra/message_store.py b/src/kallam/infra/message_store.py new file mode 100644 index 0000000000000000000000000000000000000000..ae80c39410402b17ea3848beac1e8dabc8adf813 --- /dev/null +++ b/src/kallam/infra/message_store.py @@ -0,0 +1,120 @@ +# infra/message_store.py +from typing import Any, Dict, List, Tuple, Optional +import json +from kallam.infra.db import sqlite_conn +from datetime import datetime +import uuid + +class MessageStore: + def __init__(self, db_path: str): + self.db_path = db_path.replace("sqlite:///", "") + + def get_original_history(self, session_id: str, limit: int = 10) -> List[Dict[str, str]]: + with sqlite_conn(self.db_path) as c: + rows = c.execute(""" + select role, content as content + from messages where session_id=? and role in ('user','assistant') + order by id desc limit ?""", (session_id, limit)).fetchall() + return [{"role": r["role"], "content": r["content"]} for r in reversed(rows)] + + def get_translated_history(self, session_id: str, limit: int = 10) -> List[Dict[str, str]]: + with sqlite_conn(self.db_path) as c: + rows = c.execute(""" + select role, coalesce(translated_content, content) as content + from messages where session_id=? and role in ('user','assistant') + order by id desc limit ?""", (session_id, limit)).fetchall() + return [{"role": r["role"], "content": r["content"]} for r in reversed(rows)] + + def get_reasoning_traces(self, session_id: str, limit: int = 10) -> List[Dict[str, Any]]: + with sqlite_conn(self.db_path) as c: + rows = c.execute(""" + select message_id, chain_of_thoughts from messages + where session_id=? and chain_of_thoughts is not null + order by id desc limit ?""", (session_id, limit)).fetchall() + out = [] + for r in rows: + try: + out.append({"message_id": r["message_id"], "contents": json.loads(r["chain_of_thoughts"])}) + except json.JSONDecodeError: + continue + return out + + def append_user(self, session_id: str, content: str, translated: str | None, + flags: Dict[str, Any] | None, tokens_in: int) -> None: + self._append(session_id, "user", content, translated, None, None, flags, tokens_in, 0) + + def append_assistant(self, session_id: str, content: str, translated: str | None, + reasoning: Dict[str, Any] | None, tokens_out: int) -> None: + self._append(session_id, "assistant", content, translated, reasoning, None, None, 0, tokens_out) + + def _append(self, session_id, role, content, translated, reasoning, latency_ms, flags, tok_in, tok_out): + message_id = f"MSG-{uuid.uuid4().hex[:8].upper()}" + now = datetime.now().isoformat() + with sqlite_conn(self.db_path) as c: + c.execute("""insert into messages (session_id,message_id,timestamp,role,content, + translated_content,chain_of_thoughts,tokens_input,tokens_output,latency_ms,flags) + values (?,?,?,?,?,?,?,?,?,?,?)""", + (session_id, message_id, now, role, content, + translated, json.dumps(reasoning, ensure_ascii=False) if reasoning else None, + tok_in, tok_out, latency_ms, json.dumps(flags, ensure_ascii=False) if flags else None)) + if role == "user": + c.execute("""update sessions set total_messages=total_messages+1, + total_user_messages=coalesce(total_user_messages,0)+1, + last_activity=? where session_id=?""", (now, session_id)) + elif role == "assistant": + c.execute("""update sessions set total_messages=total_messages+1, + total_assistant_messages=coalesce(total_assistant_messages,0)+1, + last_activity=? where session_id=?""", (now, session_id)) + + def aggregate_stats(self, session_id: str) -> Tuple[Dict[str, Any], Dict[str, Any]]: + """ + Returns: + stats: { + "message_count": int, + "total_tokens_in": int, + "total_tokens_out": int, + "avg_latency": float | None, + "first_message": str | None, # ISO timestamp + "last_message": str | None, # ISO timestamp + } + session: dict # full row from sessions table (as a mapping) + """ + with sqlite_conn(self.db_path) as c: + # Roll up message stats + row = c.execute( + """ + SELECT + COUNT(*) AS message_count, + COALESCE(SUM(tokens_input), 0) AS total_tokens_in, + COALESCE(SUM(tokens_output), 0) AS total_tokens_out, + AVG(CASE WHEN role='assistant' THEN latency_ms END) AS avg_latency, + MIN(timestamp) AS first_message, + MAX(timestamp) AS last_message + FROM messages + WHERE session_id = ? + AND role IN ('user','assistant') + """, + (session_id,), + ).fetchone() + + stats = { + "message_count": row["message_count"] or 0, + "total_tokens_in": row["total_tokens_in"] or 0, + "total_tokens_out": row["total_tokens_out"] or 0, + # Normalize avg_latency to float if not None + "avg_latency": float(row["avg_latency"]) if row["avg_latency"] is not None else None, + "first_message": row["first_message"], + "last_message": row["last_message"], + } + + # Fetch session info (entire row) + session = c.execute( + "SELECT * FROM sessions WHERE session_id = ?", + (session_id,), + ).fetchone() or {} + + # Convert sqlite Row to plain dict if needed + if hasattr(session, "keys"): + session = {k: session[k] for k in session.keys()} + + return stats, session diff --git a/src/kallam/infra/session_store.py b/src/kallam/infra/session_store.py new file mode 100644 index 0000000000000000000000000000000000000000..4b5a08edea7c91ec8aa8e590361859bae32f264c --- /dev/null +++ b/src/kallam/infra/session_store.py @@ -0,0 +1,122 @@ +# infra/session_store.py +from __future__ import annotations + +from dataclasses import dataclass +from datetime import datetime +from typing import Optional, Dict, Any, List +from kallam.infra.db import sqlite_conn +import uuid + +@dataclass +class SessionMeta: + session_id: str + total_user_messages: int + +class SessionStore: + """ + Storage facade expected by ChatbotManager. + Accepts either 'sqlite:///path/to.db' or 'path/to.db' and normalizes to file path. + """ + def __init__(self, db_path: str): + # ChatbotManager passes f"sqlite:///{Path(...)}" + self.db_path = db_path.replace("sqlite:///", "") + + # ----------------- create ----------------- + def create(self, saved_memories: Optional[str]) -> str: + sid = f"ID-{uuid.uuid4().hex[:8].upper()}" + now = datetime.now().isoformat() + with sqlite_conn(self.db_path) as c: + c.execute( + """ + INSERT INTO sessions ( + session_id, timestamp, last_activity, saved_memories, + total_messages, total_user_messages, total_assistant_messages, + total_summaries, is_active + ) + VALUES (?, ?, ?, ?, 0, 0, 0, 0, 1) + """, + (sid, now, now, saved_memories), + ) + return sid + + # ----------------- read (typed) ----------------- + def get(self, session_id: str) -> Optional[SessionMeta]: + with sqlite_conn(self.db_path) as c: + r = c.execute( + "SELECT * FROM sessions WHERE session_id = ?", + (session_id,), + ).fetchone() + if not r: + return None + return SessionMeta( + session_id=r["session_id"], + total_user_messages=r["total_user_messages"] or 0, + ) + + # ----------------- read (raw dict) ----------------- + def get_raw(self, session_id: str) -> Optional[Dict[str, Any]]: + with sqlite_conn(self.db_path) as c: + r = c.execute( + "SELECT * FROM sessions WHERE session_id = ?", + (session_id,), + ).fetchone() + return dict(r) if r else None + + # ----------------- meta subset for manager ----------------- + def get_meta(self, session_id: str) -> Optional[Dict[str, Any]]: + with sqlite_conn(self.db_path) as c: + r = c.execute( + """ + SELECT session_id, total_messages, total_user_messages, saved_memories, + total_assistant_messages, total_summaries, last_activity, is_active + FROM sessions + WHERE session_id = ? + """, + (session_id,), + ).fetchone() + return dict(r) if r else None + + # ----------------- list ----------------- + def list(self, active_only: bool = True, limit: int = 50) -> List[Dict[str, Any]]: + sql = "SELECT * FROM sessions" + params: List[Any] = [] + if active_only: + sql += " WHERE is_active = 1" + sql += " ORDER BY last_activity DESC LIMIT ?" + params.append(limit) + with sqlite_conn(self.db_path) as c: + rows = c.execute(sql, params).fetchall() + return [dict(r) for r in rows] + + # ----------------- close ----------------- + def close(self, session_id: str) -> bool: + with sqlite_conn(self.db_path) as c: + res = c.execute( + "UPDATE sessions SET is_active = 0, last_activity = ? WHERE session_id = ?", + (datetime.now().isoformat(), session_id), + ) + return res.rowcount > 0 + + # ----------------- delete ----------------- + def delete(self, session_id: str) -> bool: + # messages/summaries are ON DELETE CASCADE according to your schema + with sqlite_conn(self.db_path) as c: + res = c.execute("DELETE FROM sessions WHERE session_id = ?", (session_id,)) + return res.rowcount > 0 + + # ----------------- cleanup ----------------- + def cleanup_before(self, cutoff_iso: str) -> int: + with sqlite_conn(self.db_path) as c: + res = c.execute( + "DELETE FROM sessions WHERE last_activity < ?", + (cutoff_iso,), + ) + return res.rowcount + + # Optional: utility to bump last_activity + def touch(self, session_id: str) -> None: + with sqlite_conn(self.db_path) as c: + c.execute( + "UPDATE sessions SET last_activity = ? WHERE session_id = ?", + (datetime.now().isoformat(), session_id), + ) diff --git a/src/kallam/infra/summary_store.py b/src/kallam/infra/summary_store.py new file mode 100644 index 0000000000000000000000000000000000000000..4cc11f2488f7e820e5af42ef6e2220a06bd7ca71 --- /dev/null +++ b/src/kallam/infra/summary_store.py @@ -0,0 +1,22 @@ +# infra/summary_store.py +from datetime import datetime +from kallam.infra.db import sqlite_conn + +class SummaryStore: + def __init__(self, db_path: str): self.db_path = db_path.replace("sqlite:///", "") + + def list(self, session_id: str, limit: int | None = None): + q = "select timestamp, summary from summaries where session_id=? order by id desc" + params = [session_id] + if limit: q += " limit ?"; params.append(limit) + with sqlite_conn(self.db_path) as c: + rows = c.execute(q, params).fetchall() + return [{"timestamp": r["timestamp"], "summary": r["summary"]} for r in rows] + + def add(self, session_id: str, summary: str): + now = datetime.now().isoformat() + with sqlite_conn(self.db_path) as c: + c.execute("insert into summaries (session_id, timestamp, summary) values (?,?,?)", + (session_id, now, summary)) + c.execute("update sessions set total_summaries = total_summaries + 1, last_activity=? where session_id=?", + (now, session_id)) diff --git a/src/kallam/infra/token_counter.py b/src/kallam/infra/token_counter.py new file mode 100644 index 0000000000000000000000000000000000000000..95423e7ddb04eb83a24eef23bf4e1a0ad7314be0 --- /dev/null +++ b/src/kallam/infra/token_counter.py @@ -0,0 +1,14 @@ +# infra/token_counter.py +class TokenCounter: + def __init__(self, capacity: int = 1000): + self.cache = {} + self.capacity = capacity + + def count(self, text: str) -> int: + h = hash(text) + if h in self.cache: return self.cache[h] + n = max(1, len(text.split())) + if len(self.cache) >= self.capacity: + self.cache = dict(list(self.cache.items())[self.capacity // 2 :]) + self.cache[h] = n + return n diff --git a/src/kallam/infrastructure/__pycache__/sea_lion.cpython-311.pyc b/src/kallam/infrastructure/__pycache__/sea_lion.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..b43e6d9aa10af39f348693826c28c06da1209ff3 Binary files /dev/null and b/src/kallam/infrastructure/__pycache__/sea_lion.cpython-311.pyc differ diff --git a/src/kallam/infrastructure/sea_lion.py b/src/kallam/infrastructure/sea_lion.py new file mode 100644 index 0000000000000000000000000000000000000000..7e768575861e696b154cd275fae1d1f66fae160c --- /dev/null +++ b/src/kallam/infrastructure/sea_lion.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +"""Shared helpers for configuring SEA-Lion access. + +Supports both the public SEA-Lion API and the Amazon Bedrock access gateway +that exposes an OpenAI-compatible endpoint. Configuration is driven entirely by +environment variables so individual agents do not have to duplicate the same +initialisation logic. +""" + +from dataclasses import dataclass +import hashlib +import os +from typing import Optional + + +@dataclass +class SeaLionSettings: + base_url: str + token: Optional[str] + model: str + mode: str # "gateway", "api-key", or "disabled" + + +def _strip_or_none(value: Optional[str]) -> Optional[str]: + if value is None: + return None + value = value.strip() + return value or None + + +def fingerprint_secret(secret: Optional[str]) -> str: + """Return a short fingerprint for a secret without exposing it.""" + if not secret: + return "unset" + try: + return hashlib.sha256(secret.encode("utf-8")).hexdigest()[:10] + except Exception: + return "error" + + +def load_sea_lion_settings(*, default_model: str) -> SeaLionSettings: + """Load SEA-Lion configuration from environment variables.""" + gateway_url = _strip_or_none(os.getenv("SEA_LION_GATEWAY_URL")) + direct_url = _strip_or_none(os.getenv("SEA_LION_BASE_URL")) + base_url = gateway_url or direct_url or "https://api.sea-lion.ai/v1" + base_url = base_url.rstrip("/") + + gateway_token = _strip_or_none(os.getenv("SEA_LION_GATEWAY_TOKEN")) + api_key = _strip_or_none(os.getenv("SEA_LION_API_KEY")) + + if gateway_token: + mode = "gateway" + token = gateway_token + elif api_key: + mode = "api-key" + token = api_key + else: + mode = "disabled" + token = None + + model = _strip_or_none(os.getenv("SEA_LION_MODEL_ID")) or default_model + + return SeaLionSettings( + base_url=base_url, + token=token, + model=model, + mode=mode, + )