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"""
+
+ """)
+
+ # 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,
+ )