Spaces:
Sleeping
Sleeping
Kennedy Johnson Cursor commited on
Commit ·
de852c2
0
Parent(s):
Add LaunchPadAI undergraduate internship advisor panel.
Browse filesRebrand the CCAI MuscleGrowthAI template into LaunchPadAI with five career advisors, launchpad_config.yaml, and HF/Docker deploy wiring.
Co-authored-by: Cursor <cursoragent@cursor.com>
This view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +20 -0
- .gitattributes +36 -0
- .github/workflows/propose_release.yml +29 -0
- .github/workflows/publish_release.yml +31 -0
- .github/workflows/publish_test_build.yml +24 -0
- .github/workflows/unit_tests.yml +37 -0
- .gitignore +27 -0
- CHANGELOG.md +74 -0
- Dockerfile +76 -0
- README.md +98 -0
- advisor-panel-draft.json +111 -0
- docker-compose.yml +33 -0
- docs/nutrition_tools.md +35 -0
- launchpad_config.yaml +202 -0
- multi_llm_chatbot_backend/.env.example +24 -0
- multi_llm_chatbot_backend/README.md +178 -0
- multi_llm_chatbot_backend/app/__init__.py +0 -0
- multi_llm_chatbot_backend/app/api/README.md +175 -0
- multi_llm_chatbot_backend/app/api/old_routes.py +1001 -0
- multi_llm_chatbot_backend/app/api/routes/__init__.py +19 -0
- multi_llm_chatbot_backend/app/api/routes/auth.py +319 -0
- multi_llm_chatbot_backend/app/api/routes/chat.py +504 -0
- multi_llm_chatbot_backend/app/api/routes/chat_sessions.py +293 -0
- multi_llm_chatbot_backend/app/api/routes/debug.py +102 -0
- multi_llm_chatbot_backend/app/api/routes/documents.py +530 -0
- multi_llm_chatbot_backend/app/api/routes/onboarding.py +152 -0
- multi_llm_chatbot_backend/app/api/routes/phd_canvas.py +316 -0
- multi_llm_chatbot_backend/app/api/routes/provider.py +111 -0
- multi_llm_chatbot_backend/app/api/routes/root.py +30 -0
- multi_llm_chatbot_backend/app/api/routes/sessions.py +265 -0
- multi_llm_chatbot_backend/app/api/routes/user_profile.py +201 -0
- multi_llm_chatbot_backend/app/api/routes/voice.py +270 -0
- multi_llm_chatbot_backend/app/api/utils.py +140 -0
- multi_llm_chatbot_backend/app/assets/avatars/advisor1.png +3 -0
- multi_llm_chatbot_backend/app/assets/avatars/advisor2.png +3 -0
- multi_llm_chatbot_backend/app/assets/avatars/advisor3.png +3 -0
- multi_llm_chatbot_backend/app/assets/avatars/advisor4.png +3 -0
- multi_llm_chatbot_backend/app/assets/avatars/advisor5.png +3 -0
- multi_llm_chatbot_backend/app/assets/avatars/advisor6.png +3 -0
- multi_llm_chatbot_backend/app/assets/avatars/advisor7.png +3 -0
- multi_llm_chatbot_backend/app/config.py +519 -0
- multi_llm_chatbot_backend/app/core/README.md +200 -0
- multi_llm_chatbot_backend/app/core/auth.py +119 -0
- multi_llm_chatbot_backend/app/core/bootstrap.py +159 -0
- multi_llm_chatbot_backend/app/core/canvas_analysis.py +287 -0
- multi_llm_chatbot_backend/app/core/canvas_database.py +35 -0
- multi_llm_chatbot_backend/app/core/canvas_manager.py +408 -0
- multi_llm_chatbot_backend/app/core/context.py +17 -0
- multi_llm_chatbot_backend/app/core/context_manager.py +296 -0
- multi_llm_chatbot_backend/app/core/database.py +50 -0
.dockerignore
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
**/node_modules
|
| 2 |
+
**/venv
|
| 3 |
+
**/.venv
|
| 4 |
+
**/__pycache__
|
| 5 |
+
**/chromadb_storage
|
| 6 |
+
**/chroma_data
|
| 7 |
+
**/chroma_db
|
| 8 |
+
.git
|
| 9 |
+
.gitignore
|
| 10 |
+
.env
|
| 11 |
+
**/.env
|
| 12 |
+
.vscode
|
| 13 |
+
.cursor
|
| 14 |
+
**/.idea
|
| 15 |
+
**/.DS_Store
|
| 16 |
+
**/Thumbs.db
|
| 17 |
+
docker-compose.yml
|
| 18 |
+
Dockerfile
|
| 19 |
+
.dockerignore
|
| 20 |
+
*.md
|
.gitattributes
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
*.7z filter=lfs diff=lfs merge=lfs -text
|
| 2 |
+
*.arrow filter=lfs diff=lfs merge=lfs -text
|
| 3 |
+
*.bin filter=lfs diff=lfs merge=lfs -text
|
| 4 |
+
*.bz2 filter=lfs diff=lfs merge=lfs -text
|
| 5 |
+
*.ckpt filter=lfs diff=lfs merge=lfs -text
|
| 6 |
+
*.ftz filter=lfs diff=lfs merge=lfs -text
|
| 7 |
+
*.gz filter=lfs diff=lfs merge=lfs -text
|
| 8 |
+
*.h5 filter=lfs diff=lfs merge=lfs -text
|
| 9 |
+
*.joblib filter=lfs diff=lfs merge=lfs -text
|
| 10 |
+
*.lfs.* filter=lfs diff=lfs merge=lfs -text
|
| 11 |
+
*.mlmodel filter=lfs diff=lfs merge=lfs -text
|
| 12 |
+
*.model filter=lfs diff=lfs merge=lfs -text
|
| 13 |
+
*.msgpack filter=lfs diff=lfs merge=lfs -text
|
| 14 |
+
*.npy filter=lfs diff=lfs merge=lfs -text
|
| 15 |
+
*.npz filter=lfs diff=lfs merge=lfs -text
|
| 16 |
+
*.onnx filter=lfs diff=lfs merge=lfs -text
|
| 17 |
+
*.ot filter=lfs diff=lfs merge=lfs -text
|
| 18 |
+
*.parquet filter=lfs diff=lfs merge=lfs -text
|
| 19 |
+
*.pb filter=lfs diff=lfs merge=lfs -text
|
| 20 |
+
*.pickle filter=lfs diff=lfs merge=lfs -text
|
| 21 |
+
*.pkl filter=lfs diff=lfs merge=lfs -text
|
| 22 |
+
*.pt filter=lfs diff=lfs merge=lfs -text
|
| 23 |
+
*.pth filter=lfs diff=lfs merge=lfs -text
|
| 24 |
+
*.rar filter=lfs diff=lfs merge=lfs -text
|
| 25 |
+
*.safetensors filter=lfs diff=lfs merge=lfs -text
|
| 26 |
+
saved_model/**/* filter=lfs diff=lfs merge=lfs -text
|
| 27 |
+
*.tar.* filter=lfs diff=lfs merge=lfs -text
|
| 28 |
+
*.tar filter=lfs diff=lfs merge=lfs -text
|
| 29 |
+
*.tflite filter=lfs diff=lfs merge=lfs -text
|
| 30 |
+
*.tgz filter=lfs diff=lfs merge=lfs -text
|
| 31 |
+
*.wasm filter=lfs diff=lfs merge=lfs -text
|
| 32 |
+
*.xz filter=lfs diff=lfs merge=lfs -text
|
| 33 |
+
*.zip filter=lfs diff=lfs merge=lfs -text
|
| 34 |
+
*.zst filter=lfs diff=lfs merge=lfs -text
|
| 35 |
+
*tfevents* filter=lfs diff=lfs merge=lfs -text
|
| 36 |
+
*.png filter=lfs diff=lfs merge=lfs -text
|
.github/workflows/propose_release.yml
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Propose Stable Release
|
| 2 |
+
on:
|
| 3 |
+
workflow_dispatch:
|
| 4 |
+
inputs:
|
| 5 |
+
release_type:
|
| 6 |
+
type: choice
|
| 7 |
+
description: Release Type
|
| 8 |
+
options:
|
| 9 |
+
- patch
|
| 10 |
+
- minor
|
| 11 |
+
- major
|
| 12 |
+
jobs:
|
| 13 |
+
update_version:
|
| 14 |
+
uses: neongeckocom/.github/.github/workflows/propose_semver_release.yml@master
|
| 15 |
+
with:
|
| 16 |
+
branch: dev
|
| 17 |
+
release_type: ${{ inputs.release_type }}
|
| 18 |
+
update_changelog: True
|
| 19 |
+
version_file: "multi_llm_chatbot_backend/app/version.py"
|
| 20 |
+
pull_changes:
|
| 21 |
+
uses: neongeckocom/.github/.github/workflows/pull_master.yml@master
|
| 22 |
+
needs: update_version
|
| 23 |
+
with:
|
| 24 |
+
pr_reviewer: neonreviewers
|
| 25 |
+
pr_assignee: ${{ github.actor }}
|
| 26 |
+
pr_draft: false
|
| 27 |
+
pr_title: ${{ needs.update_version.outputs.version }}
|
| 28 |
+
pr_body: ${{ needs.update_version.outputs.changelog }}
|
| 29 |
+
destination: main
|
.github/workflows/publish_release.yml
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# On every push to `main` (which happens when a release PR produced by
|
| 2 |
+
# Propose Stable Release is merged), read the current version and create a
|
| 3 |
+
# matching GitHub release. Docker image publishing is intentionally left
|
| 4 |
+
# out for now and can be added separately.
|
| 5 |
+
|
| 6 |
+
name: Publish GitHub Release
|
| 7 |
+
on:
|
| 8 |
+
push:
|
| 9 |
+
branches:
|
| 10 |
+
- main
|
| 11 |
+
|
| 12 |
+
jobs:
|
| 13 |
+
tag_release:
|
| 14 |
+
runs-on: ubuntu-latest
|
| 15 |
+
permissions:
|
| 16 |
+
contents: write
|
| 17 |
+
steps:
|
| 18 |
+
- uses: actions/checkout@v4
|
| 19 |
+
- name: Set up Python
|
| 20 |
+
uses: actions/setup-python@v5
|
| 21 |
+
with:
|
| 22 |
+
python-version: "3.11"
|
| 23 |
+
- name: Get Version
|
| 24 |
+
run: |
|
| 25 |
+
VERSION=$(python multi_llm_chatbot_backend/app/version.py)
|
| 26 |
+
echo "VERSION=${VERSION}" >> $GITHUB_ENV
|
| 27 |
+
- uses: ncipollo/release-action@v1
|
| 28 |
+
with:
|
| 29 |
+
token: ${{ secrets.GITHUB_TOKEN }}
|
| 30 |
+
tag: ${{ env.VERSION }}
|
| 31 |
+
generateReleaseNotes: true
|
.github/workflows/publish_test_build.yml
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Bumps the alpha suffix on every push to `dev` (except when the only
|
| 2 |
+
# change is to the version file itself, to prevent a publish loop). A
|
| 3 |
+
# GitHub prerelease is cut for each alpha so artifacts are easy to find.
|
| 4 |
+
# This project is not distributed via PyPI, so `publish_pypi` is disabled.
|
| 5 |
+
|
| 6 |
+
name: Publish Alpha Build
|
| 7 |
+
on:
|
| 8 |
+
push:
|
| 9 |
+
branches:
|
| 10 |
+
- dev
|
| 11 |
+
paths-ignore:
|
| 12 |
+
- 'multi_llm_chatbot_backend/app/version.py'
|
| 13 |
+
- 'CHANGELOG.md'
|
| 14 |
+
|
| 15 |
+
jobs:
|
| 16 |
+
publish_alpha_release:
|
| 17 |
+
uses: neongeckocom/.github/.github/workflows/publish_alpha_release.yml@master
|
| 18 |
+
secrets: inherit
|
| 19 |
+
with:
|
| 20 |
+
version_file: "multi_llm_chatbot_backend/app/version.py"
|
| 21 |
+
setup_py: "multi_llm_chatbot_backend/app/version.py"
|
| 22 |
+
publish_prerelease: true
|
| 23 |
+
publish_pypi: false
|
| 24 |
+
update_changelog: true
|
.github/workflows/unit_tests.yml
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Run Unit Tests
|
| 2 |
+
on:
|
| 3 |
+
pull_request:
|
| 4 |
+
workflow_dispatch:
|
| 5 |
+
|
| 6 |
+
jobs:
|
| 7 |
+
unit_tests:
|
| 8 |
+
strategy:
|
| 9 |
+
matrix:
|
| 10 |
+
python-version: ['3.10', '3.11', '3.12']
|
| 11 |
+
runs-on: ubuntu-latest
|
| 12 |
+
timeout-minutes: 15
|
| 13 |
+
steps:
|
| 14 |
+
- uses: actions/checkout@v4
|
| 15 |
+
|
| 16 |
+
- name: Set up Python ${{ matrix.python-version }}
|
| 17 |
+
uses: actions/setup-python@v5
|
| 18 |
+
with:
|
| 19 |
+
python-version: ${{ matrix.python-version }}
|
| 20 |
+
|
| 21 |
+
- name: Install dependencies
|
| 22 |
+
working-directory: multi_llm_chatbot_backend
|
| 23 |
+
run: |
|
| 24 |
+
pip install wheel
|
| 25 |
+
pip install -r requirements.txt -r test_requirements.txt
|
| 26 |
+
|
| 27 |
+
- name: Run unit tests
|
| 28 |
+
working-directory: multi_llm_chatbot_backend
|
| 29 |
+
run: |
|
| 30 |
+
pytest app/tests/unit/ --junitxml=test-results/results.xml
|
| 31 |
+
|
| 32 |
+
- name: Upload test results
|
| 33 |
+
if: always()
|
| 34 |
+
uses: actions/upload-artifact@v4
|
| 35 |
+
with:
|
| 36 |
+
name: test-results-py${{ matrix.python-version }}
|
| 37 |
+
path: multi_llm_chatbot_backend/test-results/
|
.gitignore
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# main
|
| 2 |
+
.env
|
| 3 |
+
gcp_build.txt
|
| 4 |
+
|
| 5 |
+
# backend
|
| 6 |
+
multi_llm_chatbot_backend/.env
|
| 7 |
+
multi_llm_chatbot_backend/chroma_db/
|
| 8 |
+
multi_llm_chatbot_backend/chromadb_storage/
|
| 9 |
+
multi_llm_chatbot_backend/.dockerignore
|
| 10 |
+
multi_llm_chatbot_backend/.env.deploy
|
| 11 |
+
multi_llm_chatbot_backend/Dockerfile
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# frontend
|
| 16 |
+
phd-advisor-frontend/.env.production
|
| 17 |
+
phd-advisor-frontend/.firebase/
|
| 18 |
+
phd-advisor-frontend/.firebaserc
|
| 19 |
+
phd-advisor-frontend/firebase.json
|
| 20 |
+
|
| 21 |
+
# Ignore all __pycache__ folders
|
| 22 |
+
**/__pycache__/
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
# Python virtual environments
|
| 26 |
+
**/venv/
|
| 27 |
+
.venv/
|
CHANGELOG.md
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Changelog
|
| 2 |
+
|
| 3 |
+
## [2.0.1a3](https://github.com/NeonGeckoCom/CCAI-Demo/tree/2.0.1a3) (2026-05-21)
|
| 4 |
+
|
| 5 |
+
[Full Changelog](https://github.com/NeonGeckoCom/CCAI-Demo/compare/2.0.1a2...2.0.1a3)
|
| 6 |
+
|
| 7 |
+
**Merged pull requests:**
|
| 8 |
+
|
| 9 |
+
- Add Onboarding tour [\#48](https://github.com/NeonGeckoCom/CCAI-Demo/pull/48) ([NeonRyan](https://github.com/NeonRyan))
|
| 10 |
+
|
| 11 |
+
## [2.0.1a2](https://github.com/NeonGeckoCom/CCAI-Demo/tree/2.0.1a2) (2026-05-19)
|
| 12 |
+
|
| 13 |
+
[Full Changelog](https://github.com/NeonGeckoCom/CCAI-Demo/compare/33862e61789b40734f952d1ae06b03c86dff49af...2.0.1a2)
|
| 14 |
+
|
| 15 |
+
**Implemented enhancements:**
|
| 16 |
+
|
| 17 |
+
- \[FEAT\] Clear User Data [\#52](https://github.com/NeonGeckoCom/CCAI-Demo/issues/52)
|
| 18 |
+
- \[FEAT\] Support tools [\#34](https://github.com/NeonGeckoCom/CCAI-Demo/issues/34)
|
| 19 |
+
- \[FEAT\] Add endpoint and UI for User Account updates [\#26](https://github.com/NeonGeckoCom/CCAI-Demo/issues/26)
|
| 20 |
+
- \[FEAT\] Support advisor avatar images [\#25](https://github.com/NeonGeckoCom/CCAI-Demo/issues/25)
|
| 21 |
+
- \[FEAT\] Configurable User Guide [\#24](https://github.com/NeonGeckoCom/CCAI-Demo/issues/24)
|
| 22 |
+
- \[FEAT\] Support vLLM endpoints [\#22](https://github.com/NeonGeckoCom/CCAI-Demo/issues/22)
|
| 23 |
+
- \[FEAT\] Improved persona configuration handling [\#8](https://github.com/NeonGeckoCom/CCAI-Demo/issues/8)
|
| 24 |
+
- \[FEAT\] Implement automatic color picker for personas [\#7](https://github.com/NeonGeckoCom/CCAI-Demo/issues/7)
|
| 25 |
+
- \[FEAT\] Validate configured icons [\#6](https://github.com/NeonGeckoCom/CCAI-Demo/issues/6)
|
| 26 |
+
- \[FEAT\] Improved Checks for "Clarification Needed" [\#5](https://github.com/NeonGeckoCom/CCAI-Demo/issues/5)
|
| 27 |
+
- \[FEAT\] Versioning and release automation [\#4](https://github.com/NeonGeckoCom/CCAI-Demo/issues/4)
|
| 28 |
+
|
| 29 |
+
**Fixed bugs:**
|
| 30 |
+
|
| 31 |
+
- \[BUG\] Deleting a chat in the UI causes an empty chat to be created [\#45](https://github.com/NeonGeckoCom/CCAI-Demo/issues/45)
|
| 32 |
+
- \[BUG\] User inputs are sometimes repeated [\#42](https://github.com/NeonGeckoCom/CCAI-Demo/issues/42)
|
| 33 |
+
- \[BUG\] Improved method for LLM JSON Generation [\#13](https://github.com/NeonGeckoCom/CCAI-Demo/issues/13)
|
| 34 |
+
|
| 35 |
+
**Closed issues:**
|
| 36 |
+
|
| 37 |
+
- Pin the requirements.txt to stable versions [\#37](https://github.com/NeonGeckoCom/CCAI-Demo/issues/37)
|
| 38 |
+
|
| 39 |
+
**Merged pull requests:**
|
| 40 |
+
|
| 41 |
+
- feat/Added front end support for settings page [\#60](https://github.com/NeonGeckoCom/CCAI-Demo/pull/60) ([NeonRyan](https://github.com/NeonRyan))
|
| 42 |
+
- Clear User Data [\#58](https://github.com/NeonGeckoCom/CCAI-Demo/pull/58) ([NeonCharlie-24](https://github.com/NeonCharlie-24))
|
| 43 |
+
- Feat/improve clarification check [\#57](https://github.com/NeonGeckoCom/CCAI-Demo/pull/57) ([NeonCharlie-24](https://github.com/NeonCharlie-24))
|
| 44 |
+
- UI Improvements [\#56](https://github.com/NeonGeckoCom/CCAI-Demo/pull/56) ([NeonRyan](https://github.com/NeonRyan))
|
| 45 |
+
- Fix bug around chat deletion [\#54](https://github.com/NeonGeckoCom/CCAI-Demo/pull/54) ([NeonRyan](https://github.com/NeonRyan))
|
| 46 |
+
- Add User Guide [\#53](https://github.com/NeonGeckoCom/CCAI-Demo/pull/53) ([NeonRyan](https://github.com/NeonRyan))
|
| 47 |
+
- Add versioning automation [\#47](https://github.com/NeonGeckoCom/CCAI-Demo/pull/47) ([NeonDaniel](https://github.com/NeonDaniel))
|
| 48 |
+
- Fix/dup first message [\#44](https://github.com/NeonGeckoCom/CCAI-Demo/pull/44) ([NeonCharlie-24](https://github.com/NeonCharlie-24))
|
| 49 |
+
- Feat/persona avatars [\#43](https://github.com/NeonGeckoCom/CCAI-Demo/pull/43) ([NeonCharlie-24](https://github.com/NeonCharlie-24))
|
| 50 |
+
- Feat/account management [\#41](https://github.com/NeonGeckoCom/CCAI-Demo/pull/41) ([NeonCharlie-24](https://github.com/NeonCharlie-24))
|
| 51 |
+
- Fix/json output [\#40](https://github.com/NeonGeckoCom/CCAI-Demo/pull/40) ([NeonCharlie-24](https://github.com/NeonCharlie-24))
|
| 52 |
+
- Feat/advisor tools [\#38](https://github.com/NeonGeckoCom/CCAI-Demo/pull/38) ([NeonCharlie-24](https://github.com/NeonCharlie-24))
|
| 53 |
+
- Feat/vllm endpoints [\#36](https://github.com/NeonGeckoCom/CCAI-Demo/pull/36) ([NeonCharlie-24](https://github.com/NeonCharlie-24))
|
| 54 |
+
- Refactor/unittests [\#33](https://github.com/NeonGeckoCom/CCAI-Demo/pull/33) ([NeonCharlie-24](https://github.com/NeonCharlie-24))
|
| 55 |
+
- Support Voice Interaction [\#32](https://github.com/NeonGeckoCom/CCAI-Demo/pull/32) ([NeonDaniel](https://github.com/NeonDaniel))
|
| 56 |
+
- Fix/structured json output [\#30](https://github.com/NeonGeckoCom/CCAI-Demo/pull/30) ([NeonCharlie-24](https://github.com/NeonCharlie-24))
|
| 57 |
+
- Feat/auto persona colors [\#23](https://github.com/NeonGeckoCom/CCAI-Demo/pull/23) ([NeonCharlie-24](https://github.com/NeonCharlie-24))
|
| 58 |
+
- Feat/validate lucide icons [\#21](https://github.com/NeonGeckoCom/CCAI-Demo/pull/21) ([NeonCharlie-24](https://github.com/NeonCharlie-24))
|
| 59 |
+
- Initial setup of GitHub Actions workflow [\#20](https://github.com/NeonGeckoCom/CCAI-Demo/pull/20) ([NeonCharlie-24](https://github.com/NeonCharlie-24))
|
| 60 |
+
- Refactor chat UI layout [\#19](https://github.com/NeonGeckoCom/CCAI-Demo/pull/19) ([NeonDaniel](https://github.com/NeonDaniel))
|
| 61 |
+
- Stream LLM Responses [\#18](https://github.com/NeonGeckoCom/CCAI-Demo/pull/18) ([NeonDaniel](https://github.com/NeonDaniel))
|
| 62 |
+
- Feat/improve persona config [\#16](https://github.com/NeonGeckoCom/CCAI-Demo/pull/16) ([NeonCharlie-24](https://github.com/NeonCharlie-24))
|
| 63 |
+
- Add Neon AI Branding [\#15](https://github.com/NeonGeckoCom/CCAI-Demo/pull/15) ([NeonDaniel](https://github.com/NeonDaniel))
|
| 64 |
+
- Docs/update readme repo URL [\#14](https://github.com/NeonGeckoCom/CCAI-Demo/pull/14) ([NeonCharlie-24](https://github.com/NeonCharlie-24))
|
| 65 |
+
- Improve backend logic around user input parsing [\#12](https://github.com/NeonGeckoCom/CCAI-Demo/pull/12) ([NeonDaniel](https://github.com/NeonDaniel))
|
| 66 |
+
- Fix Home navigation to prevent logging out [\#11](https://github.com/NeonGeckoCom/CCAI-Demo/pull/11) ([NeonDaniel](https://github.com/NeonDaniel))
|
| 67 |
+
- Add configuration for Undergrad Advisor Panel [\#10](https://github.com/NeonGeckoCom/CCAI-Demo/pull/10) ([NeonDaniel](https://github.com/NeonDaniel))
|
| 68 |
+
- Add configuration support [\#3](https://github.com/NeonGeckoCom/CCAI-Demo/pull/3) ([NeonDaniel](https://github.com/NeonDaniel))
|
| 69 |
+
- Docker optimization by Cursor [\#2](https://github.com/NeonGeckoCom/CCAI-Demo/pull/2) ([NeonDaniel](https://github.com/NeonDaniel))
|
| 70 |
+
- Implement Docker deployment [\#1](https://github.com/NeonGeckoCom/CCAI-Demo/pull/1) ([NeonDaniel](https://github.com/NeonDaniel))
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
\* *This Changelog was automatically generated by [github_changelog_generator](https://github.com/github-changelog-generator/github-changelog-generator)*
|
Dockerfile
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# syntax=docker/dockerfile:1.7
|
| 2 |
+
# ---------------------------------------------------------------------------
|
| 3 |
+
# LaunchPadAI Panel — single-container HuggingFace Spaces image.
|
| 4 |
+
#
|
| 5 |
+
# Mirrors the structural choices used by the working HF Spaces deployments
|
| 6 |
+
# CCAI-Vibe-Demo and CU-Student-AIProject-Helper:
|
| 7 |
+
# * multi-stage Node frontend build → Python+SPA bundle
|
| 8 |
+
# * non-root user uid 1000, port 7860 (HF Spaces convention)
|
| 9 |
+
# * persistence on the HF Storage Bucket mount at /data (SQLite shim)
|
| 10 |
+
# * REACT_APP_API_URL is set to the empty string at build time so the SPA
|
| 11 |
+
# issues relative URLs and shares the FastAPI origin
|
| 12 |
+
#
|
| 13 |
+
# Build context: repo root (this file).
|
| 14 |
+
# ---------------------------------------------------------------------------
|
| 15 |
+
|
| 16 |
+
# ---- 1. Frontend build (CRA) ----------------------------------------------
|
| 17 |
+
FROM node:20-bookworm AS frontend-build
|
| 18 |
+
WORKDIR /app/frontend
|
| 19 |
+
|
| 20 |
+
COPY phd-advisor-frontend/package.json phd-advisor-frontend/package-lock.json* ./
|
| 21 |
+
RUN --mount=type=cache,target=/root/.npm \
|
| 22 |
+
npm ci
|
| 23 |
+
|
| 24 |
+
COPY phd-advisor-frontend/ ./
|
| 25 |
+
|
| 26 |
+
# Empty REACT_APP_API_URL → CRA inlines '' so every fetch() hits the same
|
| 27 |
+
# origin as the SPA (the FastAPI server below).
|
| 28 |
+
ENV REACT_APP_API_URL=""
|
| 29 |
+
RUN npm run build
|
| 30 |
+
|
| 31 |
+
# ---- 2. Python runtime + SPA bundle ---------------------------------------
|
| 32 |
+
FROM python:3.12-slim-bookworm
|
| 33 |
+
|
| 34 |
+
# ffmpeg is required by the /api/voice/transcribe endpoint
|
| 35 |
+
# (browser WebM/Opus → WAV for Whisper). Kept on the runtime image only,
|
| 36 |
+
# not on the build image, so we don't pay the apt cost during incremental
|
| 37 |
+
# code rebuilds.
|
| 38 |
+
RUN apt-get update && \
|
| 39 |
+
apt-get install -y --no-install-recommends ffmpeg && \
|
| 40 |
+
rm -rf /var/lib/apt/lists/*
|
| 41 |
+
|
| 42 |
+
# HF Spaces runs the container as uid 1000 and expects the app to do the
|
| 43 |
+
# same; with a non-root user we cannot bind privileged ports, but :7860 is
|
| 44 |
+
# fine.
|
| 45 |
+
RUN useradd -m -u 1000 user && mkdir -p /home/user/data && chown user:user /home/user/data
|
| 46 |
+
USER user
|
| 47 |
+
ENV HOME=/home/user \
|
| 48 |
+
PATH=/home/user/.local/bin:$PATH \
|
| 49 |
+
PYTHONUNBUFFERED=1 \
|
| 50 |
+
CORS_ORIGINS=* \
|
| 51 |
+
DATA_DIR=/home/user/data \
|
| 52 |
+
CONFIG_PATH=/home/user/app/launchpad_config.yaml
|
| 53 |
+
|
| 54 |
+
WORKDIR $HOME/app
|
| 55 |
+
|
| 56 |
+
# ---- Python deps (cached unless requirements.txt changes) -----------------
|
| 57 |
+
COPY --chown=user multi_llm_chatbot_backend/requirements.txt ./
|
| 58 |
+
RUN --mount=type=cache,target=/home/user/.cache/pip,uid=1000,gid=1000 \
|
| 59 |
+
pip install --no-cache-dir --user -r requirements.txt
|
| 60 |
+
|
| 61 |
+
# ---- Backend source -------------------------------------------------------
|
| 62 |
+
COPY --chown=user multi_llm_chatbot_backend/ ./
|
| 63 |
+
|
| 64 |
+
# ---- Top-level configuration files (config.yaml + persona definitions) ----
|
| 65 |
+
COPY --chown=user launchpad_config.yaml ./launchpad_config.yaml
|
| 66 |
+
COPY --chown=user personas/ ./personas/
|
| 67 |
+
|
| 68 |
+
# ---- Frontend bundle ------------------------------------------------------
|
| 69 |
+
# main.py mounts $HOME/app/static at "/" so the SPA is served same-origin
|
| 70 |
+
# with the API.
|
| 71 |
+
COPY --chown=user --from=frontend-build /app/frontend/build ./static
|
| 72 |
+
|
| 73 |
+
ENV PYTHONPATH=$HOME/app
|
| 74 |
+
|
| 75 |
+
EXPOSE 7860
|
| 76 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
title: LaunchPadAI
|
| 3 |
+
emoji: 🚀
|
| 4 |
+
colorFrom: green
|
| 5 |
+
colorTo: blue
|
| 6 |
+
sdk: docker
|
| 7 |
+
pinned: false
|
| 8 |
+
app_port: 7860
|
| 9 |
+
---
|
| 10 |
+
|
| 11 |
+
# LaunchPadAI Panel
|
| 12 |
+
|
| 13 |
+
An AI undergraduate internship and early-career assistant built on Neon AI's
|
| 14 |
+
Collaborative Conversational AI (CCAI) framework. Ask about Handshake/LinkedIn
|
| 15 |
+
search, resume polish, interview prep, application cadence, and networking —
|
| 16 |
+
and get diverse perspectives from a panel of five career AI advisors.
|
| 17 |
+
|
| 18 |
+
This repository is a **complete, deployable application** — the CCAI
|
| 19 |
+
multi-advisor stack (FastAPI backend + React frontend) wired to the
|
| 20 |
+
LaunchPadAI configuration in [`launchpad_config.yaml`](launchpad_config.yaml)
|
| 21 |
+
and the personas in [`personas/career_advisors/`](personas/career_advisors).
|
| 22 |
+
|
| 23 |
+
## Advisors
|
| 24 |
+
|
| 25 |
+
1. **Internship Search Strategist** — roles, platforms, target lists
|
| 26 |
+
2. **Resume Optimizer** — ATS, bullets, skills match
|
| 27 |
+
3. **Interview Coach** — prep, presence, company research
|
| 28 |
+
4. **Application Scheduler** — cadence, deadlines, tracking
|
| 29 |
+
5. **Career Path Mentor** — networking, pivot, full-time transition
|
| 30 |
+
|
| 31 |
+
## Hugging Face Spaces deployment
|
| 32 |
+
|
| 33 |
+
This Space ships as a single Docker image built from the repository-root
|
| 34 |
+
[`Dockerfile`](Dockerfile). The container:
|
| 35 |
+
|
| 36 |
+
1. Builds the React frontend (CRA) at image-build time with `REACT_APP_API_URL=""`
|
| 37 |
+
so every `fetch` issues a relative URL.
|
| 38 |
+
2. Serves the bundled SPA from FastAPI at `/`, with the API on `/api/...`,
|
| 39 |
+
`/auth/...`, etc. — all on the same `:7860` origin.
|
| 40 |
+
3. Persists user data (auth, profiles, chat sessions) in **SQLite via
|
| 41 |
+
`aiosqlite`** at `${DATA_DIR}/launchpad_panel.db`. Mount a Hugging Face
|
| 42 |
+
Storage Bucket at `/data` to make the database survive Space rebuilds.
|
| 43 |
+
There is **no MongoDB** and no third-party data plane.
|
| 44 |
+
|
| 45 |
+
### Required Space secrets
|
| 46 |
+
|
| 47 |
+
| Secret | Purpose |
|
| 48 |
+
|--------|---------|
|
| 49 |
+
| `JWT_SECRET_KEY` | Signs auth tokens. Set this to a long random string. |
|
| 50 |
+
| `OPENAI_API_KEY` | Powers the default OpenAI provider (`gpt-5.4-mini`). |
|
| 51 |
+
| `GEMINI_API_KEY` | Optional — only if you switch `llm.provider` back to `gemini`. |
|
| 52 |
+
|
| 53 |
+
Set these under **Settings → Variables and secrets** on the Space.
|
| 54 |
+
|
| 55 |
+
## Local deployment
|
| 56 |
+
|
| 57 |
+
### Option A — Docker
|
| 58 |
+
|
| 59 |
+
```bash
|
| 60 |
+
# From the repo root, create a .env with at least:
|
| 61 |
+
# JWT_SECRET_KEY=some-long-random-string
|
| 62 |
+
# OPENAI_API_KEY=your-openai-key
|
| 63 |
+
docker compose up --build
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
Open <http://localhost:7860>. Override the host port with `LAUNCHPAD_HOST_PORT` if
|
| 67 |
+
7860 is taken.
|
| 68 |
+
|
| 69 |
+
### Option B — Native (no Docker)
|
| 70 |
+
|
| 71 |
+
**Backend** (terminal 1):
|
| 72 |
+
|
| 73 |
+
```bash
|
| 74 |
+
cd multi_llm_chatbot_backend
|
| 75 |
+
python -m venv venv && source venv/bin/activate
|
| 76 |
+
pip install -r requirements.txt
|
| 77 |
+
CONFIG_PATH=../launchpad_config.yaml uvicorn app.main:app --reload --port 8000
|
| 78 |
+
```
|
| 79 |
+
|
| 80 |
+
**Frontend** (terminal 2):
|
| 81 |
+
|
| 82 |
+
```bash
|
| 83 |
+
cd phd-advisor-frontend
|
| 84 |
+
npm install
|
| 85 |
+
REACT_APP_API_URL=http://localhost:8000 npm start
|
| 86 |
+
```
|
| 87 |
+
|
| 88 |
+
Open <http://localhost:3000> — you should see **LaunchPadAI** with five career advisors.
|
| 89 |
+
|
| 90 |
+
## Configuration
|
| 91 |
+
|
| 92 |
+
App UI, login fields, chat examples, orchestrator keywords, and LLM/RAG
|
| 93 |
+
settings live in [`launchpad_config.yaml`](launchpad_config.yaml). The five
|
| 94 |
+
advisor YAMLs in `personas/career_advisors/` load automatically. Point the app at a
|
| 95 |
+
different config with `CONFIG_PATH`.
|
| 96 |
+
|
| 97 |
+
The workshop draft that generated this panel is saved as
|
| 98 |
+
[`advisor-panel-draft.json`](advisor-panel-draft.json).
|
advisor-panel-draft.json
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"authorName": "Kennedy Johnson",
|
| 3 |
+
"panelName": "Undergraduate Internship Search",
|
| 4 |
+
"purpose": "Help undergraduate students locate, apply for, and land internships in a competitive market — and pivot to entry-level full-time roles when appropriate.",
|
| 5 |
+
"advisors": [
|
| 6 |
+
{
|
| 7 |
+
"name": "Internship Search Strategist",
|
| 8 |
+
"role": "Roles, Platforms & Target Lists"
|
| 9 |
+
},
|
| 10 |
+
{
|
| 11 |
+
"name": "Resume Optimizer",
|
| 12 |
+
"role": "ATS, Bullets & Skills Match"
|
| 13 |
+
},
|
| 14 |
+
{
|
| 15 |
+
"name": "Interview Coach",
|
| 16 |
+
"role": "Prep, Presence & Company Research"
|
| 17 |
+
},
|
| 18 |
+
{
|
| 19 |
+
"name": "Application Scheduler",
|
| 20 |
+
"role": "Cadence, Deadlines & Tracking"
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"name": "Career Path Mentor",
|
| 24 |
+
"role": "Networking, Pivot & Full-Time Transition"
|
| 25 |
+
}
|
| 26 |
+
],
|
| 27 |
+
"visionApplicationPurpose": "Help undergraduate students locate, apply for, and land internships in a competitive market — and pivot to entry-level full-time roles when appropriate.",
|
| 28 |
+
"visionTargetAudience": "Undergraduate students, especially juniors (3rd year) seeking internships. Seniors (4th year+) are guided toward internships or full-time entry-level jobs.",
|
| 29 |
+
"visionKeyOutcomes": "Personalized daily/weekly application schedules; where to apply (Handshake, LinkedIn, company sites); interview tips and likely questions; resume feedback and revision based on experience and skills.",
|
| 30 |
+
"appTitle": "LaunchPadAI",
|
| 31 |
+
"appSubtitle": "AI mentor for internships to full-time jobs",
|
| 32 |
+
"primaryBrandColor": "#0F766E",
|
| 33 |
+
"logoIcon": "Rocket",
|
| 34 |
+
"footerText": "© 2026 Kennedy Johnson. All rights reserved.",
|
| 35 |
+
"headlinePrefix": "Undergraduate Internship Navigation",
|
| 36 |
+
"headlineHighlight": "AI Undergrad Job Finder",
|
| 37 |
+
"homepageDescription": "Receive a personalized plan to find and land an internship as a third-year undergrad, or pivot to an entry-level full-time job as a fourth-year student and beyond.",
|
| 38 |
+
"featuresSectionTitle": "AI Career Toolkit",
|
| 39 |
+
"featureCards": [
|
| 40 |
+
{
|
| 41 |
+
"title": "AI Internship Search",
|
| 42 |
+
"icon": "Search",
|
| 43 |
+
"description": "A personalized plan to match multiple internships a day, plus resume review and interview tips to build your career foundation."
|
| 44 |
+
},
|
| 45 |
+
{
|
| 46 |
+
"title": "Resume Optimizer",
|
| 47 |
+
"icon": "FilePenLine",
|
| 48 |
+
"description": "Feedback, edits, and tweaks so your resume stands out to ATS systems and recruiting teams."
|
| 49 |
+
},
|
| 50 |
+
{
|
| 51 |
+
"title": "AI Interview Helper",
|
| 52 |
+
"icon": "MessagesSquare",
|
| 53 |
+
"description": "Near-perfect answers, company research tips, and guidance on presence and body language for virtual or in-person interviews."
|
| 54 |
+
}
|
| 55 |
+
],
|
| 56 |
+
"loginPageSubtitle": "Sign in to land your internship and entry-level job today!",
|
| 57 |
+
"signupPageSubtitle": "Create an account to start the first day of your career!",
|
| 58 |
+
"userInfoRows": [
|
| 59 |
+
{
|
| 60 |
+
"infoBoxLabel": "Academic Standing",
|
| 61 |
+
"dropdownChoices": "Sophomore (2nd year), Junior (3rd year — internships), Senior (4th year — FT / entry-level), Graduate / recent grad"
|
| 62 |
+
}
|
| 63 |
+
],
|
| 64 |
+
"examplePromptCards": [
|
| 65 |
+
{
|
| 66 |
+
"title": "Career Layout",
|
| 67 |
+
"icon": "CalendarRange",
|
| 68 |
+
"prompts": [
|
| 69 |
+
"How many internships should I apply to each day?",
|
| 70 |
+
"How do I stand out in an interview?",
|
| 71 |
+
"Where can I find entry-level jobs that build real skills?"
|
| 72 |
+
]
|
| 73 |
+
},
|
| 74 |
+
{
|
| 75 |
+
"title": "Search & Apply",
|
| 76 |
+
"icon": "Search",
|
| 77 |
+
"prompts": [
|
| 78 |
+
"Build a Handshake and LinkedIn search plan for business analytics internships.",
|
| 79 |
+
"What companies should a Leeds undergrad target this semester?",
|
| 80 |
+
"How do I track applications without getting overwhelmed?"
|
| 81 |
+
]
|
| 82 |
+
},
|
| 83 |
+
{
|
| 84 |
+
"title": "Resume & Materials",
|
| 85 |
+
"icon": "FilePenLine",
|
| 86 |
+
"prompts": [
|
| 87 |
+
"Rewrite my resume bullets for an internship in marketing analytics.",
|
| 88 |
+
"What keywords should I add for ATS screening?",
|
| 89 |
+
"Review my experience section for a junior-year candidate."
|
| 90 |
+
]
|
| 91 |
+
},
|
| 92 |
+
{
|
| 93 |
+
"title": "Interview & Network",
|
| 94 |
+
"icon": "MessagesSquare",
|
| 95 |
+
"prompts": [
|
| 96 |
+
"Give me 10 behavioral interview questions for a finance internship.",
|
| 97 |
+
"How should I research a company the night before?",
|
| 98 |
+
"Draft a LinkedIn outreach message to an alumni in my field."
|
| 99 |
+
]
|
| 100 |
+
}
|
| 101 |
+
],
|
| 102 |
+
"chatInputPlaceholder": "Ask about application cadence, Handshake/LinkedIn search, resume edits, or interview prep...",
|
| 103 |
+
"sharedBasePrompt": "base_prompt: |\n **Formatting (Compact Markdown v1):**\n - Use GitHub-Flavored Markdown.\n - Output exactly three sections in this order:\n - `### Thought` -- one sentence that reflects on the user's situation from YOUR perspective.\n - `### What to do` -- exactly 3 bullets, one line each, with practical actions.\n - `### Next step` -- one imperative sentence telling the user what to do now.\n - Use `###` for headings and `-` for bullets.\n - Insert one blank line between sections.\n - Keep each section unique and non-repetitive.",
|
| 104 |
+
"includedPersonaIds": [
|
| 105 |
+
"internship_search_strategist",
|
| 106 |
+
"resume_optimizer",
|
| 107 |
+
"interview_coach",
|
| 108 |
+
"application_scheduler",
|
| 109 |
+
"career_path_mentor"
|
| 110 |
+
]
|
| 111 |
+
}
|
docker-compose.yml
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ---------------------------------------------------------------------------
|
| 2 |
+
# Local-dev wrapper around the single-container HuggingFace Spaces image.
|
| 3 |
+
#
|
| 4 |
+
# Override LAUNCHPAD_HOST_PORT via .env (or shell) if 7860 conflicts; the
|
| 5 |
+
# in-container port is fixed at 7860 to mirror HF Spaces.
|
| 6 |
+
# ---------------------------------------------------------------------------
|
| 7 |
+
|
| 8 |
+
services:
|
| 9 |
+
app:
|
| 10 |
+
build:
|
| 11 |
+
context: .
|
| 12 |
+
dockerfile: Dockerfile
|
| 13 |
+
image: launchpad-panel:dev
|
| 14 |
+
ports:
|
| 15 |
+
- "${LAUNCHPAD_HOST_PORT:-7860}:7860"
|
| 16 |
+
environment:
|
| 17 |
+
JWT_SECRET_KEY: ${JWT_SECRET_KEY:-CHANGEME-by-overriding-in-dot-env-file}
|
| 18 |
+
GEMINI_API_KEY: ${GEMINI_API_KEY:-}
|
| 19 |
+
OPENAI_API_KEY: ${OPENAI_API_KEY:-}
|
| 20 |
+
VLLM_API_KEY: ${VLLM_API_KEY:-}
|
| 21 |
+
VLLM_API_USERNAME: ${VLLM_API_USERNAME:-}
|
| 22 |
+
GEMINI_MODEL: ${GEMINI_MODEL:-gemini-2.5-flash}
|
| 23 |
+
CONFIG_PATH: ${CONFIG_PATH:-/home/user/app/launchpad_config.yaml}
|
| 24 |
+
# Same-origin in single-container; only relevant for cross-origin dev.
|
| 25 |
+
CORS_ORIGINS: ${CORS_ORIGINS:-*}
|
| 26 |
+
# SQLite + ChromaDB persistence both land here. The named volume below
|
| 27 |
+
# mirrors the HF Storage Bucket mount so user data survives rebuilds.
|
| 28 |
+
DATA_DIR: /data
|
| 29 |
+
volumes:
|
| 30 |
+
- launchpad_data:/data
|
| 31 |
+
|
| 32 |
+
volumes:
|
| 33 |
+
launchpad_data:
|
docs/nutrition_tools.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Nutrition calculation tools (MuscleGrowthAI)
|
| 2 |
+
|
| 3 |
+
Deterministic fitness/nutrition math for advisor responses. LLM personas receive pre-computed values instead of doing arithmetic themselves.
|
| 4 |
+
|
| 5 |
+
## Tools in use
|
| 6 |
+
|
| 7 |
+
| Source | Cost | Status | Purpose |
|
| 8 |
+
|--------|------|--------|---------|
|
| 9 |
+
| **Local Python formulas** | Free | **Active** | lbs↔kg, protein g/kg (1.6–2.2), meal splits |
|
| 10 |
+
| **USDA FoodData Central** | Free (API key) | Planned | Food nutrient lookup by name/barcode |
|
| 11 |
+
| **Open Food Facts** | Free (no key) | Planned | Product/barcode nutrient data |
|
| 12 |
+
|
| 13 |
+
## Core formulas (local)
|
| 14 |
+
|
| 15 |
+
- **Weight:** `kg = lbs × 0.45359237`
|
| 16 |
+
- **Protein (hypertrophy):** `protein_g_min = round(weight_kg × 1.6)`, `protein_g_mid = round(weight_kg × 1.9)`, `protein_g_max = round(weight_kg × 2.2)`
|
| 17 |
+
- **Meal split:** even distribution with remainder assigned to first meals
|
| 18 |
+
|
| 19 |
+
Example: **230 lb** → **104.33 kg** → **167–230 g protein/day**; midpoint at **1.9 g/kg** ≈ **~198 g/day** (4 meals ≈ 50/50/49/49 g at the midpoint).
|
| 20 |
+
|
| 21 |
+
## How calculations reach advisors
|
| 22 |
+
|
| 23 |
+
1. **Context injection (primary):** When chat history mentions protein and a body weight is present, `compute_protein_advisory_context()` runs in `_build_enhanced_context_for_persona()` and adds a `PRE-COMPUTED NUTRITION VALUES` JSON block to each persona system prompt.
|
| 24 |
+
2. **LLM tool calling (optional):** `calculate_protein_target` is registered in `app/tools/nutrition_calculator.py` and enabled via `muscle_growth_config.yaml` → `tools.nutrition_calculator.enabled: true`. The orchestrator may call it for direct tool responses when appropriate.
|
| 25 |
+
|
| 26 |
+
## Files
|
| 27 |
+
|
| 28 |
+
- `multi_llm_chatbot_backend/app/tools/nutrition_calculator.py` — formulas + tool definition
|
| 29 |
+
- `multi_llm_chatbot_backend/app/core/improved_orchestrator.py` — injects computed context into persona prompts
|
| 30 |
+
- `muscle_growth_config.yaml` — enables `nutrition_calculator` tool
|
| 31 |
+
|
| 32 |
+
## USDA / Open Food Facts (later)
|
| 33 |
+
|
| 34 |
+
- USDA: register at [https://fdc.nal.usda.gov/api-key-signup](https://fdc.nal.usda.gov/api-key-signup), set `USDA_API_KEY` env var.
|
| 35 |
+
- Open Food Facts: `https://world.openfoodfacts.org/api/v2/product/{barcode}` — no key required.
|
launchpad_config.yaml
ADDED
|
@@ -0,0 +1,202 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ============================================================================
|
| 2 |
+
# LaunchPadAI — Application Configuration
|
| 3 |
+
# Generated from advisor-panel-draft (Undergraduate Internship Search) by Kennedy Johnson
|
| 4 |
+
# Modeled on MuscleGrowthAI / CCAI multi-advisor panel pattern
|
| 5 |
+
# ============================================================================
|
| 6 |
+
|
| 7 |
+
app:
|
| 8 |
+
title: "LaunchPadAI"
|
| 9 |
+
subtitle: "AI mentor for internships to full-time jobs"
|
| 10 |
+
primary_color: "#0F766E"
|
| 11 |
+
logo_icon: "Rocket"
|
| 12 |
+
footer_text: "© 2026 Kennedy Johnson. All rights reserved."
|
| 13 |
+
user_avatars:
|
| 14 |
+
- { id: "rocket-teal", icon: "Rocket", color: "#0F766E", bg: "#CCFBF1" }
|
| 15 |
+
- { id: "user-blue", icon: "User", color: "#2563EB", bg: "#EFF6FF" }
|
| 16 |
+
- { id: "briefcase-slate", icon: "Briefcase", color: "#334155", bg: "#F1F5F9" }
|
| 17 |
+
- { id: "file-amber", icon: "FilePenLine", color: "#D97706", bg: "#FFFBEB" }
|
| 18 |
+
- { id: "messages-indigo", icon: "MessagesSquare", color: "#4F46E5", bg: "#EEF2FF" }
|
| 19 |
+
- { id: "calendar-rose", icon: "CalendarRange", color: "#E11D48", bg: "#FFF1F2" }
|
| 20 |
+
- { id: "search-cyan", icon: "Search", color: "#0891B2", bg: "#ECFEFF" }
|
| 21 |
+
- { id: "grad-emerald", icon: "GraduationCap", color: "#059669", bg: "#ECFDF5" }
|
| 22 |
+
|
| 23 |
+
homepage:
|
| 24 |
+
headline_prefix: "Undergraduate Internship Navigation"
|
| 25 |
+
headline_highlight: "AI Undergrad Job Finder"
|
| 26 |
+
description: >-
|
| 27 |
+
Receive a personalized plan to find and land an internship as a third-year
|
| 28 |
+
undergrad, or pivot to an entry-level full-time job as a fourth-year student
|
| 29 |
+
and beyond — search strategy, resume polish, interview prep, and application cadence.
|
| 30 |
+
features_title: "AI Career Toolkit"
|
| 31 |
+
features:
|
| 32 |
+
- title: "AI Internship Search"
|
| 33 |
+
description: "A personalized plan to match multiple internships a day, plus resume review and interview tips to build your career foundation."
|
| 34 |
+
icon: "Search"
|
| 35 |
+
- title: "Resume Optimizer"
|
| 36 |
+
description: "Feedback, edits, and tweaks so your resume stands out to ATS systems and recruiting teams."
|
| 37 |
+
icon: "FilePenLine"
|
| 38 |
+
- title: "AI Interview Helper"
|
| 39 |
+
description: "Near-perfect answers, company research tips, and guidance on presence for virtual or in-person interviews."
|
| 40 |
+
icon: "MessagesSquare"
|
| 41 |
+
|
| 42 |
+
login:
|
| 43 |
+
subtitle: "Sign in to land your internship and entry-level job today!"
|
| 44 |
+
signup_subtitle: "Create an account to start the first day of your career!"
|
| 45 |
+
knowledge_levels:
|
| 46 |
+
- { value: "", label: "Select your academic standing" }
|
| 47 |
+
- { value: "sophomore", label: "Sophomore (2nd year)" }
|
| 48 |
+
- { value: "junior", label: "Junior (3rd year — internships)" }
|
| 49 |
+
- { value: "senior", label: "Senior (4th year — FT / entry-level)" }
|
| 50 |
+
- { value: "grad", label: "Graduate / recent grad" }
|
| 51 |
+
timezones:
|
| 52 |
+
- { value: "", label: "Select timezone (optional)" }
|
| 53 |
+
- { value: "America/New_York", label: "Eastern (US)" }
|
| 54 |
+
- { value: "America/Chicago", label: "Central (US)" }
|
| 55 |
+
- { value: "America/Denver", label: "Mountain (US)" }
|
| 56 |
+
- { value: "America/Los_Angeles", label: "Pacific (US)" }
|
| 57 |
+
- { value: "America/Anchorage", label: "Alaska (US)" }
|
| 58 |
+
- { value: "Pacific/Honolulu", label: "Hawaii (US)" }
|
| 59 |
+
- { value: "UTC", label: "UTC" }
|
| 60 |
+
academic_stages:
|
| 61 |
+
- { value: "", label: "Select your academic standing" }
|
| 62 |
+
- { value: "sophomore", label: "Sophomore (2nd year)" }
|
| 63 |
+
- { value: "junior", label: "Junior (3rd year — internships)" }
|
| 64 |
+
- { value: "senior", label: "Senior (4th year — FT / entry-level)" }
|
| 65 |
+
- { value: "grad", label: "Graduate / recent grad" }
|
| 66 |
+
|
| 67 |
+
chat_page:
|
| 68 |
+
placeholder: "Ask about application cadence, Handshake/LinkedIn search, resume edits, or interview prep..."
|
| 69 |
+
examples:
|
| 70 |
+
- title: "Career Layout"
|
| 71 |
+
icon: "CalendarRange"
|
| 72 |
+
color: "#0F766E"
|
| 73 |
+
bg_color: "#CCFBF1"
|
| 74 |
+
suggestions:
|
| 75 |
+
- "How many internships should I apply to each day?"
|
| 76 |
+
- "How do I stand out in an interview?"
|
| 77 |
+
- "Where can I find entry-level jobs that build real skills?"
|
| 78 |
+
- title: "Search & Apply"
|
| 79 |
+
icon: "Search"
|
| 80 |
+
color: "#2563EB"
|
| 81 |
+
bg_color: "#EFF6FF"
|
| 82 |
+
suggestions:
|
| 83 |
+
- "Build a Handshake and LinkedIn search plan for business analytics internships."
|
| 84 |
+
- "What companies should a Leeds undergrad target this semester?"
|
| 85 |
+
- "How do I track applications without getting overwhelmed?"
|
| 86 |
+
- title: "Resume & Materials"
|
| 87 |
+
icon: "FilePenLine"
|
| 88 |
+
color: "#D97706"
|
| 89 |
+
bg_color: "#FFFBEB"
|
| 90 |
+
suggestions:
|
| 91 |
+
- "Rewrite my resume bullets for an internship in marketing analytics."
|
| 92 |
+
- "What keywords should I add for ATS screening?"
|
| 93 |
+
- "Review my experience section for a junior-year candidate."
|
| 94 |
+
- title: "Interview & Network"
|
| 95 |
+
icon: "MessagesSquare"
|
| 96 |
+
color: "#4F46E5"
|
| 97 |
+
bg_color: "#EEF2FF"
|
| 98 |
+
suggestions:
|
| 99 |
+
- "Give me 10 behavioral interview questions for a finance internship."
|
| 100 |
+
- "How should I research a company the night before?"
|
| 101 |
+
- "Draft a LinkedIn outreach message to an alumni in my field."
|
| 102 |
+
|
| 103 |
+
personas:
|
| 104 |
+
base_prompt: |
|
| 105 |
+
**Formatting (Compact Markdown v1):**
|
| 106 |
+
- Use GitHub-Flavored Markdown.
|
| 107 |
+
- Output exactly three sections in this order:
|
| 108 |
+
- `### Thought` — one sentence that reflects on the user's situation from YOUR perspective.
|
| 109 |
+
- `### What to do` — exactly 3 bullets, one line each, with practical actions.
|
| 110 |
+
- `### Next step` — one imperative sentence telling the user what to do now.
|
| 111 |
+
- Use `###` for headings and `-` for bullets.
|
| 112 |
+
- Insert one blank line between sections.
|
| 113 |
+
- Keep each section unique and non-repetitive.
|
| 114 |
+
- Be encouraging but honest about competitive markets; never invent job openings or fake company contacts.
|
| 115 |
+
- Prefer Handshake, LinkedIn, university career services, and official company career pages as primary channels.
|
| 116 |
+
personas_dir: "personas/career_advisors"
|
| 117 |
+
|
| 118 |
+
orchestrator:
|
| 119 |
+
min_words_without_keywords: 6
|
| 120 |
+
conversation_history_token_threshold: 4000
|
| 121 |
+
specific_keywords:
|
| 122 |
+
- "internship"
|
| 123 |
+
- "intern"
|
| 124 |
+
- "job"
|
| 125 |
+
- "career"
|
| 126 |
+
- "resume"
|
| 127 |
+
- "cv"
|
| 128 |
+
- "cover letter"
|
| 129 |
+
- "handshake"
|
| 130 |
+
- "linkedin"
|
| 131 |
+
- "interview"
|
| 132 |
+
- "behavioral"
|
| 133 |
+
- "recruiter"
|
| 134 |
+
- "ats"
|
| 135 |
+
- "apply"
|
| 136 |
+
- "application"
|
| 137 |
+
- "networking"
|
| 138 |
+
- "alumni"
|
| 139 |
+
- "offer"
|
| 140 |
+
- "full-time"
|
| 141 |
+
- "entry-level"
|
| 142 |
+
- "sophomore"
|
| 143 |
+
- "junior"
|
| 144 |
+
- "senior"
|
| 145 |
+
- "leeds"
|
| 146 |
+
- "undergrad"
|
| 147 |
+
- "deadline"
|
| 148 |
+
- "portfolio"
|
| 149 |
+
clarification_questions:
|
| 150 |
+
- "What is your academic standing (sophomore, junior, senior) and target start term?"
|
| 151 |
+
- "Which majors, industries, or role types are you targeting?"
|
| 152 |
+
- "Are you optimizing for internships, full-time entry-level, or both?"
|
| 153 |
+
- "How many hours per week can you realistically spend on applications?"
|
| 154 |
+
clarification_suggestions:
|
| 155 |
+
- "Ask for a weekly Handshake and LinkedIn search plan"
|
| 156 |
+
- "Upload your resume for ATS-focused edits"
|
| 157 |
+
- "Request behavioral interview practice for your target role"
|
| 158 |
+
- "Get a daily application cadence with tracking tips"
|
| 159 |
+
|
| 160 |
+
auth:
|
| 161 |
+
algorithm: "HS256"
|
| 162 |
+
token_expiry_minutes: 43200
|
| 163 |
+
|
| 164 |
+
mongodb:
|
| 165 |
+
database_name: "launchpad_advisor"
|
| 166 |
+
|
| 167 |
+
llm:
|
| 168 |
+
provider: "openai"
|
| 169 |
+
gemini:
|
| 170 |
+
model: "gemini-2.5-flash"
|
| 171 |
+
ollama:
|
| 172 |
+
model: "llama3.2:1b"
|
| 173 |
+
vllm:
|
| 174 |
+
api_url: ""
|
| 175 |
+
api_key: ""
|
| 176 |
+
api_username: "guest"
|
| 177 |
+
model_id: ""
|
| 178 |
+
neon_persona_orchestrator: "vanilla"
|
| 179 |
+
neon_persona_advisors: "vanilla"
|
| 180 |
+
openai:
|
| 181 |
+
# Key comes from the OPENAI_API_KEY env var / HF Space secret (never committed).
|
| 182 |
+
api_key: ""
|
| 183 |
+
model: "gpt-5.4-mini"
|
| 184 |
+
orchestrator_reasoning_effort: "low"
|
| 185 |
+
persona_reasoning_effort: "none"
|
| 186 |
+
resilient:
|
| 187 |
+
race_timeout_seconds: 60
|
| 188 |
+
|
| 189 |
+
rag:
|
| 190 |
+
embedding_model: "all-MiniLM-L6-v2"
|
| 191 |
+
chroma_collection: "launchpad_documents"
|
| 192 |
+
|
| 193 |
+
tools:
|
| 194 |
+
current_datetime:
|
| 195 |
+
enabled: true
|
| 196 |
+
default_timezone: "America/Denver"
|
| 197 |
+
nutrition_calculator:
|
| 198 |
+
enabled: false
|
| 199 |
+
|
| 200 |
+
voice:
|
| 201 |
+
stt_endpoint: "https://whisper.neonaiservices.com"
|
| 202 |
+
tts_endpoint: "https://coqui.neonaiservices.com"
|
multi_llm_chatbot_backend/.env.example
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Copy this file to `.env` (same directory) for a native, non-Docker local run.
|
| 2 |
+
# The backend auto-loads it on startup.
|
| 3 |
+
|
| 4 |
+
# Path to the app config, relative to this backend directory.
|
| 5 |
+
CONFIG_PATH=../launchpad_config.yaml
|
| 6 |
+
|
| 7 |
+
# Where the SQLite database + uploaded docs live. Any writable folder works;
|
| 8 |
+
# on a native run this replaces the container's /data mount.
|
| 9 |
+
DATA_DIR=./local_data
|
| 10 |
+
|
| 11 |
+
# Signs auth tokens — set to a long random string.
|
| 12 |
+
JWT_SECRET_KEY=change-me-to-a-long-random-string
|
| 13 |
+
|
| 14 |
+
# Default provider is OpenAI GPT-5.4-mini (see launchpad_config.yaml -> llm.provider).
|
| 15 |
+
# Set your OpenAI API key here (get one from Clary, or use your own).
|
| 16 |
+
OPENAI_API_KEY=
|
| 17 |
+
|
| 18 |
+
# Optional alternatives:
|
| 19 |
+
# GEMINI_API_KEY= # only if you switch llm.provider back to "gemini"
|
| 20 |
+
VLLM_API_KEY=
|
| 21 |
+
|
| 22 |
+
# Frontend origins allowed to call this backend during native dev
|
| 23 |
+
# (CRA dev server defaults to port 3000).
|
| 24 |
+
CORS_ORIGINS=http://localhost:3000,http://127.0.0.1:3000
|
multi_llm_chatbot_backend/README.md
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Multi-LLM Chatbot Backend
|
| 2 |
+
|
| 3 |
+
A modular, extensible FastAPI backend for building an AI-powered research advisor chatbot that supports:
|
| 4 |
+
- Multiple AI personas with configurable tone and behavior
|
| 5 |
+
- Dynamic switching between Gemini (cloud) and Ollama (local) LLMs
|
| 6 |
+
- Chat session persistence and context memory
|
| 7 |
+
- Document upload, chunking, and retrieval using RAG
|
| 8 |
+
- Rich export features (PDF, DOCX, TXT)
|
| 9 |
+
- User authentication and JWT-based access control
|
| 10 |
+
|
| 11 |
+
---
|
| 12 |
+
|
| 13 |
+
## Backend Architecture
|
| 14 |
+
|
| 15 |
+
```text
|
| 16 |
+
User Input
|
| 17 |
+
↓
|
| 18 |
+
/chat-stream → Orchestrator
|
| 19 |
+
↓ ↙ ↘
|
| 20 |
+
SessionManager ContextManager RAGManager
|
| 21 |
+
↓ ↓ ↓
|
| 22 |
+
MongoDB Token Trimming ChromaDB
|
| 23 |
+
↓ ↓ ↓
|
| 24 |
+
Persisted Chat & Doc Context → LLM (Gemini/Ollama)
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
---
|
| 28 |
+
|
| 29 |
+
## Features
|
| 30 |
+
|
| 31 |
+
- Persona-based multi-agent conversation (`Theorist`, `Pragmatist`, etc.)
|
| 32 |
+
- Provider switching (Gemini ↔ Ollama)
|
| 33 |
+
- Context-aware response routing + top-K advisor selection
|
| 34 |
+
- PDF, DOCX, and TXT file upload and semantic retrieval
|
| 35 |
+
- Developer tools: debug personas, test RAG, export sessions
|
| 36 |
+
- Secure authentication and session scoping
|
| 37 |
+
|
| 38 |
+
---
|
| 39 |
+
|
| 40 |
+
## Setup Instructions
|
| 41 |
+
|
| 42 |
+
### 1. Clone and Configure Environment
|
| 43 |
+
|
| 44 |
+
```bash
|
| 45 |
+
git clone https://github.com/yourorg/multi-llm-chatbot-backend
|
| 46 |
+
cd multi-llm-chatbot-backend
|
| 47 |
+
cp .env.example .env # already provided
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
### 2. Python Environment Setup
|
| 51 |
+
|
| 52 |
+
```bash
|
| 53 |
+
python -m venv venv
|
| 54 |
+
source venv/bin/activate # or venv\Scripts\activate on Windows
|
| 55 |
+
|
| 56 |
+
pip install -r requirements.txt
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
### 3. Run the Server
|
| 60 |
+
|
| 61 |
+
```bash
|
| 62 |
+
uvicorn app.main:app --reload
|
| 63 |
+
```
|
| 64 |
+
|
| 65 |
+
> Server will be available at: `http://localhost:8000`
|
| 66 |
+
|
| 67 |
+
---
|
| 68 |
+
|
| 69 |
+
## FastAPI Routing & Modules
|
| 70 |
+
|
| 71 |
+
| Folder | Description |
|
| 72 |
+
|--------|-------------|
|
| 73 |
+
| [`app/api`](./api_README.md) | REST API endpoints for chat, auth, RAG, exports |
|
| 74 |
+
| [`app/core`](./core_README.md) | Main orchestration, context windows, database logic |
|
| 75 |
+
| [`app/llm`](./llm_README.md) | Gemini + Ollama LLM wrappers |
|
| 76 |
+
| [`app/models`](./models_README.md) | Persona and user schemas |
|
| 77 |
+
| [`app/utils`](./utils_README.md) | File parsing, summaries, exports, vector helpers |
|
| 78 |
+
|
| 79 |
+
---
|
| 80 |
+
|
| 81 |
+
## Key Files
|
| 82 |
+
|
| 83 |
+
### `main.py`
|
| 84 |
+
|
| 85 |
+
- Loads env vars, sets up FastAPI instance with CORS and routers
|
| 86 |
+
- Calls `connect_to_mongo()` on startup and `close_mongo_connection()` on shutdown
|
| 87 |
+
- Imports and registers all routers (`auth`, `chat_sessions`, etc.)
|
| 88 |
+
|
| 89 |
+
### `.env` (Sample Vars)
|
| 90 |
+
|
| 91 |
+
```ini
|
| 92 |
+
# MongoDB
|
| 93 |
+
MONGODB_CONNECTION_STRING=mongodb://localhost:27017
|
| 94 |
+
MONGODB_DATABASE_NAME=neon_ai_backend
|
| 95 |
+
|
| 96 |
+
# Gemini API Key and model
|
| 97 |
+
GEMINI_API_KEY=... # Replace with real key
|
| 98 |
+
GEMINI_MODEL=gemini-2.0-flash
|
| 99 |
+
|
| 100 |
+
# Default provider
|
| 101 |
+
DEFAULT_PROVIDER=gemini
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
### `requirements.txt`
|
| 105 |
+
|
| 106 |
+
Includes:
|
| 107 |
+
- **FastAPI**, **Uvicorn**: API framework and server
|
| 108 |
+
- **httpx**: Async LLM request handler
|
| 109 |
+
- **motor**, **pymongo**: MongoDB async access
|
| 110 |
+
- **chromadb**, **sentence-transformers**: Vector database + embeddings
|
| 111 |
+
- **PyPDF2**, **docx2txt**, **reportlab**: Document parsing and PDF generation
|
| 112 |
+
- **passlib**, **python-jose**: Auth and security
|
| 113 |
+
|
| 114 |
+
---
|
| 115 |
+
|
| 116 |
+
## Persona Design & Context Handling
|
| 117 |
+
|
| 118 |
+
- Personas defined in `app/models/default_personas.py`
|
| 119 |
+
- Rich system prompts, styles, and epistemologies
|
| 120 |
+
- Responses routed through `ImprovedChatOrchestrator`
|
| 121 |
+
- Context trimmed and weighted via `ContextManager`
|
| 122 |
+
|
| 123 |
+
---
|
| 124 |
+
|
| 125 |
+
## Switching LLM Providers
|
| 126 |
+
|
| 127 |
+
You can hot-swap models via API:
|
| 128 |
+
|
| 129 |
+
```http
|
| 130 |
+
POST /switch-provider
|
| 131 |
+
{ "provider": "gemini" } | { "provider": "ollama" }
|
| 132 |
+
```
|
| 133 |
+
|
| 134 |
+
> Also supported: `/switch-model`, `/current-model`, `/current-provider`
|
| 135 |
+
|
| 136 |
+
---
|
| 137 |
+
|
| 138 |
+
## Document Upload + RAG
|
| 139 |
+
|
| 140 |
+
- Upload PDFs, DOCX, or TXT to sessions
|
| 141 |
+
- Text is extracted → chunked → embedded → stored in ChromaDB
|
| 142 |
+
- Queried during conversation by persona-aware `EnhancedRAGManager`
|
| 143 |
+
|
| 144 |
+
---
|
| 145 |
+
|
| 146 |
+
## Export Options
|
| 147 |
+
|
| 148 |
+
| Format | Export Endpoint |
|
| 149 |
+
|--------|------------------|
|
| 150 |
+
| PDF | `/export-chat?format=pdf` |
|
| 151 |
+
| DOCX | `/export-chat?format=docx` |
|
| 152 |
+
| TXT | `/export-chat?format=txt` |
|
| 153 |
+
| Summary | `/chat-summary?format=pdf` |
|
| 154 |
+
|
| 155 |
+
---
|
| 156 |
+
|
| 157 |
+
## Developer & Debug Endpoints
|
| 158 |
+
|
| 159 |
+
| Endpoint | Purpose |
|
| 160 |
+
|----------|---------|
|
| 161 |
+
| `/debug/personas` | See registered advisors and prompts |
|
| 162 |
+
| `/debug/ranked-personas` | View top-K advisors for context |
|
| 163 |
+
| `/debug/rag-status` | Run sample search to test document index |
|
| 164 |
+
|
| 165 |
+
---
|
| 166 |
+
|
| 167 |
+
## Status & Roadmap
|
| 168 |
+
|
| 169 |
+
- [x] Multi-LLM backend ready (Gemini + Ollama)
|
| 170 |
+
- [x] Document RAG + export system
|
| 171 |
+
- [x] Session-aware persona routing
|
| 172 |
+
- [x] JWT Auth + MongoDB user handling
|
| 173 |
+
- [ ] UI enhancements and persona memory
|
| 174 |
+
- [ ] Persona fine-tuning support (future)
|
| 175 |
+
|
| 176 |
+
---
|
| 177 |
+
|
| 178 |
+
For questions, contributions, or deployment help — feel free to reach out!
|
multi_llm_chatbot_backend/app/__init__.py
ADDED
|
File without changes
|
multi_llm_chatbot_backend/app/api/README.md
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# `app/api` – REST API Layer for Multi-LLM Chatbot
|
| 2 |
+
|
| 3 |
+
This module defines the complete FastAPI-based HTTP interface for all backend features, including chat, session management, RAG operations, provider switching, and document interaction.
|
| 4 |
+
|
| 5 |
+
Each file in this directory defines route groups (`APIRouter`) to modularize functionality.
|
| 6 |
+
|
| 7 |
+
---
|
| 8 |
+
|
| 9 |
+
## API Directory Layout
|
| 10 |
+
|
| 11 |
+
| File | Purpose |
|
| 12 |
+
|------|---------|
|
| 13 |
+
| `auth.py` | Handles user authentication (login, signup, token validation) |
|
| 14 |
+
| `chat.py` | Core routes for LLM-backed chat, reply-to-advisor, and multi-turn flow |
|
| 15 |
+
| `chat_sessions.py` | Stores user conversations and provides access to saved history |
|
| 16 |
+
| `debug.py` | Developer tools: debug personas, RAG tests, ranking advisor responses |
|
| 17 |
+
| `documents.py` | Upload, parse, index, and query documents via RAG |
|
| 18 |
+
| `provider.py` | Switch between Gemini and Ollama providers |
|
| 19 |
+
| `root.py` | Root `/` endpoint for heartbeat and versioning |
|
| 20 |
+
| `sessions.py` | Tracks and resets session-specific in-memory context |
|
| 21 |
+
| `utils.py` | Helpers used by multiple routers (e.g. session ID management) |
|
| 22 |
+
|
| 23 |
+
---
|
| 24 |
+
|
| 25 |
+
## `auth.py` – User Authentication API
|
| 26 |
+
|
| 27 |
+
| Endpoint | Method | Description |
|
| 28 |
+
|----------|--------|-------------|
|
| 29 |
+
| `/signup` | `POST` | Register a new user |
|
| 30 |
+
| `/login` | `POST` | Authenticate user and return access token |
|
| 31 |
+
| `/me` | `GET` | Return current logged-in user |
|
| 32 |
+
| `/healthcheck` | `GET` | Ping endpoint to check login status |
|
| 33 |
+
|
| 34 |
+
Uses JWT-based Bearer token auth via FastAPI dependencies.
|
| 35 |
+
|
| 36 |
+
---
|
| 37 |
+
|
| 38 |
+
## `chat.py` – Chat Interaction
|
| 39 |
+
|
| 40 |
+
| Endpoint | Method | Description |
|
| 41 |
+
|----------|--------|-------------|
|
| 42 |
+
| `/chat-stream` | `POST` | Stream advisor responses as newline-delimited JSON |
|
| 43 |
+
| `/reply-to-advisor` | `POST` | Ask a question to a specific advisor/persona |
|
| 44 |
+
|
| 45 |
+
These routes handle:
|
| 46 |
+
- Message routing via `ImprovedChatOrchestrator`
|
| 47 |
+
- Persona-wise response generation
|
| 48 |
+
- Embedding document-aware context
|
| 49 |
+
- Returning consistent message structure
|
| 50 |
+
|
| 51 |
+
---
|
| 52 |
+
|
| 53 |
+
## `chat_sessions.py` – Persistent Storage of Conversations
|
| 54 |
+
|
| 55 |
+
| Endpoint | Method | Description |
|
| 56 |
+
|----------|--------|-------------|
|
| 57 |
+
| `/chat-sessions` | `GET` | List all saved chat sessions |
|
| 58 |
+
| `/chat-sessions/{id}` | `GET` | Retrieve specific chat session |
|
| 59 |
+
| `/chat-sessions/{id}` | `DELETE` | Soft-delete a chat session |
|
| 60 |
+
| `/chat-sessions/save` | `POST` | Save in-memory session to MongoDB |
|
| 61 |
+
|
| 62 |
+
Saves message history, metadata, and uploaded files.
|
| 63 |
+
|
| 64 |
+
---
|
| 65 |
+
|
| 66 |
+
## `debug.py` – Developer Tools
|
| 67 |
+
|
| 68 |
+
| Endpoint | Method | Description |
|
| 69 |
+
|----------|--------|-------------|
|
| 70 |
+
| `/debug/personas` | `GET` | List current personas, prompts, keywords |
|
| 71 |
+
| `/debug/ranked-personas` | `GET` | Return top advisors for current session |
|
| 72 |
+
| `/debug/rag-status` | `GET` | Run sample RAG query + return health info |
|
| 73 |
+
|
| 74 |
+
Provides insight into:
|
| 75 |
+
- Persona prompt preview
|
| 76 |
+
- RAG test queries and indexed documents
|
| 77 |
+
- Session size + truncation status
|
| 78 |
+
|
| 79 |
+
---
|
| 80 |
+
|
| 81 |
+
## `documents.py` – Document Upload and RAG
|
| 82 |
+
|
| 83 |
+
| Endpoint | Method | Description |
|
| 84 |
+
|----------|--------|-------------|
|
| 85 |
+
| `/upload-document` | `POST` | Upload and parse a document for semantic search |
|
| 86 |
+
| `/search-documents` | `POST` | RAG search using text query and persona context |
|
| 87 |
+
| `/document-stats` | `GET` | Overview of documents uploaded to session |
|
| 88 |
+
| `/uploaded-files` | `GET` | Return list of uploaded file names |
|
| 89 |
+
| `/document-insights/{filename}` | `GET` | Get detailed metadata for a document |
|
| 90 |
+
| `/export-chat` | `GET` | Export current or stored chat session (PDF, TXT, DOCX) |
|
| 91 |
+
| `/chat-summary` | `GET` | Export summary generated by LLM (multi-format) |
|
| 92 |
+
|
| 93 |
+
Supports file parsing (`PDF`, `DOCX`, `TXT`), chunking, embedding, and export.
|
| 94 |
+
|
| 95 |
+
---
|
| 96 |
+
|
| 97 |
+
## `provider.py` – LLM Provider Control
|
| 98 |
+
|
| 99 |
+
| Endpoint | Method | Description |
|
| 100 |
+
|----------|--------|-------------|
|
| 101 |
+
| `/current-provider` | `GET` | Return currently active provider and model |
|
| 102 |
+
| `/switch-provider` | `POST` | Dynamically switch between `gemini` and `ollama` |
|
| 103 |
+
| `/current-model` | `GET` | Get currently loaded model name |
|
| 104 |
+
| `/switch-model` | `POST` | Alias for switching based on model name |
|
| 105 |
+
|
| 106 |
+
Changes are propagated by:
|
| 107 |
+
- Creating new LLM client
|
| 108 |
+
- Re-registering all personas
|
| 109 |
+
|
| 110 |
+
---
|
| 111 |
+
|
| 112 |
+
## `sessions.py` – In-Memory Session Management
|
| 113 |
+
|
| 114 |
+
| Endpoint | Method | Description |
|
| 115 |
+
|----------|--------|-------------|
|
| 116 |
+
| `/context` | `GET` | Return current session context (messages, documents, stats) |
|
| 117 |
+
| `/reset-session` | `POST` | Reset in-memory session or specific chat context |
|
| 118 |
+
| `/session-stats` | `GET` | Return stats like message count, file size, timestamps |
|
| 119 |
+
| `/active-sessions` | `GET` | Return list of all active in-memory sessions |
|
| 120 |
+
| `/cleanup-sessions` | `POST` | Manually trigger expired session cleanup |
|
| 121 |
+
|
| 122 |
+
Supports ephemeral sessions and reusable chat contexts (e.g. for documents).
|
| 123 |
+
|
| 124 |
+
---
|
| 125 |
+
|
| 126 |
+
## `utils.py` – Route-Level Utilities
|
| 127 |
+
|
| 128 |
+
Defines shared helper:
|
| 129 |
+
|
| 130 |
+
- `get_or_create_session_for_request(request)`
|
| 131 |
+
- `get_or_create_session_for_request_async(request)`
|
| 132 |
+
|
| 133 |
+
These parse session cookies or generate new session IDs, crucial for maintaining separation across:
|
| 134 |
+
- In-memory ephemeral sessions
|
| 135 |
+
- Document-linked long-term sessions
|
| 136 |
+
|
| 137 |
+
---
|
| 138 |
+
|
| 139 |
+
## `root.py` – API Healthcheck
|
| 140 |
+
|
| 141 |
+
| Endpoint | Method | Description |
|
| 142 |
+
|----------|--------|-------------|
|
| 143 |
+
| `/` | `GET` | Return version + feature list |
|
| 144 |
+
|
| 145 |
+
Simple heartbeat endpoint used for readiness probes and sanity checks.
|
| 146 |
+
|
| 147 |
+
---
|
| 148 |
+
|
| 149 |
+
## Auth Flow Integration
|
| 150 |
+
|
| 151 |
+
Most routes use:
|
| 152 |
+
|
| 153 |
+
```python
|
| 154 |
+
Depends(get_current_active_user)
|
| 155 |
+
```
|
| 156 |
+
|
| 157 |
+
This ensures only logged-in users can:
|
| 158 |
+
- Upload and retrieve files
|
| 159 |
+
- Export summaries
|
| 160 |
+
- Save or delete chat sessions
|
| 161 |
+
|
| 162 |
+
JWT tokens are passed via the `Authorization: Bearer ...` header.
|
| 163 |
+
|
| 164 |
+
---
|
| 165 |
+
|
| 166 |
+
## High-Level Flow
|
| 167 |
+
|
| 168 |
+
```text
|
| 169 |
+
Frontend → /chat-stream → orchestrator → personas → RAG + LLM → response[]
|
| 170 |
+
↘ /upload-document → extractor → RAG chunks → indexed
|
| 171 |
+
↘ /context or /reset-session → session_manager
|
| 172 |
+
↘ /export-chat or /chat-summary → utils + formatter
|
| 173 |
+
```
|
| 174 |
+
|
| 175 |
+
---
|
multi_llm_chatbot_backend/app/api/old_routes.py
ADDED
|
@@ -0,0 +1,1001 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from fastapi import APIRouter, Body, HTTPException, Header, UploadFile, File, Request
|
| 3 |
+
from fastapi import Query
|
| 4 |
+
from typing import Optional, List
|
| 5 |
+
import httpx
|
| 6 |
+
from app.llm.llm_client import LLMClient
|
| 7 |
+
from app.llm.improved_gemini_client import ImprovedGeminiClient
|
| 8 |
+
from app.llm.improved_ollama_client import ImprovedOllamaClient
|
| 9 |
+
from app.models.persona import Persona
|
| 10 |
+
from app.core.improved_orchestrator import ImprovedChatOrchestrator
|
| 11 |
+
from app.core.session_manager import get_session_manager
|
| 12 |
+
from app.core.rag_manager import get_rag_manager
|
| 13 |
+
from app.models.default_personas import get_default_personas
|
| 14 |
+
from app.utils.document_extractor import extract_text_from_file
|
| 15 |
+
from app.utils.file_limits import is_within_upload_limit
|
| 16 |
+
from pydantic import BaseModel
|
| 17 |
+
|
| 18 |
+
from fastapi.responses import StreamingResponse
|
| 19 |
+
from fastapi import Query
|
| 20 |
+
from app.utils.file_export import export_chat_as_file
|
| 21 |
+
|
| 22 |
+
from app.utils.chat_summary import generate_summary_from_messages, parse_summary_to_blocks
|
| 23 |
+
from app.utils.file_export import prepare_export_response, generate_pdf_file_from_blocks
|
| 24 |
+
from app.version import __version__
|
| 25 |
+
|
| 26 |
+
import hashlib
|
| 27 |
+
import logging
|
| 28 |
+
|
| 29 |
+
logger = logging.getLogger(__name__)
|
| 30 |
+
|
| 31 |
+
router = APIRouter()
|
| 32 |
+
|
| 33 |
+
# Provider management (same as before)
|
| 34 |
+
current_provider = "gemini"
|
| 35 |
+
available_providers = ["ollama", "gemini"]
|
| 36 |
+
|
| 37 |
+
def create_llm_client(provider: str = None) -> LLMClient:
|
| 38 |
+
"""Create LLM client based on provider"""
|
| 39 |
+
if provider is None:
|
| 40 |
+
provider = current_provider
|
| 41 |
+
|
| 42 |
+
if provider == "gemini":
|
| 43 |
+
try:
|
| 44 |
+
return ImprovedGeminiClient(model_name=os.getenv("GEMINI_MODEL"))
|
| 45 |
+
except ValueError as e:
|
| 46 |
+
logger.warning(f"Gemini API key not found, falling back to Ollama: {e}")
|
| 47 |
+
return ImprovedOllamaClient(model_name="llama3.2:1b")
|
| 48 |
+
elif provider == "ollama":
|
| 49 |
+
return ImprovedOllamaClient(model_name="llama3.2:1b")
|
| 50 |
+
else:
|
| 51 |
+
raise ValueError(f"Unknown provider: {provider}")
|
| 52 |
+
|
| 53 |
+
# Initialize with default provider
|
| 54 |
+
llm = create_llm_client()
|
| 55 |
+
chat_orchestrator = ImprovedChatOrchestrator()
|
| 56 |
+
session_manager = get_session_manager()
|
| 57 |
+
|
| 58 |
+
# Initialize personas
|
| 59 |
+
DEFAULT_PERSONAS = get_default_personas(llm)
|
| 60 |
+
for persona in DEFAULT_PERSONAS:
|
| 61 |
+
chat_orchestrator.register_persona(persona)
|
| 62 |
+
|
| 63 |
+
# Keep all the same data models as before
|
| 64 |
+
class UserInput(BaseModel):
|
| 65 |
+
user_input: str
|
| 66 |
+
|
| 67 |
+
class PersonaInput(BaseModel):
|
| 68 |
+
id: str
|
| 69 |
+
name: str
|
| 70 |
+
system_prompt: str
|
| 71 |
+
|
| 72 |
+
class ChatMessage(BaseModel):
|
| 73 |
+
user_input: str
|
| 74 |
+
session_id: Optional[str] = None
|
| 75 |
+
response_length: Optional[str] = "medium"
|
| 76 |
+
|
| 77 |
+
class ReplyToAdvisor(BaseModel):
|
| 78 |
+
user_input: str
|
| 79 |
+
advisor_id: str
|
| 80 |
+
original_message_id: Optional[str] = None
|
| 81 |
+
|
| 82 |
+
class ProviderSwitch(BaseModel):
|
| 83 |
+
provider: str
|
| 84 |
+
|
| 85 |
+
# ==============================================================
|
| 86 |
+
# SESSION MANAGEMENT COMPATIBILITY LAYER
|
| 87 |
+
# ==============================================================
|
| 88 |
+
|
| 89 |
+
def get_or_create_session_for_request(request: Request,
|
| 90 |
+
session_id_override: Optional[str] = None) -> str:
|
| 91 |
+
"""
|
| 92 |
+
Get or create session for request using multiple strategies:
|
| 93 |
+
1. Use provided session_id if given
|
| 94 |
+
2. Use X-Session-ID header if present
|
| 95 |
+
3. Use client IP as fallback for backward compatibility
|
| 96 |
+
4. Create new session if nothing available
|
| 97 |
+
|
| 98 |
+
This allows the old stateless API to work with session management
|
| 99 |
+
"""
|
| 100 |
+
# Strategy 1: Explicit session ID (for new clients)
|
| 101 |
+
if session_id_override:
|
| 102 |
+
return session_id_override
|
| 103 |
+
|
| 104 |
+
# Strategy 2: Check for session header (optional for frontend)
|
| 105 |
+
session_header = request.headers.get("X-Session-ID")
|
| 106 |
+
if session_header:
|
| 107 |
+
return session_header
|
| 108 |
+
|
| 109 |
+
# Strategy 3: Use client IP for backward compatibility
|
| 110 |
+
# This gives each client IP their own persistent session
|
| 111 |
+
client_ip = request.client.host if request.client else "unknown"
|
| 112 |
+
ip_session_id = f"ip_{client_ip}"
|
| 113 |
+
|
| 114 |
+
# Get or create session for this IP
|
| 115 |
+
session = session_manager.get_session(ip_session_id)
|
| 116 |
+
return session.session_id
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
# Helper functions (same as before)
|
| 121 |
+
def _is_valid_response(response: str, persona_id: str) -> bool:
|
| 122 |
+
"""Validate response quality"""
|
| 123 |
+
if len(response) < 2 or len(response) > 5000:
|
| 124 |
+
return False
|
| 125 |
+
|
| 126 |
+
confusion_indicators = [
|
| 127 |
+
f"Thank you, Dr. {persona_id.title()}",
|
| 128 |
+
"Assistant:",
|
| 129 |
+
f"Dr. {persona_id.title()}",
|
| 130 |
+
"Assistant:",
|
| 131 |
+
f"Dr. {persona_id.title()} Advisor:",
|
| 132 |
+
"excellent discussion, Assistant"
|
| 133 |
+
]
|
| 134 |
+
|
| 135 |
+
return not any(indicator in response for indicator in confusion_indicators)
|
| 136 |
+
|
| 137 |
+
def _get_persona_fallback(persona_id: str) -> str:
|
| 138 |
+
"""Get persona-specific fallback responses"""
|
| 139 |
+
fallbacks = {
|
| 140 |
+
"methodologist": "Focus on ensuring your methodology aligns with your research question. What specific method are you considering?",
|
| 141 |
+
"theorist": "Consider the theoretical framework underlying your approach. What assumptions guide your thinking?",
|
| 142 |
+
"pragmatist": "Let's break this down into actionable steps. What's the most important thing you need to decide today?"
|
| 143 |
+
}
|
| 144 |
+
return fallbacks.get(persona_id, "I'd be happy to help. Could you provide more details?")
|
| 145 |
+
|
| 146 |
+
# Provider management endpoints (EXACTLY THE SAME)
|
| 147 |
+
@router.get("/current-provider")
|
| 148 |
+
async def get_current_provider():
|
| 149 |
+
return {
|
| 150 |
+
"current_provider": current_provider,
|
| 151 |
+
"available_providers": available_providers,
|
| 152 |
+
"model_info": {
|
| 153 |
+
"name": llm.model_name if hasattr(llm, 'model_name') else "gemini-2.0-flash",
|
| 154 |
+
"provider": current_provider
|
| 155 |
+
}
|
| 156 |
+
}
|
| 157 |
+
|
| 158 |
+
@router.post("/switch-provider")
|
| 159 |
+
async def switch_provider(provider_data: ProviderSwitch):
|
| 160 |
+
global current_provider, llm
|
| 161 |
+
|
| 162 |
+
if provider_data.provider not in available_providers:
|
| 163 |
+
raise HTTPException(
|
| 164 |
+
status_code=400,
|
| 165 |
+
detail=f"Unknown provider: {provider_data.provider}. Available: {available_providers}"
|
| 166 |
+
)
|
| 167 |
+
|
| 168 |
+
try:
|
| 169 |
+
current_provider = provider_data.provider
|
| 170 |
+
new_llm = create_llm_client(current_provider)
|
| 171 |
+
llm = new_llm
|
| 172 |
+
|
| 173 |
+
new_personas = get_default_personas(new_llm)
|
| 174 |
+
chat_orchestrator.personas.clear()
|
| 175 |
+
for persona in new_personas:
|
| 176 |
+
chat_orchestrator.register_persona(persona)
|
| 177 |
+
|
| 178 |
+
return {
|
| 179 |
+
"message": f"Successfully switched to {current_provider}",
|
| 180 |
+
"current_provider": current_provider,
|
| 181 |
+
"model_info": {
|
| 182 |
+
"name": new_llm.model_name if hasattr(new_llm, 'model_name') else "gemini-2.0-flash",
|
| 183 |
+
"provider": current_provider
|
| 184 |
+
}
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
except Exception as e:
|
| 188 |
+
raise HTTPException(
|
| 189 |
+
status_code=500,
|
| 190 |
+
detail=f"Failed to switch to {provider_data.provider}: {str(e)}"
|
| 191 |
+
)
|
| 192 |
+
|
| 193 |
+
# Main chat endpoint
|
| 194 |
+
@router.post("/chat-sequential")
|
| 195 |
+
async def chat_sequential_enhanced(message: ChatMessage, request: Request):
|
| 196 |
+
"""
|
| 197 |
+
Enhanced sequential chat with intelligent persona ordering.
|
| 198 |
+
Returns responses in the order determined by LLM-based relevance ranking.
|
| 199 |
+
"""
|
| 200 |
+
try:
|
| 201 |
+
# Get or create session
|
| 202 |
+
session_id = get_or_create_session_for_request(request, message.session_id)
|
| 203 |
+
|
| 204 |
+
# Add user message to session first (needed for persona ranking)
|
| 205 |
+
session = session_manager.get_session(session_id)
|
| 206 |
+
session.append_message("user", message.user_input)
|
| 207 |
+
|
| 208 |
+
# Get intelligently ordered personas based on context
|
| 209 |
+
top_personas = await chat_orchestrator.get_top_personas(
|
| 210 |
+
session_id=session_id,
|
| 211 |
+
k=3 # Get top 3 most relevant personas
|
| 212 |
+
)
|
| 213 |
+
|
| 214 |
+
logger.info(f"Intelligent persona order for session {session_id}: {top_personas}")
|
| 215 |
+
|
| 216 |
+
# Generate responses from personas in the intelligent order
|
| 217 |
+
responses = []
|
| 218 |
+
|
| 219 |
+
for persona_id in top_personas:
|
| 220 |
+
try:
|
| 221 |
+
# Generate response from this persona
|
| 222 |
+
persona_result = await chat_orchestrator.chat_with_persona(
|
| 223 |
+
user_input=message.user_input,
|
| 224 |
+
persona_id=persona_id,
|
| 225 |
+
session_id=session_id,
|
| 226 |
+
response_length=message.response_length or "medium"
|
| 227 |
+
)
|
| 228 |
+
|
| 229 |
+
|
| 230 |
+
if "persona_name" in persona_result and "response" in persona_result:
|
| 231 |
+
responses.append({
|
| 232 |
+
"persona": persona_result["persona_name"],
|
| 233 |
+
"persona_id": persona_result["persona_id"],
|
| 234 |
+
"response": persona_result["response"]
|
| 235 |
+
})
|
| 236 |
+
elif persona_result.get("type") == "single_persona_response" and "persona" in persona_result:
|
| 237 |
+
persona_data = persona_result["persona"]
|
| 238 |
+
responses.append({
|
| 239 |
+
"persona": persona_data["persona_name"],
|
| 240 |
+
"persona_id": persona_data["persona_id"],
|
| 241 |
+
"response": persona_data["response"]
|
| 242 |
+
})
|
| 243 |
+
else:
|
| 244 |
+
# Fallback response
|
| 245 |
+
responses.append({
|
| 246 |
+
"persona": chat_orchestrator.personas[persona_id].name,
|
| 247 |
+
"persona_id": persona_id,
|
| 248 |
+
"response": "I'm having trouble processing your question right now. Please try again."
|
| 249 |
+
})
|
| 250 |
+
|
| 251 |
+
except Exception as e:
|
| 252 |
+
logger.error(f"Error generating response for persona {persona_id}: {str(e)}")
|
| 253 |
+
# Error fallback
|
| 254 |
+
responses.append({
|
| 255 |
+
"persona": chat_orchestrator.personas[persona_id].name,
|
| 256 |
+
"persona_id": persona_id,
|
| 257 |
+
"response": "I encountered an error while processing your question. Please try again."
|
| 258 |
+
})
|
| 259 |
+
|
| 260 |
+
# response format
|
| 261 |
+
return {
|
| 262 |
+
"type": "sequential_responses",
|
| 263 |
+
"responses": responses
|
| 264 |
+
}
|
| 265 |
+
|
| 266 |
+
except Exception as e:
|
| 267 |
+
logger.error(f"Error in enhanced sequential chat: {str(e)}")
|
| 268 |
+
return {
|
| 269 |
+
"type": "error",
|
| 270 |
+
"responses": [{
|
| 271 |
+
"persona": "System",
|
| 272 |
+
"response": "I'm having trouble processing your request. Could you please try again?"
|
| 273 |
+
}]
|
| 274 |
+
}
|
| 275 |
+
|
| 276 |
+
@router.post("/chat/{persona_id}")
|
| 277 |
+
async def chat_with_specific_advisor(persona_id: str, input: UserInput, request: Request):
|
| 278 |
+
"""Chat with a specific advisor - SAME INTERFACE"""
|
| 279 |
+
try:
|
| 280 |
+
if persona_id not in chat_orchestrator.personas:
|
| 281 |
+
raise HTTPException(status_code=404, detail=f"Persona '{persona_id}' not found")
|
| 282 |
+
|
| 283 |
+
# Get session using compatibility layer
|
| 284 |
+
session_id = get_or_create_session_for_request(request)
|
| 285 |
+
|
| 286 |
+
# Use new orchestrator
|
| 287 |
+
result = await chat_orchestrator.chat_with_persona(
|
| 288 |
+
user_input=input.user_input,
|
| 289 |
+
persona_id=persona_id,
|
| 290 |
+
session_id=session_id
|
| 291 |
+
)
|
| 292 |
+
|
| 293 |
+
# FIX: Handle the actual response structure from orchestrator
|
| 294 |
+
if result.get("type") == "single_persona_response" and "persona" in result:
|
| 295 |
+
# New expected structure
|
| 296 |
+
persona_data = result["persona"]
|
| 297 |
+
return {
|
| 298 |
+
"persona": persona_data["persona_name"],
|
| 299 |
+
"persona_id": persona_data["persona_id"],
|
| 300 |
+
"response": persona_data["response"]
|
| 301 |
+
}
|
| 302 |
+
elif "persona_id" in result and "response" in result:
|
| 303 |
+
# Current actual structure from orchestrator
|
| 304 |
+
return {
|
| 305 |
+
"persona": result["persona_name"],
|
| 306 |
+
"persona_id": result["persona_id"],
|
| 307 |
+
"response": result["response"]
|
| 308 |
+
}
|
| 309 |
+
elif result.get("type") == "error" or "error" in result:
|
| 310 |
+
# Error handling
|
| 311 |
+
return {
|
| 312 |
+
"persona": "System",
|
| 313 |
+
"response": result.get("error", "I'm having trouble generating a response right now. Please try again.")
|
| 314 |
+
}
|
| 315 |
+
else:
|
| 316 |
+
# Fallback
|
| 317 |
+
return {
|
| 318 |
+
"persona": "System",
|
| 319 |
+
"response": "I'm having trouble generating a response right now. Please try again."
|
| 320 |
+
}
|
| 321 |
+
|
| 322 |
+
except HTTPException:
|
| 323 |
+
raise
|
| 324 |
+
except Exception as e:
|
| 325 |
+
logger.error(f"Error in chat_with_specific_advisor: {e}")
|
| 326 |
+
return {
|
| 327 |
+
"persona": "System",
|
| 328 |
+
"response": "I'm having trouble generating a response right now. Please try again."
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
# Reply to advisor endpoint (SAME INTERFACE)
|
| 332 |
+
@router.post("/reply-to-advisor")
|
| 333 |
+
async def reply_to_advisor(reply: ReplyToAdvisor, request: Request):
|
| 334 |
+
"""Reply to a specific advisor - SAME INTERFACE"""
|
| 335 |
+
try:
|
| 336 |
+
if reply.advisor_id not in chat_orchestrator.personas:
|
| 337 |
+
raise HTTPException(status_code=404, detail=f"Advisor '{reply.advisor_id}' not found")
|
| 338 |
+
|
| 339 |
+
# Get session using compatibility layer
|
| 340 |
+
session_id = get_or_create_session_for_request(request)
|
| 341 |
+
|
| 342 |
+
# Use new orchestrator
|
| 343 |
+
result = await chat_orchestrator.chat_with_persona(
|
| 344 |
+
user_input=reply.user_input,
|
| 345 |
+
persona_id=reply.advisor_id,
|
| 346 |
+
session_id=session_id
|
| 347 |
+
)
|
| 348 |
+
|
| 349 |
+
if result["type"] == "single_persona_response":
|
| 350 |
+
persona_data = result["persona"]
|
| 351 |
+
return {
|
| 352 |
+
"type": "advisor_reply",
|
| 353 |
+
"persona": persona_data["persona_name"],
|
| 354 |
+
"persona_id": persona_data["persona_id"],
|
| 355 |
+
"response": persona_data["response"],
|
| 356 |
+
"original_message_id": reply.original_message_id
|
| 357 |
+
}
|
| 358 |
+
else:
|
| 359 |
+
return {
|
| 360 |
+
"type": "error",
|
| 361 |
+
"persona": "System",
|
| 362 |
+
"response": result.get("message", "I'm having trouble generating a reply right now. Please try again.")
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
except HTTPException:
|
| 366 |
+
raise
|
| 367 |
+
except Exception as e:
|
| 368 |
+
logger.error(f"Error in reply_to_advisor: {e}")
|
| 369 |
+
return {
|
| 370 |
+
"type": "error",
|
| 371 |
+
"persona": "System",
|
| 372 |
+
"response": "I'm having trouble generating a reply right now. Please try again."
|
| 373 |
+
}
|
| 374 |
+
|
| 375 |
+
@router.post("/upload-document")
|
| 376 |
+
async def upload_document(file: UploadFile = File(...), request: Request = None):
|
| 377 |
+
"""
|
| 378 |
+
Enhanced document upload with better metadata tracking and user feedback
|
| 379 |
+
"""
|
| 380 |
+
try:
|
| 381 |
+
# Get or create session
|
| 382 |
+
session_id = get_or_create_session_for_request(request)
|
| 383 |
+
session = session_manager.get_session(session_id)
|
| 384 |
+
|
| 385 |
+
# Validate file
|
| 386 |
+
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
|
| 387 |
+
if file.size and file.size > MAX_FILE_SIZE:
|
| 388 |
+
raise HTTPException(status_code=413, detail="File size exceeds 10MB limit")
|
| 389 |
+
|
| 390 |
+
|
| 391 |
+
# Read and validate file content
|
| 392 |
+
file_bytes = await file.read()
|
| 393 |
+
content = extract_text_from_file(file_bytes, file.content_type)
|
| 394 |
+
if not content.strip():
|
| 395 |
+
raise HTTPException(status_code=400, detail="Document is empty or unreadable.")
|
| 396 |
+
|
| 397 |
+
# Get enhanced RAG manager
|
| 398 |
+
rag_manager = get_rag_manager()
|
| 399 |
+
|
| 400 |
+
# Determine file type for metadata
|
| 401 |
+
file_type_map = {
|
| 402 |
+
"application/pdf": "pdf",
|
| 403 |
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
|
| 404 |
+
"text/plain": "txt"
|
| 405 |
+
}
|
| 406 |
+
file_type = file_type_map.get(file.content_type, "unknown")
|
| 407 |
+
|
| 408 |
+
# Add document to enhanced vector database
|
| 409 |
+
rag_result = rag_manager.add_document(
|
| 410 |
+
content=content,
|
| 411 |
+
filename=file.filename,
|
| 412 |
+
session_id=session_id,
|
| 413 |
+
file_type=file_type
|
| 414 |
+
)
|
| 415 |
+
|
| 416 |
+
if not rag_result["success"]:
|
| 417 |
+
raise HTTPException(
|
| 418 |
+
status_code=500,
|
| 419 |
+
detail=f"Failed to process document: {rag_result.get('error', 'Unknown error')}"
|
| 420 |
+
)
|
| 421 |
+
|
| 422 |
+
# Update session tracking
|
| 423 |
+
session.uploaded_files.append(file.filename)
|
| 424 |
+
session.total_upload_size += len(file_bytes)
|
| 425 |
+
|
| 426 |
+
# Add enhanced document reference to session messages
|
| 427 |
+
doc_metadata = rag_result.get("document_metadata", {})
|
| 428 |
+
doc_title = doc_metadata.get("title", file.filename)
|
| 429 |
+
|
| 430 |
+
session.append_message(
|
| 431 |
+
"system",
|
| 432 |
+
f"Document uploaded: '{doc_title}' ({file.filename}) - "
|
| 433 |
+
f"{rag_result['chunks_created']} sections processed, "
|
| 434 |
+
f"~{rag_result['total_tokens']} tokens analyzed. "
|
| 435 |
+
f"You can now ask questions about this document by referencing it by name."
|
| 436 |
+
)
|
| 437 |
+
|
| 438 |
+
return {
|
| 439 |
+
"message": f"Document '{file.filename}' uploaded and processed successfully.",
|
| 440 |
+
"filename": file.filename,
|
| 441 |
+
"document_title": doc_title,
|
| 442 |
+
"chunks_created": rag_result['chunks_created'],
|
| 443 |
+
"total_tokens": rag_result['total_tokens'],
|
| 444 |
+
"file_type": file_type,
|
| 445 |
+
"can_reference_by_name": True,
|
| 446 |
+
"suggestions": [
|
| 447 |
+
f"Try asking: 'What methodology does my {file.filename} propose?'",
|
| 448 |
+
f"Or: 'What are the key findings in {doc_title}?'",
|
| 449 |
+
f"Or: 'Compare the approach in my document with current best practices'"
|
| 450 |
+
]
|
| 451 |
+
}
|
| 452 |
+
|
| 453 |
+
except HTTPException:
|
| 454 |
+
raise
|
| 455 |
+
except Exception as e:
|
| 456 |
+
logger.error(f"Error processing document upload: {str(e)}")
|
| 457 |
+
raise HTTPException(status_code=500, detail=f"Error processing document: {str(e)}")
|
| 458 |
+
|
| 459 |
+
|
| 460 |
+
@router.get("/export-chat")
|
| 461 |
+
async def export_chat(request: Request, format: str = Query(..., regex="^(txt|pdf|docx)$")):
|
| 462 |
+
"""
|
| 463 |
+
Export the current chat context in the requested format.
|
| 464 |
+
"""
|
| 465 |
+
try:
|
| 466 |
+
session_id = get_or_create_session_for_request(request)
|
| 467 |
+
session = session_manager.get_session(session_id)
|
| 468 |
+
|
| 469 |
+
if not session.messages:
|
| 470 |
+
return {"error": "No messages in this session."}
|
| 471 |
+
|
| 472 |
+
return prepare_export_response(session.messages, format)
|
| 473 |
+
|
| 474 |
+
except Exception as e:
|
| 475 |
+
logger.error(f"Error exporting chat: {str(e)}")
|
| 476 |
+
return {"error": "Failed to export chat.", "detail": str(e)}
|
| 477 |
+
|
| 478 |
+
|
| 479 |
+
@router.get("/chat-summary")
|
| 480 |
+
async def chat_summary(
|
| 481 |
+
request: Request,
|
| 482 |
+
format: str = Query("text", regex="^(txt|pdf|docx)$")
|
| 483 |
+
):
|
| 484 |
+
"""
|
| 485 |
+
Generate and return a summary of the current session chat.
|
| 486 |
+
Can return as plain txt, PDF, or DOCX.
|
| 487 |
+
"""
|
| 488 |
+
try:
|
| 489 |
+
session_id = get_or_create_session_for_request(request)
|
| 490 |
+
session = session_manager.get_session(session_id)
|
| 491 |
+
|
| 492 |
+
if not session.messages:
|
| 493 |
+
return {"error": "No messages in this session."}
|
| 494 |
+
|
| 495 |
+
llm = next(iter(chat_orchestrator.personas.values())).llm
|
| 496 |
+
summary_text = await generate_summary_from_messages(session.messages, llm)
|
| 497 |
+
|
| 498 |
+
if format == "txt":
|
| 499 |
+
return prepare_export_response(summary_text, "txt", filename_prefix="chat_summary")
|
| 500 |
+
|
| 501 |
+
elif format == "docx":
|
| 502 |
+
return prepare_export_response(summary_text, "docx", filename_prefix="chat_summary")
|
| 503 |
+
|
| 504 |
+
elif format == "pdf":
|
| 505 |
+
# Parse and render using block formatting
|
| 506 |
+
blocks = [{"type": "heading", "text": "Chat Summary"}] + parse_summary_to_blocks(summary_text)
|
| 507 |
+
|
| 508 |
+
file_stream = generate_pdf_file_from_blocks(blocks)
|
| 509 |
+
return StreamingResponse(
|
| 510 |
+
file_stream,
|
| 511 |
+
media_type="application/pdf",
|
| 512 |
+
headers={"Content-Disposition": "attachment; filename=chat_summary.pdf"}
|
| 513 |
+
)
|
| 514 |
+
|
| 515 |
+
except Exception as e:
|
| 516 |
+
logger.error(f"Error in chat-summary endpoint: {str(e)}")
|
| 517 |
+
return {"error": "Summary generation failed", "detail": str(e)}
|
| 518 |
+
|
| 519 |
+
|
| 520 |
+
|
| 521 |
+
|
| 522 |
+
# Add new endpoint to get document statistics
|
| 523 |
+
@router.get("/document-stats")
|
| 524 |
+
async def get_document_stats(request: Request):
|
| 525 |
+
"""Get statistics about uploaded documents in vector database"""
|
| 526 |
+
try:
|
| 527 |
+
session_id = get_or_create_session_for_request(request)
|
| 528 |
+
rag_manager = get_rag_manager()
|
| 529 |
+
|
| 530 |
+
stats = rag_manager.get_document_stats(session_id)
|
| 531 |
+
return stats
|
| 532 |
+
|
| 533 |
+
except Exception as e:
|
| 534 |
+
logger.error(f"Error getting document stats: {str(e)}")
|
| 535 |
+
return {"total_chunks": 0, "total_documents": 0, "documents": []}
|
| 536 |
+
|
| 537 |
+
# Get uploaded files (SAME INTERFACE)
|
| 538 |
+
@router.get("/uploaded-files")
|
| 539 |
+
async def get_uploaded_filenames(request: Request):
|
| 540 |
+
"""Get uploaded files - SAME INTERFACE"""
|
| 541 |
+
try:
|
| 542 |
+
session_id = get_or_create_session_for_request(request)
|
| 543 |
+
session = session_manager.get_session(session_id)
|
| 544 |
+
return {"files": session.uploaded_files}
|
| 545 |
+
except Exception as e:
|
| 546 |
+
logger.error(f"Error getting uploaded files: {str(e)}")
|
| 547 |
+
return {"files": []}
|
| 548 |
+
|
| 549 |
+
# Context endpoint (SAME INTERFACE)
|
| 550 |
+
@router.get("/context")
|
| 551 |
+
async def get_context(request: Request):
|
| 552 |
+
"""Get context - ENHANCED with RAG information"""
|
| 553 |
+
try:
|
| 554 |
+
session_id = get_or_create_session_for_request(request)
|
| 555 |
+
session = session_manager.get_session(session_id)
|
| 556 |
+
|
| 557 |
+
# Get RAG statistics
|
| 558 |
+
rag_stats = session.get_rag_stats()
|
| 559 |
+
|
| 560 |
+
return {
|
| 561 |
+
"messages": session.messages,
|
| 562 |
+
"rag_info": {
|
| 563 |
+
"total_documents": rag_stats.get("total_documents", 0),
|
| 564 |
+
"total_chunks": rag_stats.get("total_chunks", 0),
|
| 565 |
+
"documents": rag_stats.get("documents", [])
|
| 566 |
+
}
|
| 567 |
+
}
|
| 568 |
+
except Exception as e:
|
| 569 |
+
logger.error(f"Error getting context: {str(e)}")
|
| 570 |
+
return {"messages": [], "rag_info": {"total_documents": 0, "total_chunks": 0}}
|
| 571 |
+
|
| 572 |
+
@router.post("/reset-session")
|
| 573 |
+
async def reset_session(request: Request):
|
| 574 |
+
"""Reset session - ENHANCED with RAG cleanup"""
|
| 575 |
+
try:
|
| 576 |
+
session_id = get_or_create_session_for_request(request)
|
| 577 |
+
|
| 578 |
+
# Use the enhanced reset that clears both conversation and vector DB
|
| 579 |
+
success = session_manager.reset_session_completely(session_id)
|
| 580 |
+
|
| 581 |
+
if success:
|
| 582 |
+
return {"status": "reset", "message": "Session and all documents reset successfully"}
|
| 583 |
+
else:
|
| 584 |
+
return {"status": "error", "message": "Failed to reset session"}
|
| 585 |
+
except Exception as e:
|
| 586 |
+
logger.error(f"Error resetting session: {e}")
|
| 587 |
+
return {"status": "error", "message": "Failed to reset session"}
|
| 588 |
+
|
| 589 |
+
|
| 590 |
+
# Legacy model endpoints (SAME INTERFACE)
|
| 591 |
+
@router.post("/switch-model")
|
| 592 |
+
async def switch_model(model_name: str = Body(...)):
|
| 593 |
+
"""Legacy model switching - SAME INTERFACE"""
|
| 594 |
+
if "gemini" in model_name.lower():
|
| 595 |
+
return await switch_provider(ProviderSwitch(provider="gemini"))
|
| 596 |
+
else:
|
| 597 |
+
return await switch_provider(ProviderSwitch(provider="ollama"))
|
| 598 |
+
|
| 599 |
+
@router.get("/current-model")
|
| 600 |
+
async def get_current_model():
|
| 601 |
+
"""Legacy model info - SAME INTERFACE"""
|
| 602 |
+
model_name = llm.model_name if hasattr(llm, 'model_name') else "gemini-2.0-flash"
|
| 603 |
+
return {
|
| 604 |
+
"model": model_name,
|
| 605 |
+
"provider": current_provider
|
| 606 |
+
}
|
| 607 |
+
|
| 608 |
+
@router.post("/search-documents")
|
| 609 |
+
async def search_documents(request: Request, query: str = Body(..., embed=True), persona: str = Body("", embed=True)):
|
| 610 |
+
"""
|
| 611 |
+
Search uploaded documents using RAG
|
| 612 |
+
|
| 613 |
+
This endpoint allows direct document search for debugging/testing
|
| 614 |
+
"""
|
| 615 |
+
try:
|
| 616 |
+
session_id = get_or_create_session_for_request(request)
|
| 617 |
+
rag_manager = get_rag_manager()
|
| 618 |
+
|
| 619 |
+
# Get persona context for search enhancement
|
| 620 |
+
persona_contexts = {
|
| 621 |
+
"methodologist": "methodology research design analysis",
|
| 622 |
+
"theorist": "theory theoretical framework conceptual",
|
| 623 |
+
"pragmatist": "practical application implementation"
|
| 624 |
+
}
|
| 625 |
+
persona_context = persona_contexts.get(persona, "")
|
| 626 |
+
|
| 627 |
+
# Search documents
|
| 628 |
+
results = rag_manager.search_documents(
|
| 629 |
+
query=query,
|
| 630 |
+
session_id=session_id,
|
| 631 |
+
persona_context=persona_context,
|
| 632 |
+
n_results=5
|
| 633 |
+
)
|
| 634 |
+
|
| 635 |
+
return {
|
| 636 |
+
"query": query,
|
| 637 |
+
"persona_filter": persona,
|
| 638 |
+
"results_count": len(results),
|
| 639 |
+
"results": results
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
+
except Exception as e:
|
| 643 |
+
logger.error(f"Error searching documents: {str(e)}")
|
| 644 |
+
return {"query": query, "results_count": 0, "results": [], "error": str(e)}
|
| 645 |
+
|
| 646 |
+
@router.get("/session-stats")
|
| 647 |
+
async def get_session_stats(request: Request):
|
| 648 |
+
"""Get comprehensive session statistics including RAG data"""
|
| 649 |
+
try:
|
| 650 |
+
session_id = get_or_create_session_for_request(request)
|
| 651 |
+
stats = session_manager.get_session_stats(session_id)
|
| 652 |
+
return stats
|
| 653 |
+
except Exception as e:
|
| 654 |
+
logger.error(f"Error getting session stats: {str(e)}")
|
| 655 |
+
return {"error": str(e)}
|
| 656 |
+
|
| 657 |
+
|
| 658 |
+
@router.get("/debug/personas")
|
| 659 |
+
async def debug_personas(request: Request):
|
| 660 |
+
"""Debug personas - ENHANCED with RAG information"""
|
| 661 |
+
try:
|
| 662 |
+
session_id = get_or_create_session_for_request(request)
|
| 663 |
+
session = session_manager.get_session(session_id)
|
| 664 |
+
|
| 665 |
+
# Get RAG statistics
|
| 666 |
+
rag_manager = get_rag_manager()
|
| 667 |
+
rag_stats = rag_manager.get_document_stats(session_id)
|
| 668 |
+
|
| 669 |
+
return {
|
| 670 |
+
"personas": {
|
| 671 |
+
pid: {
|
| 672 |
+
"name": persona.name,
|
| 673 |
+
"prompt": persona.system_prompt[:100] + "...",
|
| 674 |
+
"retrieval_keywords": chat_orchestrator._get_persona_context_keywords(pid)
|
| 675 |
+
} for pid, persona in chat_orchestrator.personas.items()
|
| 676 |
+
},
|
| 677 |
+
"session_info": {
|
| 678 |
+
"context_length": len(session.messages),
|
| 679 |
+
"uploaded_files": session.uploaded_files,
|
| 680 |
+
"rag_stats": rag_stats
|
| 681 |
+
},
|
| 682 |
+
"current_provider": current_provider,
|
| 683 |
+
"rag_enabled": True
|
| 684 |
+
}
|
| 685 |
+
except Exception as e:
|
| 686 |
+
logger.error(f"Error in debug endpoint: {str(e)}")
|
| 687 |
+
return {
|
| 688 |
+
"personas": {},
|
| 689 |
+
"session_info": {"context_length": 0},
|
| 690 |
+
"current_provider": current_provider,
|
| 691 |
+
"rag_enabled": False,
|
| 692 |
+
"error": str(e)
|
| 693 |
+
}
|
| 694 |
+
|
| 695 |
+
@router.get("/debug/ranked-personas")
|
| 696 |
+
async def get_ranked_personas(request: Request, k: int = Query(3, ge=1, le=10)):
|
| 697 |
+
"""
|
| 698 |
+
Debug endpoint: Get top-k ranked personas based on current session context.
|
| 699 |
+
Uses LLM to rank based on latest conversation messages.
|
| 700 |
+
"""
|
| 701 |
+
try:
|
| 702 |
+
session_id = get_or_create_session_for_request(request)
|
| 703 |
+
|
| 704 |
+
# Call the ranking method
|
| 705 |
+
top_personas = await chat_orchestrator.get_top_personas(session_id=session_id, k=k)
|
| 706 |
+
|
| 707 |
+
# Include some metadata for debug purposes
|
| 708 |
+
return {
|
| 709 |
+
"ranked_personas": top_personas,
|
| 710 |
+
"available_personas": list(chat_orchestrator.personas.keys()),
|
| 711 |
+
"session_id": session_id
|
| 712 |
+
}
|
| 713 |
+
except Exception as e:
|
| 714 |
+
logger.error(f"Error in /debug/ranked-personas: {e}")
|
| 715 |
+
return {
|
| 716 |
+
"ranked_personas": [],
|
| 717 |
+
"error": str(e)
|
| 718 |
+
}
|
| 719 |
+
|
| 720 |
+
|
| 721 |
+
@router.post("/chat/{persona_id}")
|
| 722 |
+
async def chat_with_specific_persona(persona_id: str, message: ChatMessage, request: Request):
|
| 723 |
+
"""
|
| 724 |
+
Chat with a specific persona - Enhanced with RAG debugging
|
| 725 |
+
|
| 726 |
+
This endpoint helps debug RAG integration by testing individual personas
|
| 727 |
+
"""
|
| 728 |
+
try:
|
| 729 |
+
session_id = get_or_create_session_for_request(request, message.session_id)
|
| 730 |
+
|
| 731 |
+
# Validate persona exists
|
| 732 |
+
if persona_id not in chat_orchestrator.personas:
|
| 733 |
+
available_personas = list(chat_orchestrator.personas.keys())
|
| 734 |
+
raise HTTPException(
|
| 735 |
+
status_code=400,
|
| 736 |
+
detail=f"Persona '{persona_id}' not found. Available: {available_personas}"
|
| 737 |
+
)
|
| 738 |
+
|
| 739 |
+
# Use the enhanced orchestrator method
|
| 740 |
+
result = await chat_orchestrator.chat_with_persona(
|
| 741 |
+
user_input=message.user_input,
|
| 742 |
+
persona_id=persona_id,
|
| 743 |
+
session_id=session_id,
|
| 744 |
+
response_length=message.response_length or "medium"
|
| 745 |
+
)
|
| 746 |
+
|
| 747 |
+
# Fix: Handle the response structure properly
|
| 748 |
+
if result.get("type") == "single_persona_response" and "persona" in result:
|
| 749 |
+
persona_data = result["persona"]
|
| 750 |
+
|
| 751 |
+
# Add debugging information
|
| 752 |
+
result["debug_info"] = {
|
| 753 |
+
"persona_id": persona_id,
|
| 754 |
+
"session_id": session_id,
|
| 755 |
+
"query_length": len(message.user_input),
|
| 756 |
+
"rag_manager_available": True,
|
| 757 |
+
"used_documents": persona_data.get("used_documents", False),
|
| 758 |
+
"chunks_used": persona_data.get("document_chunks_used", 0)
|
| 759 |
+
}
|
| 760 |
+
|
| 761 |
+
return result
|
| 762 |
+
|
| 763 |
+
except HTTPException:
|
| 764 |
+
raise
|
| 765 |
+
except Exception as e:
|
| 766 |
+
logger.error(f"Error in individual persona chat: {str(e)}")
|
| 767 |
+
return {
|
| 768 |
+
"type": "error",
|
| 769 |
+
"message": f"Error chatting with {persona_id}: {str(e)}",
|
| 770 |
+
"persona_id": persona_id
|
| 771 |
+
}
|
| 772 |
+
|
| 773 |
+
@router.get("/debug/enhanced-personas")
|
| 774 |
+
async def debug_enhanced_personas(request: Request):
|
| 775 |
+
"""
|
| 776 |
+
Enhanced debug endpoint with document context information
|
| 777 |
+
"""
|
| 778 |
+
try:
|
| 779 |
+
session_id = get_or_create_session_for_request(request)
|
| 780 |
+
session = session_manager.get_session(session_id)
|
| 781 |
+
|
| 782 |
+
# Get enhanced RAG statistics
|
| 783 |
+
rag_manager = get_rag_manager()
|
| 784 |
+
rag_stats = rag_manager.get_document_stats(session_id)
|
| 785 |
+
|
| 786 |
+
# Analyze document awareness capabilities
|
| 787 |
+
document_analysis = {}
|
| 788 |
+
if rag_stats.get("documents"):
|
| 789 |
+
for doc in rag_stats["documents"]:
|
| 790 |
+
document_analysis[doc["filename"]] = {
|
| 791 |
+
"chunks_available": doc["chunks"],
|
| 792 |
+
"estimated_tokens": doc["estimated_tokens"],
|
| 793 |
+
"sections_identified": doc["sections"],
|
| 794 |
+
"content_types_detected": {
|
| 795 |
+
"has_methodology": doc.get("has_methodology", False),
|
| 796 |
+
"has_theory": doc.get("has_theory", False),
|
| 797 |
+
"has_references": doc.get("has_references", False)
|
| 798 |
+
}
|
| 799 |
+
}
|
| 800 |
+
|
| 801 |
+
return {
|
| 802 |
+
"personas": {
|
| 803 |
+
pid: {
|
| 804 |
+
"name": persona.name,
|
| 805 |
+
"expertise_area": persona.name.split(" - ")[1] if " - " in persona.name else "General",
|
| 806 |
+
"prompt_quality": "enhanced" if len(persona.system_prompt) > 500 else "basic",
|
| 807 |
+
"document_handling_enabled": "document awareness" in persona.system_prompt.lower(),
|
| 808 |
+
"retrieval_keywords": chat_orchestrator._get_enhanced_persona_context_keywords(pid)[:100] + "...",
|
| 809 |
+
"temperature": getattr(persona, 'temperature', 5)
|
| 810 |
+
} for pid, persona in chat_orchestrator.personas.items()
|
| 811 |
+
},
|
| 812 |
+
"session_info": {
|
| 813 |
+
"context_length": len(session.messages),
|
| 814 |
+
"uploaded_files": session.uploaded_files,
|
| 815 |
+
"rag_stats": rag_stats,
|
| 816 |
+
"document_analysis": document_analysis
|
| 817 |
+
},
|
| 818 |
+
"system_capabilities": {
|
| 819 |
+
"document_name_recognition": True,
|
| 820 |
+
"cross_document_analysis": True,
|
| 821 |
+
"persona_specialized_retrieval": True,
|
| 822 |
+
"enhanced_attribution": True,
|
| 823 |
+
"query_document_detection": True
|
| 824 |
+
},
|
| 825 |
+
"current_provider": current_provider,
|
| 826 |
+
"rag_enabled": True,
|
| 827 |
+
"enhancement_level": "advanced"
|
| 828 |
+
}
|
| 829 |
+
except Exception as e:
|
| 830 |
+
logger.error(f"Error in enhanced debug endpoint: {str(e)}")
|
| 831 |
+
return {
|
| 832 |
+
"error": str(e),
|
| 833 |
+
"enhancement_level": "error",
|
| 834 |
+
"rag_enabled": False
|
| 835 |
+
}
|
| 836 |
+
|
| 837 |
+
@router.get("/document-insights/{filename}")
|
| 838 |
+
async def get_document_insights(filename: str, request: Request):
|
| 839 |
+
"""
|
| 840 |
+
NEW ENDPOINT: Get insights about a specific uploaded document
|
| 841 |
+
"""
|
| 842 |
+
try:
|
| 843 |
+
session_id = get_or_create_session_for_request(request)
|
| 844 |
+
rag_manager = get_rag_manager()
|
| 845 |
+
|
| 846 |
+
# Get document statistics
|
| 847 |
+
stats = rag_manager.get_document_stats(session_id)
|
| 848 |
+
|
| 849 |
+
# Find the specific document
|
| 850 |
+
document_info = None
|
| 851 |
+
for doc in stats.get("documents", []):
|
| 852 |
+
if doc["filename"] == filename:
|
| 853 |
+
document_info = doc
|
| 854 |
+
break
|
| 855 |
+
|
| 856 |
+
if not document_info:
|
| 857 |
+
raise HTTPException(status_code=404, detail=f"Document {filename} not found")
|
| 858 |
+
|
| 859 |
+
# Get a sample of content from this document
|
| 860 |
+
results = rag_manager.collection.get(
|
| 861 |
+
where={"session_id": session_id, "filename": filename},
|
| 862 |
+
limit=3,
|
| 863 |
+
include=["documents", "metadatas"]
|
| 864 |
+
)
|
| 865 |
+
|
| 866 |
+
sample_sections = []
|
| 867 |
+
if results["documents"]:
|
| 868 |
+
for doc, metadata in zip(results["documents"], results["metadatas"]):
|
| 869 |
+
sample_sections.append({
|
| 870 |
+
"section": metadata.get("document_section", "unknown"),
|
| 871 |
+
"content_preview": doc[:200] + "..." if len(doc) > 200 else doc,
|
| 872 |
+
"keywords": metadata.get("keywords", "")
|
| 873 |
+
})
|
| 874 |
+
|
| 875 |
+
return {
|
| 876 |
+
"filename": filename,
|
| 877 |
+
"document_title": document_info.get("title", filename),
|
| 878 |
+
"file_type": document_info.get("file_type", "unknown"),
|
| 879 |
+
"statistics": {
|
| 880 |
+
"total_chunks": document_info["chunks"],
|
| 881 |
+
"estimated_tokens": document_info["estimated_tokens"],
|
| 882 |
+
"sections_identified": document_info["sections"]
|
| 883 |
+
},
|
| 884 |
+
"content_analysis": {
|
| 885 |
+
"has_methodology": document_info.get("has_methodology", False),
|
| 886 |
+
"has_theory": document_info.get("has_theory", False),
|
| 887 |
+
"has_references": document_info.get("has_references", False)
|
| 888 |
+
},
|
| 889 |
+
"sample_sections": sample_sections,
|
| 890 |
+
"suggested_queries": [
|
| 891 |
+
f"What methodology does my {filename} propose?",
|
| 892 |
+
f"What are the key theoretical concepts in {filename}?",
|
| 893 |
+
f"What are the main findings in my {document_info.get('title', filename)}?",
|
| 894 |
+
f"How can I improve the approach described in {filename}?"
|
| 895 |
+
]
|
| 896 |
+
}
|
| 897 |
+
|
| 898 |
+
except HTTPException:
|
| 899 |
+
raise
|
| 900 |
+
except Exception as e:
|
| 901 |
+
logger.error(f"Error getting document insights: {str(e)}")
|
| 902 |
+
raise HTTPException(status_code=500, detail=f"Error analyzing document: {str(e)}")
|
| 903 |
+
|
| 904 |
+
# Also add a debug endpoint to check RAG status:
|
| 905 |
+
|
| 906 |
+
@router.get("/debug/rag-status")
|
| 907 |
+
async def debug_rag_status(request: Request):
|
| 908 |
+
"""
|
| 909 |
+
Debug endpoint to check RAG system status
|
| 910 |
+
"""
|
| 911 |
+
try:
|
| 912 |
+
session_id = get_or_create_session_for_request(request)
|
| 913 |
+
|
| 914 |
+
# Get RAG manager
|
| 915 |
+
rag_manager = get_rag_manager()
|
| 916 |
+
|
| 917 |
+
# Get session stats
|
| 918 |
+
session_stats = session_manager.get_session_stats(session_id)
|
| 919 |
+
|
| 920 |
+
# Test a simple search
|
| 921 |
+
test_search = rag_manager.search_documents(
|
| 922 |
+
query="test methodology research",
|
| 923 |
+
session_id=session_id,
|
| 924 |
+
persona_context="",
|
| 925 |
+
n_results=3
|
| 926 |
+
)
|
| 927 |
+
|
| 928 |
+
return {
|
| 929 |
+
"rag_manager_healthy": True,
|
| 930 |
+
"session_id": session_id,
|
| 931 |
+
"session_stats": session_stats.get("rag_stats", {}),
|
| 932 |
+
"test_search_results": len(test_search),
|
| 933 |
+
"test_search_details": [
|
| 934 |
+
{
|
| 935 |
+
"relevance": chunk.get("relevance_score", 0),
|
| 936 |
+
"distance": chunk.get("distance", "unknown"),
|
| 937 |
+
"text_length": len(chunk.get("text", "")),
|
| 938 |
+
"filename": chunk.get("metadata", {}).get("filename", "unknown")
|
| 939 |
+
}
|
| 940 |
+
for chunk in test_search[:3]
|
| 941 |
+
],
|
| 942 |
+
"persona_keywords": {
|
| 943 |
+
pid: chat_orchestrator._get_persona_context_keywords(pid)
|
| 944 |
+
for pid in chat_orchestrator.personas.keys()
|
| 945 |
+
}
|
| 946 |
+
}
|
| 947 |
+
|
| 948 |
+
except Exception as e:
|
| 949 |
+
logger.error(f"Error in RAG debug: {str(e)}")
|
| 950 |
+
return {
|
| 951 |
+
"rag_manager_healthy": False,
|
| 952 |
+
"error": str(e),
|
| 953 |
+
"session_id": session_id if 'session_id' in locals() else "unknown"
|
| 954 |
+
}
|
| 955 |
+
|
| 956 |
+
# Ask endpoint (SAME INTERFACE)
|
| 957 |
+
class PersonaQuery(BaseModel):
|
| 958 |
+
question: str
|
| 959 |
+
persona: str
|
| 960 |
+
|
| 961 |
+
@router.post("/ask/")
|
| 962 |
+
async def ask_question(query: PersonaQuery, request: Request):
|
| 963 |
+
"""Ask question - SAME INTERFACE"""
|
| 964 |
+
try:
|
| 965 |
+
session_id = get_or_create_session_for_request(request)
|
| 966 |
+
|
| 967 |
+
# Use the new orchestrator
|
| 968 |
+
result = await chat_orchestrator.chat_with_persona(
|
| 969 |
+
user_input=query.question,
|
| 970 |
+
persona_id=query.persona,
|
| 971 |
+
session_id=session_id
|
| 972 |
+
)
|
| 973 |
+
|
| 974 |
+
if result["type"] == "single_persona_response":
|
| 975 |
+
response_text = result["persona"]["response"]
|
| 976 |
+
else:
|
| 977 |
+
response_text = result.get("message", "I'm having trouble responding right now.")
|
| 978 |
+
|
| 979 |
+
return {"response": response_text}
|
| 980 |
+
|
| 981 |
+
except Exception as e:
|
| 982 |
+
logger.error(f"Error in ask endpoint: {str(e)}")
|
| 983 |
+
return {"response": "I encountered an error. Please try again."}
|
| 984 |
+
|
| 985 |
+
|
| 986 |
+
|
| 987 |
+
# Root endpoint (SAME INTERFACE)
|
| 988 |
+
@router.get("/")
|
| 989 |
+
def root():
|
| 990 |
+
"""Root endpoint - SAME INTERFACE with updated info"""
|
| 991 |
+
return {
|
| 992 |
+
"message": "Multi-LLM PhD Advisor Backend is up and running",
|
| 993 |
+
"version": __version__,
|
| 994 |
+
"features": [
|
| 995 |
+
"Improved Session Management",
|
| 996 |
+
"Unified Context Handling",
|
| 997 |
+
"Ollama Support",
|
| 998 |
+
"Gemini API Support",
|
| 999 |
+
"Provider Switching"
|
| 1000 |
+
]
|
| 1001 |
+
}
|
multi_llm_chatbot_backend/app/api/routes/__init__.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter
|
| 2 |
+
from .chat import router as chat_router
|
| 3 |
+
from .documents import router as document_router
|
| 4 |
+
from .sessions import router as session_router
|
| 5 |
+
from .provider import router as provider_router
|
| 6 |
+
from .debug import router as debug_router
|
| 7 |
+
from .root import router as root_router
|
| 8 |
+
from .phd_canvas import router as canvas_router
|
| 9 |
+
from .voice import router as voice_router
|
| 10 |
+
|
| 11 |
+
router = APIRouter()
|
| 12 |
+
router.include_router(chat_router)
|
| 13 |
+
router.include_router(document_router)
|
| 14 |
+
router.include_router(session_router)
|
| 15 |
+
router.include_router(provider_router)
|
| 16 |
+
router.include_router(debug_router)
|
| 17 |
+
router.include_router(root_router)
|
| 18 |
+
router.include_router(canvas_router)
|
| 19 |
+
router.include_router(voice_router)
|
multi_llm_chatbot_backend/app/api/routes/auth.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Depends, status
|
| 2 |
+
from datetime import datetime, timedelta
|
| 3 |
+
from app.models.user import UserCreate, UserLogin, User, Token, UserResponse, UserUpdate
|
| 4 |
+
from pydantic import BaseModel, model_validator
|
| 5 |
+
from typing import Optional
|
| 6 |
+
from app.core.auth import (
|
| 7 |
+
get_password_hash,
|
| 8 |
+
verify_password,
|
| 9 |
+
authenticate_user,
|
| 10 |
+
create_access_token,
|
| 11 |
+
get_user_by_email,
|
| 12 |
+
get_current_active_user,
|
| 13 |
+
create_user_response,
|
| 14 |
+
ACCESS_TOKEN_EXPIRE_MINUTES
|
| 15 |
+
)
|
| 16 |
+
from app.core.database import get_database
|
| 17 |
+
import logging
|
| 18 |
+
|
| 19 |
+
logger = logging.getLogger(__name__)
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class MessageResponse(BaseModel):
|
| 23 |
+
message: str
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
class ChangePasswordRequest(BaseModel):
|
| 27 |
+
current_password: str
|
| 28 |
+
new_password: str
|
| 29 |
+
|
| 30 |
+
@model_validator(mode="after")
|
| 31 |
+
def passwords_must_differ(self):
|
| 32 |
+
if self.current_password == self.new_password:
|
| 33 |
+
raise ValueError("New password must be different from the current password")
|
| 34 |
+
return self
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
class UpdateProfileRequest(BaseModel):
|
| 38 |
+
first_name: Optional[str] = None
|
| 39 |
+
last_name: Optional[str] = None
|
| 40 |
+
|
| 41 |
+
@model_validator(mode="after")
|
| 42 |
+
def at_least_one_field(self):
|
| 43 |
+
if self.first_name is not None:
|
| 44 |
+
self.first_name = self.first_name.strip() or None
|
| 45 |
+
if self.last_name is not None:
|
| 46 |
+
self.last_name = self.last_name.strip() or None
|
| 47 |
+
if self.first_name is None and self.last_name is None:
|
| 48 |
+
raise ValueError("At least one field must be provided")
|
| 49 |
+
return self
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class DeleteAccountRequest(BaseModel):
|
| 53 |
+
password: str
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
router = APIRouter()
|
| 57 |
+
|
| 58 |
+
@router.post("/signup", response_model=Token)
|
| 59 |
+
async def signup(user_data: UserCreate):
|
| 60 |
+
"""
|
| 61 |
+
Register a new user and return an access token.
|
| 62 |
+
@param user_data: UserCreate with name, email, password, and optional academic fields
|
| 63 |
+
@return: Token containing a JWT access token and the created UserResponse
|
| 64 |
+
"""
|
| 65 |
+
try:
|
| 66 |
+
db = get_database()
|
| 67 |
+
|
| 68 |
+
# Check if user already exists
|
| 69 |
+
existing_user = await get_user_by_email(user_data.email)
|
| 70 |
+
if existing_user:
|
| 71 |
+
raise HTTPException(
|
| 72 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 73 |
+
detail="Email already registered"
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
# Create new user
|
| 77 |
+
hashed_password = get_password_hash(user_data.password)
|
| 78 |
+
user = User(
|
| 79 |
+
firstName=user_data.firstName,
|
| 80 |
+
lastName=user_data.lastName,
|
| 81 |
+
email=user_data.email,
|
| 82 |
+
hashed_password=hashed_password,
|
| 83 |
+
academicStage=user_data.academicStage,
|
| 84 |
+
researchArea=user_data.researchArea,
|
| 85 |
+
created_at=datetime.utcnow(),
|
| 86 |
+
is_active=True
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
# Insert user into database
|
| 90 |
+
result = await db.users.insert_one(user.dict(by_alias=True))
|
| 91 |
+
user.id = result.inserted_id
|
| 92 |
+
|
| 93 |
+
profile_seed: dict = {
|
| 94 |
+
"user_id": user.id,
|
| 95 |
+
"updated_at": datetime.utcnow(),
|
| 96 |
+
}
|
| 97 |
+
if user_data.academicStage:
|
| 98 |
+
profile_seed["knowledge_level"] = user_data.academicStage
|
| 99 |
+
if user_data.researchArea:
|
| 100 |
+
profile_seed["timezone"] = user_data.researchArea
|
| 101 |
+
await db.user_profiles.update_one(
|
| 102 |
+
{"user_id": user.id},
|
| 103 |
+
{"$set": profile_seed},
|
| 104 |
+
upsert=True,
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
# Create access token
|
| 108 |
+
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 109 |
+
access_token = create_access_token(
|
| 110 |
+
data={"sub": str(user.id)},
|
| 111 |
+
expires_delta=access_token_expires
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
return Token(
|
| 115 |
+
access_token=access_token,
|
| 116 |
+
token_type="bearer",
|
| 117 |
+
user=create_user_response(user)
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
except HTTPException:
|
| 121 |
+
raise
|
| 122 |
+
except Exception as e:
|
| 123 |
+
logger.error(f"Error during signup: {e}")
|
| 124 |
+
raise HTTPException(
|
| 125 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 126 |
+
detail="Could not create user account"
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
@router.post("/login", response_model=Token)
|
| 130 |
+
async def login(user_credentials: UserLogin):
|
| 131 |
+
"""
|
| 132 |
+
Authenticate a user and return an access token.
|
| 133 |
+
@param user_credentials: UserLogin with email and password
|
| 134 |
+
@return: Token containing a JWT access token and the authenticated UserResponse
|
| 135 |
+
"""
|
| 136 |
+
try:
|
| 137 |
+
# Authenticate user
|
| 138 |
+
user = await authenticate_user(user_credentials.email, user_credentials.password)
|
| 139 |
+
if not user:
|
| 140 |
+
raise HTTPException(
|
| 141 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 142 |
+
detail="Incorrect email or password",
|
| 143 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
# Update last login time
|
| 147 |
+
db = get_database()
|
| 148 |
+
await db.users.update_one(
|
| 149 |
+
{"_id": user.id},
|
| 150 |
+
{"$set": {"last_login": datetime.utcnow()}}
|
| 151 |
+
)
|
| 152 |
+
user.last_login = datetime.utcnow()
|
| 153 |
+
|
| 154 |
+
# Create access token
|
| 155 |
+
access_token_expires = timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 156 |
+
access_token = create_access_token(
|
| 157 |
+
data={"sub": str(user.id)},
|
| 158 |
+
expires_delta=access_token_expires
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
return Token(
|
| 162 |
+
access_token=access_token,
|
| 163 |
+
token_type="bearer",
|
| 164 |
+
user=create_user_response(user)
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
except HTTPException:
|
| 168 |
+
raise
|
| 169 |
+
except Exception as e:
|
| 170 |
+
logger.error(f"Error during login: {e}")
|
| 171 |
+
raise HTTPException(
|
| 172 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 173 |
+
detail="Login failed"
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
@router.get("/me", response_model=UserResponse)
|
| 177 |
+
async def get_current_user_profile(current_user: User = Depends(get_current_active_user)):
|
| 178 |
+
"""
|
| 179 |
+
Retrieve the profile of the currently authenticated user.
|
| 180 |
+
@param current_user: Authenticated user from dependency injection
|
| 181 |
+
@return: UserResponse with the user's profile information
|
| 182 |
+
"""
|
| 183 |
+
return create_user_response(current_user)
|
| 184 |
+
|
| 185 |
+
@router.post("/logout", response_model=MessageResponse)
|
| 186 |
+
async def logout():
|
| 187 |
+
"""
|
| 188 |
+
Log out the current user (client should discard the token).
|
| 189 |
+
@return: MessageResponse with a confirmation message
|
| 190 |
+
"""
|
| 191 |
+
return MessageResponse(message="Successfully logged out")
|
| 192 |
+
|
| 193 |
+
@router.post("/verify-token", response_model=UserResponse)
|
| 194 |
+
async def verify_token(current_user: User = Depends(get_current_active_user)):
|
| 195 |
+
"""
|
| 196 |
+
Validate the caller's JWT and return their profile.
|
| 197 |
+
@param current_user: Authenticated user from dependency injection
|
| 198 |
+
@return: UserResponse with the user's profile information
|
| 199 |
+
"""
|
| 200 |
+
return create_user_response(current_user)
|
| 201 |
+
|
| 202 |
+
@router.post("/me/password", response_model=MessageResponse)
|
| 203 |
+
async def change_password(
|
| 204 |
+
body: ChangePasswordRequest,
|
| 205 |
+
current_user: User = Depends(get_current_active_user),
|
| 206 |
+
):
|
| 207 |
+
"""
|
| 208 |
+
Change the authenticated user's password.
|
| 209 |
+
@param body: ChangePasswordRequest with the current and new passwords
|
| 210 |
+
@param current_user: Authenticated user from dependency injection
|
| 211 |
+
@return: MessageResponse with a confirmation message
|
| 212 |
+
"""
|
| 213 |
+
try:
|
| 214 |
+
if not verify_password(body.current_password, current_user.hashed_password):
|
| 215 |
+
raise HTTPException(
|
| 216 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 217 |
+
detail="Current password is incorrect",
|
| 218 |
+
)
|
| 219 |
+
if len(body.new_password) < 8:
|
| 220 |
+
raise HTTPException(
|
| 221 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 222 |
+
detail="New password must be at least 8 characters",
|
| 223 |
+
)
|
| 224 |
+
db = get_database()
|
| 225 |
+
await db.users.update_one(
|
| 226 |
+
{"_id": current_user.id},
|
| 227 |
+
{"$set": {"hashed_password": get_password_hash(body.new_password)}},
|
| 228 |
+
)
|
| 229 |
+
return MessageResponse(message="Password changed successfully")
|
| 230 |
+
|
| 231 |
+
except HTTPException:
|
| 232 |
+
raise
|
| 233 |
+
except Exception as e:
|
| 234 |
+
logger.error(f"Error during password change: {e}")
|
| 235 |
+
raise HTTPException(
|
| 236 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 237 |
+
detail="Could not change password"
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
@router.patch("/me", response_model=UserResponse)
|
| 241 |
+
async def update_profile(
|
| 242 |
+
body: UserUpdate,
|
| 243 |
+
current_user: User = Depends(get_current_active_user),
|
| 244 |
+
):
|
| 245 |
+
"""Update account fields (name, email, avatar, signup preferences)."""
|
| 246 |
+
try:
|
| 247 |
+
db = get_database()
|
| 248 |
+
updates = {k: v for k, v in body.dict().items() if v is not None}
|
| 249 |
+
if body.firstName is not None:
|
| 250 |
+
updates["firstName"] = body.firstName.strip()
|
| 251 |
+
if body.lastName is not None:
|
| 252 |
+
updates["lastName"] = body.lastName.strip()
|
| 253 |
+
if not updates:
|
| 254 |
+
return create_user_response(current_user)
|
| 255 |
+
|
| 256 |
+
if "email" in updates and updates["email"] != current_user.email:
|
| 257 |
+
existing = await get_user_by_email(updates["email"])
|
| 258 |
+
if existing:
|
| 259 |
+
raise HTTPException(
|
| 260 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 261 |
+
detail="Email already in use",
|
| 262 |
+
)
|
| 263 |
+
|
| 264 |
+
await db.users.update_one({"_id": current_user.id}, {"$set": updates})
|
| 265 |
+
profile_sync: dict = {"updated_at": datetime.utcnow()}
|
| 266 |
+
if body.academicStage is not None:
|
| 267 |
+
profile_sync["knowledge_level"] = body.academicStage
|
| 268 |
+
if body.researchArea is not None:
|
| 269 |
+
profile_sync["timezone"] = body.researchArea
|
| 270 |
+
if len(profile_sync) > 1:
|
| 271 |
+
await db.user_profiles.update_one(
|
| 272 |
+
{"user_id": current_user.id},
|
| 273 |
+
{"$set": profile_sync, "$setOnInsert": {"user_id": current_user.id}},
|
| 274 |
+
upsert=True,
|
| 275 |
+
)
|
| 276 |
+
updated_user = await db.users.find_one({"_id": current_user.id})
|
| 277 |
+
return create_user_response(User(**updated_user))
|
| 278 |
+
|
| 279 |
+
except HTTPException:
|
| 280 |
+
raise
|
| 281 |
+
except Exception as e:
|
| 282 |
+
logger.error(f"Error during profile update: {e}")
|
| 283 |
+
raise HTTPException(
|
| 284 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 285 |
+
detail="Could not update profile"
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
@router.delete("/me", response_model=MessageResponse)
|
| 289 |
+
async def delete_account(
|
| 290 |
+
body: DeleteAccountRequest,
|
| 291 |
+
current_user: User = Depends(get_current_active_user),
|
| 292 |
+
):
|
| 293 |
+
"""
|
| 294 |
+
Permanently delete the authenticated user's account, all chat sessions, and all PhD Canvas data.
|
| 295 |
+
@param body: DeleteAccountRequest with the user's password for confirmation
|
| 296 |
+
@param current_user: Authenticated user from dependency injection
|
| 297 |
+
@return: MessageResponse with a confirmation message
|
| 298 |
+
"""
|
| 299 |
+
try:
|
| 300 |
+
if not verify_password(body.password, current_user.hashed_password):
|
| 301 |
+
raise HTTPException(
|
| 302 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 303 |
+
detail="Incorrect password",
|
| 304 |
+
)
|
| 305 |
+
db = get_database()
|
| 306 |
+
uid = current_user.id
|
| 307 |
+
await db.chat_sessions.delete_many({"user_id": uid})
|
| 308 |
+
await db.phd_canvases.delete_many({"user_id": uid})
|
| 309 |
+
await db.users.delete_one({"_id": uid})
|
| 310 |
+
return MessageResponse(message="Account deleted")
|
| 311 |
+
|
| 312 |
+
except HTTPException:
|
| 313 |
+
raise
|
| 314 |
+
except Exception as e:
|
| 315 |
+
logger.error(f"Error during account deletion: {e}")
|
| 316 |
+
raise HTTPException(
|
| 317 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 318 |
+
detail="Could not delete account"
|
| 319 |
+
)
|
multi_llm_chatbot_backend/app/api/routes/chat.py
ADDED
|
@@ -0,0 +1,504 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import json
|
| 3 |
+
import logging
|
| 4 |
+
import traceback
|
| 5 |
+
from typing import Any, Dict, List, Literal, Optional
|
| 6 |
+
|
| 7 |
+
from bson import ObjectId
|
| 8 |
+
from fastapi import APIRouter, Depends, HTTPException, Request
|
| 9 |
+
from fastapi.responses import StreamingResponse
|
| 10 |
+
from pydantic import BaseModel, Field
|
| 11 |
+
|
| 12 |
+
from app.api.routes.chat_sessions import persist_message
|
| 13 |
+
from app.api.utils import get_or_create_session_for_request_async, load_chat_session_into_context
|
| 14 |
+
from app.core.auth import get_current_active_user
|
| 15 |
+
from app.core.bootstrap import chat_orchestrator
|
| 16 |
+
from app.core.database import get_database
|
| 17 |
+
from app.core.session_manager import get_session_manager
|
| 18 |
+
from app.models.user import User
|
| 19 |
+
from app.api.routes.user_profile import PROFILE_FIELDS, enrich_profile_from_user
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
router = APIRouter()
|
| 24 |
+
session_manager = get_session_manager()
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
async def _attach_user_profile_context(session, user: User) -> None:
|
| 28 |
+
"""Load Mongo profile + signup fields into session for persona prompts."""
|
| 29 |
+
try:
|
| 30 |
+
db = get_database()
|
| 31 |
+
doc = await db.user_profiles.find_one({"user_id": user.id})
|
| 32 |
+
profile = enrich_profile_from_user(doc, user)
|
| 33 |
+
parts = []
|
| 34 |
+
for key in PROFILE_FIELDS:
|
| 35 |
+
val = profile.get(key)
|
| 36 |
+
if val:
|
| 37 |
+
if isinstance(val, list):
|
| 38 |
+
val = ", ".join(str(v) for v in val)
|
| 39 |
+
parts.append(f"{key}: {val}")
|
| 40 |
+
if parts:
|
| 41 |
+
session.user_profile_context = "USER SECURITY PROFILE: " + "; ".join(parts)
|
| 42 |
+
except Exception as prof_err:
|
| 43 |
+
logger.warning(f"Could not load user profile: {prof_err}")
|
| 44 |
+
|
| 45 |
+
# Enhanced data models
|
| 46 |
+
class UserInput(BaseModel):
|
| 47 |
+
user_input: str
|
| 48 |
+
|
| 49 |
+
class ChatMessage(BaseModel):
|
| 50 |
+
user_input: str
|
| 51 |
+
session_id: Optional[str] = None
|
| 52 |
+
chat_session_id: Optional[str] = None # MongoDB chat session ID
|
| 53 |
+
response_length: str = "medium"
|
| 54 |
+
active_advisors: Optional[List[str]] = None
|
| 55 |
+
|
| 56 |
+
class ReplyToAdvisor(BaseModel):
|
| 57 |
+
user_input: str
|
| 58 |
+
advisor_id: str
|
| 59 |
+
original_message_id: str = None
|
| 60 |
+
chat_session_id: Optional[str] = None
|
| 61 |
+
|
| 62 |
+
class PersonaQuery(BaseModel):
|
| 63 |
+
question: str
|
| 64 |
+
persona: str
|
| 65 |
+
|
| 66 |
+
class SwitchChatRequest(BaseModel):
|
| 67 |
+
chat_session_id: str
|
| 68 |
+
|
| 69 |
+
class NewChatRequest(BaseModel):
|
| 70 |
+
title: Optional[str] = "New Chat"
|
| 71 |
+
|
| 72 |
+
ChatStreamEventType = Literal["error", "progress", "clarification", "advisor"]
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class ChatStreamLine(BaseModel):
|
| 76 |
+
"""One NDJSON line from ``/chat-stream``."""
|
| 77 |
+
|
| 78 |
+
type: ChatStreamEventType
|
| 79 |
+
data: Dict[str, Any] = Field(default_factory=dict)
|
| 80 |
+
|
| 81 |
+
def to_ndjson(self) -> str:
|
| 82 |
+
return json.dumps(self.model_dump(mode="json"), ensure_ascii=False) + "\n"
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@router.post("/chat-stream")
|
| 86 |
+
async def chat_stream(
|
| 87 |
+
message: ChatMessage,
|
| 88 |
+
request: Request,
|
| 89 |
+
current_user: User = Depends(get_current_active_user),
|
| 90 |
+
) -> StreamingResponse:
|
| 91 |
+
"""
|
| 92 |
+
Streaming chat endpoint (newline-delimited JSON).
|
| 93 |
+
@param message: ChatMessage containing user input and optional session/chat IDs
|
| 94 |
+
@param request: FastAPI Request object for session management
|
| 95 |
+
@param current_user: Authenticated user from dependency injection
|
| 96 |
+
@return: StreamingResponse that yields ChatStreamLine events as NDJSON
|
| 97 |
+
"""
|
| 98 |
+
|
| 99 |
+
async def _event_generator():
|
| 100 |
+
try:
|
| 101 |
+
# Load or create the in-memory session
|
| 102 |
+
if message.chat_session_id:
|
| 103 |
+
sid = f"chat_{message.chat_session_id}"
|
| 104 |
+
if sid not in session_manager.sessions:
|
| 105 |
+
sid = await get_or_create_session_for_request_async(
|
| 106 |
+
request,
|
| 107 |
+
chat_session_id=message.chat_session_id,
|
| 108 |
+
user_id=str(current_user.id),
|
| 109 |
+
)
|
| 110 |
+
elif len(session_manager.get_session(sid).messages) == 0:
|
| 111 |
+
# Cold start / new worker: hydrate from MongoDB before routing
|
| 112 |
+
loaded = await load_chat_session_into_context(
|
| 113 |
+
message.chat_session_id,
|
| 114 |
+
str(current_user.id),
|
| 115 |
+
)
|
| 116 |
+
if loaded:
|
| 117 |
+
sid = loaded
|
| 118 |
+
else:
|
| 119 |
+
sid = await get_or_create_session_for_request_async(request)
|
| 120 |
+
|
| 121 |
+
session = session_manager.get_session(sid)
|
| 122 |
+
await _attach_user_profile_context(session, current_user)
|
| 123 |
+
|
| 124 |
+
# Append user message to in-memory session and persist to MongoDB
|
| 125 |
+
session.append_message("user", message.user_input)
|
| 126 |
+
if message.chat_session_id:
|
| 127 |
+
await persist_message(message.chat_session_id, {
|
| 128 |
+
"id": str(ObjectId()),
|
| 129 |
+
"type": "user",
|
| 130 |
+
"content": message.user_input,
|
| 131 |
+
})
|
| 132 |
+
|
| 133 |
+
if await chat_orchestrator.needs_clarification_improved(session, message.user_input):
|
| 134 |
+
clar = await chat_orchestrator.generate_contextual_clarification(message.user_input)
|
| 135 |
+
yield ChatStreamLine(
|
| 136 |
+
type="clarification",
|
| 137 |
+
data={
|
| 138 |
+
"message": clar["question"],
|
| 139 |
+
"suggestions": clar["suggestions"],
|
| 140 |
+
},
|
| 141 |
+
).to_ndjson()
|
| 142 |
+
yield ChatStreamLine(
|
| 143 |
+
type="progress",
|
| 144 |
+
data={"phase": "complete"},
|
| 145 |
+
).to_ndjson()
|
| 146 |
+
return
|
| 147 |
+
|
| 148 |
+
# If an enabled tool can handle this query, return its response
|
| 149 |
+
# directly and skip persona generation.
|
| 150 |
+
tool_result = await chat_orchestrator.get_tool_response(message.user_input)
|
| 151 |
+
if tool_result.used_tool:
|
| 152 |
+
session.append_message("orchestrator", tool_result.text)
|
| 153 |
+
yield ChatStreamLine(
|
| 154 |
+
type="advisor",
|
| 155 |
+
data={
|
| 156 |
+
"persona_id": "orchestrator",
|
| 157 |
+
"persona_name": "Orchestrator",
|
| 158 |
+
"content": tool_result.text,
|
| 159 |
+
"used_documents": False,
|
| 160 |
+
"document_chunks_used": 0,
|
| 161 |
+
},
|
| 162 |
+
).to_ndjson()
|
| 163 |
+
yield ChatStreamLine(
|
| 164 |
+
type="progress",
|
| 165 |
+
data={"phase": "complete"},
|
| 166 |
+
).to_ndjson()
|
| 167 |
+
return
|
| 168 |
+
|
| 169 |
+
# Always relevance-rank to the top 3 advisors, scoped to the
|
| 170 |
+
# user's active-advisor selection (the header dropdown) when one is
|
| 171 |
+
# provided. The dropdown filters the candidate pool; the LLM
|
| 172 |
+
# ranking still picks the top 3 from that pool.
|
| 173 |
+
if message.active_advisors:
|
| 174 |
+
candidate_ids = [
|
| 175 |
+
pid for pid in message.active_advisors
|
| 176 |
+
if pid in chat_orchestrator.personas
|
| 177 |
+
]
|
| 178 |
+
else:
|
| 179 |
+
candidate_ids = list(chat_orchestrator.personas.keys())
|
| 180 |
+
top_personas = await chat_orchestrator.get_top_personas(
|
| 181 |
+
session_id=sid,
|
| 182 |
+
k=3,
|
| 183 |
+
candidate_ids=candidate_ids,
|
| 184 |
+
)
|
| 185 |
+
|
| 186 |
+
# Tell the client which advisors will respond so it can show
|
| 187 |
+
# thinking indicators for just those, not the entire active pool.
|
| 188 |
+
yield ChatStreamLine(
|
| 189 |
+
type="progress",
|
| 190 |
+
data={
|
| 191 |
+
"phase": "selected",
|
| 192 |
+
"selected_advisors": top_personas,
|
| 193 |
+
},
|
| 194 |
+
).to_ndjson()
|
| 195 |
+
|
| 196 |
+
done_queue: asyncio.Queue = asyncio.Queue()
|
| 197 |
+
|
| 198 |
+
async def _run(pid: str) -> None:
|
| 199 |
+
try:
|
| 200 |
+
persona = chat_orchestrator.get_persona(pid)
|
| 201 |
+
result = await chat_orchestrator.generate_single_persona_response(
|
| 202 |
+
session, persona,
|
| 203 |
+
message.response_length or "medium",
|
| 204 |
+
)
|
| 205 |
+
session.append_message(pid, result["response"])
|
| 206 |
+
await done_queue.put(result)
|
| 207 |
+
except Exception as e:
|
| 208 |
+
logger.exception(f"chat-stream _run failed for {pid}: {e}")
|
| 209 |
+
failed_persona = chat_orchestrator.get_persona(pid)
|
| 210 |
+
await done_queue.put({
|
| 211 |
+
"persona_id": pid,
|
| 212 |
+
"persona_name": failed_persona.name if failed_persona else pid,
|
| 213 |
+
"response": f"I ran into a technical issue. Please try again. ({e!s})",
|
| 214 |
+
"used_documents": False,
|
| 215 |
+
"document_chunks_used": 0,
|
| 216 |
+
})
|
| 217 |
+
|
| 218 |
+
tasks = [asyncio.create_task(_run(pid)) for pid in top_personas]
|
| 219 |
+
|
| 220 |
+
for _ in range(len(tasks)):
|
| 221 |
+
result = await done_queue.get()
|
| 222 |
+
line = ChatStreamLine(
|
| 223 |
+
type="advisor",
|
| 224 |
+
data={
|
| 225 |
+
"persona_id": result["persona_id"],
|
| 226 |
+
"persona_name": result["persona_name"],
|
| 227 |
+
"content": result["response"],
|
| 228 |
+
"used_documents": result.get("used_documents", False),
|
| 229 |
+
"document_chunks_used": result.get("document_chunks_used", 0),
|
| 230 |
+
},
|
| 231 |
+
)
|
| 232 |
+
yield line.to_ndjson()
|
| 233 |
+
|
| 234 |
+
await asyncio.gather(*tasks, return_exceptions=True)
|
| 235 |
+
|
| 236 |
+
yield ChatStreamLine(
|
| 237 |
+
type="progress",
|
| 238 |
+
data={"phase": "complete"},
|
| 239 |
+
).to_ndjson()
|
| 240 |
+
|
| 241 |
+
except Exception as exc:
|
| 242 |
+
logger.error(f"chat-stream error: {exc}")
|
| 243 |
+
logger.error(traceback.format_exc())
|
| 244 |
+
yield ChatStreamLine(
|
| 245 |
+
type="error",
|
| 246 |
+
data={"detail": str(exc)},
|
| 247 |
+
).to_ndjson()
|
| 248 |
+
|
| 249 |
+
return StreamingResponse(
|
| 250 |
+
_event_generator(),
|
| 251 |
+
media_type="application/x-ndjson",
|
| 252 |
+
headers={
|
| 253 |
+
"Cache-Control": "no-cache",
|
| 254 |
+
"X-Accel-Buffering": "no",
|
| 255 |
+
},
|
| 256 |
+
)
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
@router.post("/switch-chat")
|
| 260 |
+
async def switch_to_chat(
|
| 261 |
+
request: SwitchChatRequest,
|
| 262 |
+
req: Request,
|
| 263 |
+
current_user: User = Depends(get_current_active_user)
|
| 264 |
+
):
|
| 265 |
+
"""
|
| 266 |
+
Switch to an existing chat session and load its context - FIXED VERSION
|
| 267 |
+
Ensures documents are accessible after switching
|
| 268 |
+
"""
|
| 269 |
+
try:
|
| 270 |
+
logger.info(f"Switching to chat session: {request.chat_session_id}")
|
| 271 |
+
|
| 272 |
+
# Load the chat session into memory context with consistent session ID
|
| 273 |
+
memory_session_id = await get_or_create_session_for_request_async(
|
| 274 |
+
req,
|
| 275 |
+
chat_session_id=request.chat_session_id,
|
| 276 |
+
user_id=str(current_user.id)
|
| 277 |
+
)
|
| 278 |
+
|
| 279 |
+
if not memory_session_id:
|
| 280 |
+
raise HTTPException(status_code=404, detail="Chat session not found")
|
| 281 |
+
|
| 282 |
+
logger.info(f"Loaded chat into memory session: {memory_session_id}")
|
| 283 |
+
|
| 284 |
+
# Get the loaded session
|
| 285 |
+
session = session_manager.get_session(memory_session_id)
|
| 286 |
+
|
| 287 |
+
# Verify document access after loading
|
| 288 |
+
rag_stats = session.get_rag_stats()
|
| 289 |
+
logger.info(f"After switch - Session {memory_session_id} has {rag_stats.get('total_documents', 0)} documents")
|
| 290 |
+
|
| 291 |
+
# Get the original MongoDB chat session to retrieve messages in proper format
|
| 292 |
+
db = get_database()
|
| 293 |
+
chat_session = await db.chat_sessions.find_one({
|
| 294 |
+
"_id": ObjectId(request.chat_session_id),
|
| 295 |
+
"user_id": current_user.id,
|
| 296 |
+
"is_active": True
|
| 297 |
+
})
|
| 298 |
+
|
| 299 |
+
if not chat_session:
|
| 300 |
+
raise HTTPException(status_code=404, detail="Chat session not found in database")
|
| 301 |
+
|
| 302 |
+
# Return the messages in the original frontend format from MongoDB
|
| 303 |
+
original_messages = chat_session.get("messages", [])
|
| 304 |
+
|
| 305 |
+
logger.info(f"Switch successful - {len(original_messages)} messages, {rag_stats.get('total_documents', 0)} documents")
|
| 306 |
+
|
| 307 |
+
return {
|
| 308 |
+
"status": "success",
|
| 309 |
+
"memory_session_id": memory_session_id,
|
| 310 |
+
"chat_session_id": request.chat_session_id,
|
| 311 |
+
"message_count": len(original_messages),
|
| 312 |
+
"context": {
|
| 313 |
+
"messages": original_messages, # Return original format messages
|
| 314 |
+
"rag_info": rag_stats
|
| 315 |
+
},
|
| 316 |
+
# Include document access verification
|
| 317 |
+
"document_access": {
|
| 318 |
+
"total_documents": rag_stats.get('total_documents', 0),
|
| 319 |
+
"total_chunks": rag_stats.get('total_chunks', 0),
|
| 320 |
+
"documents": rag_stats.get('documents', []),
|
| 321 |
+
"uploaded_files": session.uploaded_files
|
| 322 |
+
},
|
| 323 |
+
"debug_info": {
|
| 324 |
+
"memory_session_format": memory_session_id,
|
| 325 |
+
"documents_accessible": rag_stats.get('total_documents', 0) > 0,
|
| 326 |
+
"session_loaded": memory_session_id in session_manager.sessions
|
| 327 |
+
}
|
| 328 |
+
}
|
| 329 |
+
|
| 330 |
+
except HTTPException:
|
| 331 |
+
raise
|
| 332 |
+
except Exception as e:
|
| 333 |
+
logger.error(f"Error switching to chat {request.chat_session_id}: {e}")
|
| 334 |
+
import traceback
|
| 335 |
+
logger.error(f"Full traceback: {traceback.format_exc()}")
|
| 336 |
+
raise HTTPException(status_code=500, detail="Failed to switch to chat")
|
| 337 |
+
|
| 338 |
+
@router.post("/new-chat")
|
| 339 |
+
async def create_new_chat(
|
| 340 |
+
request: NewChatRequest,
|
| 341 |
+
req: Request,
|
| 342 |
+
current_user: User = Depends(get_current_active_user)
|
| 343 |
+
):
|
| 344 |
+
"""
|
| 345 |
+
Create a new chat with fresh context
|
| 346 |
+
"""
|
| 347 |
+
try:
|
| 348 |
+
# Create a completely new session (no chat_session_id means fresh context)
|
| 349 |
+
memory_session_id = await get_or_create_session_for_request_async(req)
|
| 350 |
+
|
| 351 |
+
# Ensure the session is completely clean
|
| 352 |
+
session = session_manager.get_session(memory_session_id)
|
| 353 |
+
session.clear_all_data() # This clears both messages and documents
|
| 354 |
+
|
| 355 |
+
return {
|
| 356 |
+
"status": "success",
|
| 357 |
+
"memory_session_id": memory_session_id,
|
| 358 |
+
"message": "New chat created with fresh context",
|
| 359 |
+
"context": {
|
| 360 |
+
"messages": [],
|
| 361 |
+
"rag_info": {"total_documents": 0, "total_chunks": 0}
|
| 362 |
+
}
|
| 363 |
+
}
|
| 364 |
+
|
| 365 |
+
except Exception as e:
|
| 366 |
+
logger.error(f"Error creating new chat: {e}")
|
| 367 |
+
raise HTTPException(status_code=500, detail="Failed to create new chat")
|
| 368 |
+
|
| 369 |
+
@router.post("/chat/{persona_id}")
|
| 370 |
+
async def chat_with_specific_advisor(persona_id: str, input: UserInput, request: Request):
|
| 371 |
+
"""Chat with a specific advisor - UPDATED"""
|
| 372 |
+
try:
|
| 373 |
+
if persona_id not in chat_orchestrator.personas:
|
| 374 |
+
raise HTTPException(status_code=404, detail=f"Persona '{persona_id}' not found")
|
| 375 |
+
|
| 376 |
+
# Use async session management
|
| 377 |
+
session_id = await get_or_create_session_for_request_async(request)
|
| 378 |
+
|
| 379 |
+
result = await chat_orchestrator.chat_with_persona(
|
| 380 |
+
user_input=input.user_input,
|
| 381 |
+
persona_id=persona_id,
|
| 382 |
+
session_id=session_id
|
| 383 |
+
)
|
| 384 |
+
|
| 385 |
+
# Handle response structure
|
| 386 |
+
if result.get("type") == "single_persona_response" and "persona" in result:
|
| 387 |
+
persona_data = result["persona"]
|
| 388 |
+
return {
|
| 389 |
+
"persona": persona_data["persona_name"],
|
| 390 |
+
"persona_id": persona_data["persona_id"],
|
| 391 |
+
"response": persona_data["response"]
|
| 392 |
+
}
|
| 393 |
+
elif "persona_id" in result and "response" in result:
|
| 394 |
+
return {
|
| 395 |
+
"persona": result["persona_name"],
|
| 396 |
+
"persona_id": result["persona_id"],
|
| 397 |
+
"response": result["response"]
|
| 398 |
+
}
|
| 399 |
+
else:
|
| 400 |
+
return {
|
| 401 |
+
"persona": "System",
|
| 402 |
+
"response": "I'm having trouble generating a response right now. Please try again."
|
| 403 |
+
}
|
| 404 |
+
|
| 405 |
+
except HTTPException:
|
| 406 |
+
raise
|
| 407 |
+
except Exception as e:
|
| 408 |
+
logger.error(f"Error in chat_with_specific_advisor: {e}")
|
| 409 |
+
return {
|
| 410 |
+
"persona": "System",
|
| 411 |
+
"response": "I'm having trouble generating a response right now. Please try again."
|
| 412 |
+
}
|
| 413 |
+
|
| 414 |
+
@router.post("/reply-to-advisor")
|
| 415 |
+
async def reply_to_advisor(reply: ReplyToAdvisor, request: Request):
|
| 416 |
+
"""Reply to a specific advisor with proper context - UPDATED"""
|
| 417 |
+
try:
|
| 418 |
+
if reply.advisor_id not in chat_orchestrator.personas:
|
| 419 |
+
raise HTTPException(status_code=404, detail=f"Advisor '{reply.advisor_id}' not found")
|
| 420 |
+
|
| 421 |
+
# Handle session management for existing chats
|
| 422 |
+
if reply.chat_session_id:
|
| 423 |
+
session_id = f"chat_{reply.chat_session_id}"
|
| 424 |
+
else:
|
| 425 |
+
session_id = await get_or_create_session_for_request_async(request)
|
| 426 |
+
|
| 427 |
+
session = session_manager.get_session(session_id)
|
| 428 |
+
|
| 429 |
+
# Find the original message being replied to for context
|
| 430 |
+
original_message = None
|
| 431 |
+
if reply.original_message_id:
|
| 432 |
+
for msg in session.messages:
|
| 433 |
+
if getattr(msg, 'id', None) == reply.original_message_id:
|
| 434 |
+
original_message = msg.content
|
| 435 |
+
break
|
| 436 |
+
|
| 437 |
+
# Create context-aware input
|
| 438 |
+
contextual_input = reply.user_input
|
| 439 |
+
if original_message:
|
| 440 |
+
contextual_input = f"[Replying to your previous message: '{original_message[:100]}...'] {reply.user_input}"
|
| 441 |
+
|
| 442 |
+
result = await chat_orchestrator.chat_with_persona(
|
| 443 |
+
user_input=contextual_input,
|
| 444 |
+
persona_id=reply.advisor_id,
|
| 445 |
+
session_id=session_id
|
| 446 |
+
)
|
| 447 |
+
|
| 448 |
+
# Handle response structure
|
| 449 |
+
if result.get("type") == "single_persona_response" and "persona" in result:
|
| 450 |
+
persona_data = result["persona"]
|
| 451 |
+
return {
|
| 452 |
+
"type": "advisor_reply",
|
| 453 |
+
"persona": persona_data["persona_name"],
|
| 454 |
+
"persona_id": persona_data["persona_id"],
|
| 455 |
+
"response": persona_data["response"],
|
| 456 |
+
"original_message_id": reply.original_message_id
|
| 457 |
+
}
|
| 458 |
+
elif "persona_id" in result and "response" in result:
|
| 459 |
+
return {
|
| 460 |
+
"type": "advisor_reply",
|
| 461 |
+
"persona": result["persona_name"],
|
| 462 |
+
"persona_id": result["persona_id"],
|
| 463 |
+
"response": result["response"],
|
| 464 |
+
"original_message_id": reply.original_message_id
|
| 465 |
+
}
|
| 466 |
+
else:
|
| 467 |
+
return {
|
| 468 |
+
"type": "error",
|
| 469 |
+
"persona": "System",
|
| 470 |
+
"response": "I'm having trouble generating a reply right now. Please try again."
|
| 471 |
+
}
|
| 472 |
+
|
| 473 |
+
except HTTPException:
|
| 474 |
+
raise
|
| 475 |
+
except Exception as e:
|
| 476 |
+
logger.error(f"Error in reply_to_advisor: {e}")
|
| 477 |
+
return {
|
| 478 |
+
"type": "error",
|
| 479 |
+
"persona": "System",
|
| 480 |
+
"response": "I'm having trouble generating a reply right now. Please try again."
|
| 481 |
+
}
|
| 482 |
+
|
| 483 |
+
@router.post("/ask/")
|
| 484 |
+
async def ask_question(query: PersonaQuery, request: Request):
|
| 485 |
+
"""Ask question - UPDATED"""
|
| 486 |
+
try:
|
| 487 |
+
session_id = await get_or_create_session_for_request_async(request)
|
| 488 |
+
|
| 489 |
+
result = await chat_orchestrator.chat_with_persona(
|
| 490 |
+
user_input=query.question,
|
| 491 |
+
persona_id=query.persona,
|
| 492 |
+
session_id=session_id
|
| 493 |
+
)
|
| 494 |
+
|
| 495 |
+
if result["type"] == "single_persona_response":
|
| 496 |
+
response_text = result["persona"]["response"]
|
| 497 |
+
else:
|
| 498 |
+
response_text = result.get("message", "I'm having trouble responding right now.")
|
| 499 |
+
|
| 500 |
+
return {"response": response_text}
|
| 501 |
+
|
| 502 |
+
except Exception as e:
|
| 503 |
+
logger.error(f"Error in ask endpoint: {str(e)}")
|
| 504 |
+
return {"response": "I encountered an error. Please try again."}
|
multi_llm_chatbot_backend/app/api/routes/chat_sessions.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Depends, status
|
| 2 |
+
from typing import List, Optional
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
from bson import ObjectId
|
| 5 |
+
from app.models.user import User, ChatSession, ChatSessionResponse
|
| 6 |
+
from app.core.auth import get_current_active_user
|
| 7 |
+
from app.core.database import get_database
|
| 8 |
+
from pydantic import BaseModel
|
| 9 |
+
import logging
|
| 10 |
+
|
| 11 |
+
logger = logging.getLogger(__name__)
|
| 12 |
+
|
| 13 |
+
router = APIRouter()
|
| 14 |
+
|
| 15 |
+
class CreateChatSessionRequest(BaseModel):
|
| 16 |
+
title: str
|
| 17 |
+
|
| 18 |
+
class UpdateChatSessionRequest(BaseModel):
|
| 19 |
+
title: Optional[str] = None
|
| 20 |
+
messages: Optional[List[dict]] = None
|
| 21 |
+
|
| 22 |
+
class SaveMessageRequest(BaseModel):
|
| 23 |
+
session_id: str
|
| 24 |
+
message: dict
|
| 25 |
+
|
| 26 |
+
async def persist_message(session_id: str, message: dict):
|
| 27 |
+
"""Write a single message to a MongoDB chat session."""
|
| 28 |
+
db = get_database()
|
| 29 |
+
msg = message.copy()
|
| 30 |
+
if "timestamp" not in msg:
|
| 31 |
+
msg["timestamp"] = datetime.utcnow().isoformat()
|
| 32 |
+
await db.chat_sessions.update_one(
|
| 33 |
+
{"_id": ObjectId(session_id)},
|
| 34 |
+
{
|
| 35 |
+
"$push": {"messages": msg},
|
| 36 |
+
"$set": {"updated_at": datetime.utcnow()}
|
| 37 |
+
}
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
@router.post("/chat-sessions", response_model=dict)
|
| 42 |
+
async def create_chat_session(
|
| 43 |
+
request: CreateChatSessionRequest,
|
| 44 |
+
current_user: User = Depends(get_current_active_user)
|
| 45 |
+
):
|
| 46 |
+
"""Create a new chat session for the user"""
|
| 47 |
+
try:
|
| 48 |
+
db = get_database()
|
| 49 |
+
|
| 50 |
+
session = ChatSession(
|
| 51 |
+
user_id=current_user.id,
|
| 52 |
+
title=request.title,
|
| 53 |
+
messages=[],
|
| 54 |
+
created_at=datetime.utcnow(),
|
| 55 |
+
updated_at=datetime.utcnow()
|
| 56 |
+
)
|
| 57 |
+
|
| 58 |
+
result = await db.chat_sessions.insert_one(session.dict(by_alias=True))
|
| 59 |
+
session.id = result.inserted_id
|
| 60 |
+
|
| 61 |
+
return {
|
| 62 |
+
"id": str(session.id),
|
| 63 |
+
"title": session.title,
|
| 64 |
+
"created_at": session.created_at,
|
| 65 |
+
"updated_at": session.updated_at,
|
| 66 |
+
"message_count": 0
|
| 67 |
+
}
|
| 68 |
+
|
| 69 |
+
except Exception as e:
|
| 70 |
+
logger.error(f"Error creating chat session: {e}")
|
| 71 |
+
raise HTTPException(
|
| 72 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 73 |
+
detail="Could not create chat session"
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
@router.get("/chat-sessions", response_model=List[ChatSessionResponse])
|
| 78 |
+
async def get_user_chat_sessions(
|
| 79 |
+
current_user: User = Depends(get_current_active_user),
|
| 80 |
+
limit: int = 50,
|
| 81 |
+
skip: int = 0
|
| 82 |
+
):
|
| 83 |
+
"""Get all chat sessions for the current user"""
|
| 84 |
+
try:
|
| 85 |
+
db = get_database()
|
| 86 |
+
|
| 87 |
+
cursor = db.chat_sessions.find(
|
| 88 |
+
{"user_id": current_user.id, "is_active": True}
|
| 89 |
+
).sort("updated_at", -1).skip(skip).limit(limit)
|
| 90 |
+
|
| 91 |
+
sessions = []
|
| 92 |
+
async for session_data in cursor:
|
| 93 |
+
sessions.append(ChatSessionResponse(
|
| 94 |
+
id=str(session_data["_id"]),
|
| 95 |
+
title=session_data["title"],
|
| 96 |
+
created_at=session_data["created_at"],
|
| 97 |
+
updated_at=session_data["updated_at"],
|
| 98 |
+
message_count=len(session_data.get("messages", []))
|
| 99 |
+
))
|
| 100 |
+
|
| 101 |
+
return sessions
|
| 102 |
+
|
| 103 |
+
except Exception as e:
|
| 104 |
+
logger.error(f"Error fetching chat sessions: {e}")
|
| 105 |
+
raise HTTPException(
|
| 106 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 107 |
+
detail="Could not fetch chat sessions"
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
@router.get("/chat-sessions/count")
|
| 113 |
+
async def get_chat_sessions_count(
|
| 114 |
+
current_user: User = Depends(get_current_active_user)
|
| 115 |
+
):
|
| 116 |
+
"""Get count of user's chat sessions"""
|
| 117 |
+
try:
|
| 118 |
+
db = get_database()
|
| 119 |
+
user_object_id = ObjectId(str(current_user.id))
|
| 120 |
+
|
| 121 |
+
count = await db.chat_sessions.count_documents({
|
| 122 |
+
"user_id": user_object_id,
|
| 123 |
+
"is_active": {"$ne": False},
|
| 124 |
+
"deleted_at": {"$exists": False}
|
| 125 |
+
})
|
| 126 |
+
|
| 127 |
+
return {"count": count}
|
| 128 |
+
|
| 129 |
+
except Exception as e:
|
| 130 |
+
logger.error(f"Error counting chat sessions for user {current_user.id}: {e}")
|
| 131 |
+
raise HTTPException(
|
| 132 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 133 |
+
detail="Failed to count chat sessions"
|
| 134 |
+
)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@router.get("/chat-sessions/{session_id}")
|
| 139 |
+
async def get_chat_session(
|
| 140 |
+
session_id: str,
|
| 141 |
+
current_user: User = Depends(get_current_active_user)
|
| 142 |
+
):
|
| 143 |
+
"""Get a specific chat session with all messages"""
|
| 144 |
+
try:
|
| 145 |
+
db = get_database()
|
| 146 |
+
|
| 147 |
+
session_data = await db.chat_sessions.find_one({
|
| 148 |
+
"_id": ObjectId(session_id),
|
| 149 |
+
"user_id": current_user.id,
|
| 150 |
+
"is_active": True
|
| 151 |
+
})
|
| 152 |
+
|
| 153 |
+
if not session_data:
|
| 154 |
+
raise HTTPException(
|
| 155 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 156 |
+
detail="Chat session not found"
|
| 157 |
+
)
|
| 158 |
+
|
| 159 |
+
return {
|
| 160 |
+
"id": str(session_data["_id"]),
|
| 161 |
+
"title": session_data["title"],
|
| 162 |
+
"messages": session_data.get("messages", []),
|
| 163 |
+
"created_at": session_data["created_at"],
|
| 164 |
+
"updated_at": session_data["updated_at"]
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
except HTTPException:
|
| 168 |
+
raise
|
| 169 |
+
except Exception as e:
|
| 170 |
+
logger.error(f"Error fetching chat session: {e}")
|
| 171 |
+
raise HTTPException(
|
| 172 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 173 |
+
detail="Could not fetch chat session"
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
@router.put("/chat-sessions/{session_id}")
|
| 177 |
+
async def update_chat_session(
|
| 178 |
+
session_id: str,
|
| 179 |
+
request: UpdateChatSessionRequest,
|
| 180 |
+
current_user: User = Depends(get_current_active_user)
|
| 181 |
+
):
|
| 182 |
+
"""Update a chat session (title or messages)"""
|
| 183 |
+
try:
|
| 184 |
+
db = get_database()
|
| 185 |
+
|
| 186 |
+
# Verify session belongs to user
|
| 187 |
+
session_data = await db.chat_sessions.find_one({
|
| 188 |
+
"_id": ObjectId(session_id),
|
| 189 |
+
"user_id": current_user.id,
|
| 190 |
+
"is_active": True
|
| 191 |
+
})
|
| 192 |
+
|
| 193 |
+
if not session_data:
|
| 194 |
+
raise HTTPException(
|
| 195 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 196 |
+
detail="Chat session not found"
|
| 197 |
+
)
|
| 198 |
+
|
| 199 |
+
update_data = {"updated_at": datetime.utcnow()}
|
| 200 |
+
|
| 201 |
+
if request.title is not None:
|
| 202 |
+
update_data["title"] = request.title
|
| 203 |
+
|
| 204 |
+
if request.messages is not None:
|
| 205 |
+
update_data["messages"] = request.messages
|
| 206 |
+
|
| 207 |
+
await db.chat_sessions.update_one(
|
| 208 |
+
{"_id": ObjectId(session_id)},
|
| 209 |
+
{"$set": update_data}
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
return {"message": "Chat session updated successfully"}
|
| 213 |
+
|
| 214 |
+
except HTTPException:
|
| 215 |
+
raise
|
| 216 |
+
except Exception as e:
|
| 217 |
+
logger.error(f"Error updating chat session: {e}")
|
| 218 |
+
raise HTTPException(
|
| 219 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 220 |
+
detail="Could not update chat session"
|
| 221 |
+
)
|
| 222 |
+
|
| 223 |
+
@router.post("/chat-sessions/{session_id}/messages")
|
| 224 |
+
async def save_message_to_session(
|
| 225 |
+
session_id: str,
|
| 226 |
+
request: SaveMessageRequest,
|
| 227 |
+
current_user: User = Depends(get_current_active_user)
|
| 228 |
+
):
|
| 229 |
+
"""Add a message to a chat session"""
|
| 230 |
+
try:
|
| 231 |
+
db = get_database()
|
| 232 |
+
|
| 233 |
+
# Verify session belongs to user
|
| 234 |
+
session_data = await db.chat_sessions.find_one({
|
| 235 |
+
"_id": ObjectId(session_id),
|
| 236 |
+
"user_id": current_user.id,
|
| 237 |
+
"is_active": True
|
| 238 |
+
})
|
| 239 |
+
|
| 240 |
+
if not session_data:
|
| 241 |
+
raise HTTPException(
|
| 242 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 243 |
+
detail="Chat session not found"
|
| 244 |
+
)
|
| 245 |
+
|
| 246 |
+
await persist_message(session_id, request.message)
|
| 247 |
+
|
| 248 |
+
return {"message": "Message saved successfully"}
|
| 249 |
+
|
| 250 |
+
except HTTPException:
|
| 251 |
+
raise
|
| 252 |
+
except Exception as e:
|
| 253 |
+
logger.error(f"Error saving message: {e}")
|
| 254 |
+
raise HTTPException(
|
| 255 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 256 |
+
detail="Could not save message"
|
| 257 |
+
)
|
| 258 |
+
|
| 259 |
+
|
| 260 |
+
|
| 261 |
+
@router.delete("/chat-sessions/{session_id}")
|
| 262 |
+
async def delete_chat_session(
|
| 263 |
+
session_id: str,
|
| 264 |
+
current_user: User = Depends(get_current_active_user)
|
| 265 |
+
):
|
| 266 |
+
"""Delete a chat session (soft delete)"""
|
| 267 |
+
try:
|
| 268 |
+
db = get_database()
|
| 269 |
+
|
| 270 |
+
result = await db.chat_sessions.update_one(
|
| 271 |
+
{
|
| 272 |
+
"_id": ObjectId(session_id),
|
| 273 |
+
"user_id": current_user.id
|
| 274 |
+
},
|
| 275 |
+
{"$set": {"is_active": False, "updated_at": datetime.utcnow()}}
|
| 276 |
+
)
|
| 277 |
+
|
| 278 |
+
if result.matched_count == 0:
|
| 279 |
+
raise HTTPException(
|
| 280 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 281 |
+
detail="Chat session not found"
|
| 282 |
+
)
|
| 283 |
+
|
| 284 |
+
return {"message": "Chat session deleted successfully"}
|
| 285 |
+
|
| 286 |
+
except HTTPException:
|
| 287 |
+
raise
|
| 288 |
+
except Exception as e:
|
| 289 |
+
logger.error(f"Error deleting chat session: {e}")
|
| 290 |
+
raise HTTPException(
|
| 291 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 292 |
+
detail="Could not delete chat session"
|
| 293 |
+
)
|
multi_llm_chatbot_backend/app/api/routes/debug.py
ADDED
|
@@ -0,0 +1,102 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Request, Query
|
| 2 |
+
from app.core.session_manager import get_session_manager
|
| 3 |
+
from app.core.rag_manager import get_rag_manager
|
| 4 |
+
from app.core.bootstrap import chat_orchestrator
|
| 5 |
+
import logging
|
| 6 |
+
|
| 7 |
+
from app.api.old_routes import get_or_create_session_for_request
|
| 8 |
+
|
| 9 |
+
logger = logging.getLogger(__name__)
|
| 10 |
+
|
| 11 |
+
router = APIRouter()
|
| 12 |
+
|
| 13 |
+
session_manager = get_session_manager()
|
| 14 |
+
|
| 15 |
+
@router.get("/debug/personas")
|
| 16 |
+
async def debug_personas(request: Request):
|
| 17 |
+
try:
|
| 18 |
+
session_id = get_or_create_session_for_request(request)
|
| 19 |
+
session = session_manager.get_session(session_id)
|
| 20 |
+
rag_manager = get_rag_manager()
|
| 21 |
+
rag_stats = rag_manager.get_document_stats(session_id)
|
| 22 |
+
|
| 23 |
+
return {
|
| 24 |
+
"personas": {
|
| 25 |
+
pid: {
|
| 26 |
+
"name": persona.name,
|
| 27 |
+
"prompt": persona.system_prompt[:100] + "...",
|
| 28 |
+
"retrieval_keywords": chat_orchestrator._get_persona_context_keywords(pid)
|
| 29 |
+
} for pid, persona in chat_orchestrator.personas.items()
|
| 30 |
+
},
|
| 31 |
+
"session_info": {
|
| 32 |
+
"context_length": len(session.messages),
|
| 33 |
+
"uploaded_files": session.uploaded_files,
|
| 34 |
+
"rag_stats": rag_stats
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
except Exception as e:
|
| 38 |
+
logger.error(f"Error in debug endpoint: {str(e)}")
|
| 39 |
+
return {
|
| 40 |
+
"personas": {},
|
| 41 |
+
"session_info": {"context_length": 0},
|
| 42 |
+
"error": str(e)
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
@router.get("/debug/ranked-personas")
|
| 46 |
+
async def get_ranked_personas(request: Request, k: int = Query(3, ge=1, le=10)):
|
| 47 |
+
try:
|
| 48 |
+
session_id = get_or_create_session_for_request(request)
|
| 49 |
+
top_personas = await chat_orchestrator.get_top_personas(session_id=session_id, k=k)
|
| 50 |
+
return {
|
| 51 |
+
"ranked_personas": top_personas,
|
| 52 |
+
"available_personas": list(chat_orchestrator.personas.keys()),
|
| 53 |
+
"session_id": session_id
|
| 54 |
+
}
|
| 55 |
+
except Exception as e:
|
| 56 |
+
logger.error(f"Error in /debug/ranked-personas: {e}")
|
| 57 |
+
return {
|
| 58 |
+
"ranked_personas": [],
|
| 59 |
+
"error": str(e)
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
@router.get("/debug/rag-status")
|
| 63 |
+
async def debug_rag_status(request: Request):
|
| 64 |
+
try:
|
| 65 |
+
session_id = get_or_create_session_for_request(request)
|
| 66 |
+
rag_manager = get_rag_manager()
|
| 67 |
+
session_stats = session_manager.get_session_stats(session_id)
|
| 68 |
+
|
| 69 |
+
test_search = rag_manager.search_documents(
|
| 70 |
+
query="test methodology research",
|
| 71 |
+
session_id=session_id,
|
| 72 |
+
persona_context="",
|
| 73 |
+
n_results=3
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
return {
|
| 77 |
+
"rag_manager_healthy": True,
|
| 78 |
+
"session_id": session_id,
|
| 79 |
+
"session_stats": session_stats.get("rag_stats", {}),
|
| 80 |
+
"test_search_results": len(test_search),
|
| 81 |
+
"test_search_details": [
|
| 82 |
+
{
|
| 83 |
+
"relevance": chunk.get("relevance_score", 0),
|
| 84 |
+
"distance": chunk.get("distance", "unknown"),
|
| 85 |
+
"text_length": len(chunk.get("text", "")),
|
| 86 |
+
"filename": chunk.get("metadata", {}).get("filename", "unknown")
|
| 87 |
+
}
|
| 88 |
+
for chunk in test_search[:3]
|
| 89 |
+
],
|
| 90 |
+
"persona_keywords": {
|
| 91 |
+
pid: chat_orchestrator._get_persona_context_keywords(pid)
|
| 92 |
+
for pid in chat_orchestrator.personas.keys()
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
except Exception as e:
|
| 97 |
+
logger.error(f"Error in RAG debug: {str(e)}")
|
| 98 |
+
return {
|
| 99 |
+
"rag_manager_healthy": False,
|
| 100 |
+
"error": str(e),
|
| 101 |
+
"session_id": session_id if 'session_id' in locals() else "unknown"
|
| 102 |
+
}
|
multi_llm_chatbot_backend/app/api/routes/documents.py
ADDED
|
@@ -0,0 +1,530 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Request, HTTPException, UploadFile, File, Body, Depends
|
| 2 |
+
from fastapi import Query
|
| 3 |
+
from app.utils.document_extractor import extract_text_from_file
|
| 4 |
+
from app.core.session_manager import get_session_manager
|
| 5 |
+
from app.core.rag_manager import get_rag_manager
|
| 6 |
+
from app.api.utils import get_or_create_session_for_request_async
|
| 7 |
+
from fastapi.responses import StreamingResponse
|
| 8 |
+
from app.utils.chat_summary import generate_summary_from_messages, parse_summary_to_blocks, format_summary_for_text_export
|
| 9 |
+
from app.utils.file_export import prepare_export_response, generate_pdf_file_from_blocks
|
| 10 |
+
from app.core.session_manager import get_session_manager
|
| 11 |
+
from app.core.bootstrap import chat_orchestrator
|
| 12 |
+
from app.core.auth import get_current_active_user
|
| 13 |
+
from app.core.database import get_database
|
| 14 |
+
from app.models.user import User
|
| 15 |
+
from bson import ObjectId
|
| 16 |
+
import logging
|
| 17 |
+
import re
|
| 18 |
+
from html import unescape
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
router = APIRouter()
|
| 24 |
+
|
| 25 |
+
session_manager = get_session_manager()
|
| 26 |
+
get_rag_manager = get_rag_manager
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
def sanitize_html_content(content):
|
| 30 |
+
"""
|
| 31 |
+
Clean up HTML content by removing or fixing malformed tags.
|
| 32 |
+
This prevents PDF export errors caused by invalid HTML structure.
|
| 33 |
+
"""
|
| 34 |
+
if not content:
|
| 35 |
+
return content
|
| 36 |
+
|
| 37 |
+
try:
|
| 38 |
+
logger.debug(f"Sanitizing content (first 200 chars): {content[:200]}")
|
| 39 |
+
|
| 40 |
+
# First, unescape HTML entities
|
| 41 |
+
content = unescape(content)
|
| 42 |
+
|
| 43 |
+
# More aggressive approach: Strip ALL HTML tags first, then apply simple formatting
|
| 44 |
+
# This prevents malformed HTML from causing issues
|
| 45 |
+
|
| 46 |
+
# Remove all HTML tags completely (most aggressive approach)
|
| 47 |
+
content = re.sub(r'<[^>]*>', '', content)
|
| 48 |
+
|
| 49 |
+
# Clean up multiple spaces and normalize whitespace
|
| 50 |
+
content = re.sub(r'\s+', ' ', content)
|
| 51 |
+
content = content.strip()
|
| 52 |
+
|
| 53 |
+
# Remove any remaining HTML entities that might have been missed
|
| 54 |
+
content = re.sub(r'&[a-zA-Z0-9#]+;', '', content)
|
| 55 |
+
|
| 56 |
+
# Remove any remaining angle brackets that might cause issues
|
| 57 |
+
content = content.replace('<', '').replace('>', '')
|
| 58 |
+
|
| 59 |
+
logger.debug(f"Sanitized content (first 200 chars): {content[:200]}")
|
| 60 |
+
return content
|
| 61 |
+
|
| 62 |
+
except Exception as e:
|
| 63 |
+
logger.error(f"Error sanitizing HTML content: {str(e)}")
|
| 64 |
+
# Ultra-fallback: return only alphanumeric and basic punctuation
|
| 65 |
+
try:
|
| 66 |
+
import string
|
| 67 |
+
allowed_chars = string.ascii_letters + string.digits + string.punctuation + ' \n\r\t'
|
| 68 |
+
cleaned = ''.join(c for c in content if c in allowed_chars)
|
| 69 |
+
return re.sub(r'\s+', ' ', cleaned).strip()
|
| 70 |
+
except:
|
| 71 |
+
return "Content could not be sanitized for export"
|
| 72 |
+
|
| 73 |
+
def convert_messages_for_export(messages):
|
| 74 |
+
"""
|
| 75 |
+
Convert stored message format to export-compatible format.
|
| 76 |
+
Stored format uses 'type', export functions expect 'role' and specific structure.
|
| 77 |
+
"""
|
| 78 |
+
converted_messages = []
|
| 79 |
+
|
| 80 |
+
for i, msg in enumerate(messages):
|
| 81 |
+
try:
|
| 82 |
+
# Get and sanitize content
|
| 83 |
+
raw_content = msg.get('content', '')
|
| 84 |
+
sanitized_content = sanitize_html_content(raw_content)
|
| 85 |
+
|
| 86 |
+
# Debug logging for problematic content
|
| 87 |
+
if i < 5 or '<' in raw_content or '>' in raw_content: # Log first few messages and any with HTML
|
| 88 |
+
logger.debug(f"Message {i}: Original length: {len(raw_content)}, Sanitized length: {len(sanitized_content)}")
|
| 89 |
+
if raw_content != sanitized_content:
|
| 90 |
+
logger.debug(f"Content changed during sanitization for message {msg.get('id', 'unknown')}")
|
| 91 |
+
|
| 92 |
+
# Create base converted message
|
| 93 |
+
converted_msg = {
|
| 94 |
+
'id': msg.get('id', 'unknown'),
|
| 95 |
+
'timestamp': msg.get('timestamp', ''),
|
| 96 |
+
'content': sanitized_content,
|
| 97 |
+
}
|
| 98 |
+
|
| 99 |
+
# Convert type to role and handle different message types
|
| 100 |
+
msg_type = msg.get('type', 'unknown')
|
| 101 |
+
|
| 102 |
+
if msg_type == 'user':
|
| 103 |
+
converted_msg['role'] = 'user'
|
| 104 |
+
# Add reply context if present
|
| 105 |
+
if 'replyTo' in msg:
|
| 106 |
+
reply_to = msg['replyTo']
|
| 107 |
+
converted_msg['content'] = f"[Reply to {reply_to.get('advisorName', 'advisor')}] {converted_msg['content']}"
|
| 108 |
+
|
| 109 |
+
elif msg_type == 'advisor':
|
| 110 |
+
converted_msg['role'] = 'assistant'
|
| 111 |
+
# Include advisor information
|
| 112 |
+
advisor_name = msg.get('advisorName', msg.get('persona', 'Advisor'))
|
| 113 |
+
converted_msg['advisor_name'] = advisor_name
|
| 114 |
+
converted_msg['advisor_id'] = msg.get('advisorId', msg.get('persona_id', 'unknown'))
|
| 115 |
+
|
| 116 |
+
# Mark special response types
|
| 117 |
+
if msg.get('isReply'):
|
| 118 |
+
converted_msg['content'] = f"[{advisor_name} replies] {converted_msg['content']}"
|
| 119 |
+
elif msg.get('isExpansion'):
|
| 120 |
+
converted_msg['content'] = f"[{advisor_name} expands] {converted_msg['content']}"
|
| 121 |
+
else:
|
| 122 |
+
converted_msg['content'] = f"[{advisor_name}] {converted_msg['content']}"
|
| 123 |
+
|
| 124 |
+
elif msg_type == 'system':
|
| 125 |
+
converted_msg['role'] = 'system'
|
| 126 |
+
|
| 127 |
+
elif msg_type == 'document_upload':
|
| 128 |
+
converted_msg['role'] = 'system'
|
| 129 |
+
converted_msg['content'] = f"📄 {converted_msg['content']}"
|
| 130 |
+
|
| 131 |
+
elif msg_type == 'error':
|
| 132 |
+
converted_msg['role'] = 'system'
|
| 133 |
+
converted_msg['content'] = f"❌ Error: {converted_msg['content']}"
|
| 134 |
+
|
| 135 |
+
else:
|
| 136 |
+
# Unknown type, treat as system message
|
| 137 |
+
converted_msg['role'] = 'system'
|
| 138 |
+
converted_msg['content'] = f"[{msg_type}] {converted_msg['content']}"
|
| 139 |
+
|
| 140 |
+
converted_messages.append(converted_msg)
|
| 141 |
+
|
| 142 |
+
except Exception as e:
|
| 143 |
+
logger.error(f"Error converting message {msg.get('id', 'unknown')}: {str(e)}")
|
| 144 |
+
# Add a fallback message to maintain conversation flow
|
| 145 |
+
converted_messages.append({
|
| 146 |
+
'id': msg.get('id', 'unknown'),
|
| 147 |
+
'role': 'system',
|
| 148 |
+
'content': f"[Message conversion error: {str(e)}]",
|
| 149 |
+
'timestamp': msg.get('timestamp', '')
|
| 150 |
+
})
|
| 151 |
+
|
| 152 |
+
logger.info(f"Converted {len(messages)} messages for export")
|
| 153 |
+
return converted_messages
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
@router.post("/upload-document")
|
| 157 |
+
async def upload_document(
|
| 158 |
+
file: UploadFile = File(...),
|
| 159 |
+
request: Request = None,
|
| 160 |
+
chat_session_id: str = Query(None, description="Chat session ID if uploading to specific chat"),
|
| 161 |
+
current_user: User = Depends(get_current_active_user) # ADDED: Require authentication
|
| 162 |
+
):
|
| 163 |
+
try:
|
| 164 |
+
if chat_session_id:
|
| 165 |
+
# If uploading to a specific chat, use chat_{id} format
|
| 166 |
+
session_id = f"chat_{chat_session_id}"
|
| 167 |
+
logger.info(f"Uploading document to specific chat session: {session_id}")
|
| 168 |
+
else:
|
| 169 |
+
# For new/temporary chats, use regular session management
|
| 170 |
+
session_id = await get_or_create_session_for_request_async(request)
|
| 171 |
+
logger.info(f"Uploading document to new session: {session_id}")
|
| 172 |
+
|
| 173 |
+
# Add debug logging to track session IDs
|
| 174 |
+
logger.info(f"Document upload - chat_session_id parameter: {chat_session_id}")
|
| 175 |
+
logger.info(f"Document upload - final session_id: {session_id}")
|
| 176 |
+
logger.info(f"Document upload - user_id: {current_user.id}")
|
| 177 |
+
|
| 178 |
+
session = session_manager.get_session(session_id)
|
| 179 |
+
|
| 180 |
+
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10MB
|
| 181 |
+
if file.size and file.size > MAX_FILE_SIZE:
|
| 182 |
+
raise HTTPException(status_code=413, detail="File size exceeds 10MB limit")
|
| 183 |
+
|
| 184 |
+
file_bytes = await file.read()
|
| 185 |
+
content = extract_text_from_file(file_bytes, file.content_type)
|
| 186 |
+
if not content.strip():
|
| 187 |
+
raise HTTPException(status_code=400, detail="Document is empty or unreadable.")
|
| 188 |
+
|
| 189 |
+
rag_manager = get_rag_manager()
|
| 190 |
+
file_type_map = {
|
| 191 |
+
"application/pdf": "pdf",
|
| 192 |
+
"application/vnd.openxmlformats-officedocument.wordprocessingml.document": "docx",
|
| 193 |
+
"text/plain": "txt"
|
| 194 |
+
}
|
| 195 |
+
file_type = file_type_map.get(file.content_type, "unknown")
|
| 196 |
+
|
| 197 |
+
# Pass the consistent session_id to RAG manager
|
| 198 |
+
logger.info(f"Adding document {file.filename} to session {session_id}")
|
| 199 |
+
rag_result = rag_manager.add_document(
|
| 200 |
+
content=content,
|
| 201 |
+
filename=file.filename,
|
| 202 |
+
session_id=session_id,
|
| 203 |
+
file_type=file_type
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
if not rag_result["success"]:
|
| 207 |
+
raise HTTPException(status_code=500, detail=f"Failed to process document: {rag_result.get('error', 'Unknown error')}")
|
| 208 |
+
|
| 209 |
+
session.uploaded_files.append(file.filename)
|
| 210 |
+
session.total_upload_size += len(file_bytes)
|
| 211 |
+
|
| 212 |
+
doc_metadata = rag_result.get("document_metadata", {})
|
| 213 |
+
doc_title = doc_metadata.get("title", file.filename)
|
| 214 |
+
|
| 215 |
+
session.append_message(
|
| 216 |
+
"system",
|
| 217 |
+
f"Document uploaded: '{doc_title}' ({file.filename}) - {rag_result['chunks_created']} sections processed, ~{rag_result['total_tokens']} tokens analyzed. You can now ask questions about this document by referencing it by name."
|
| 218 |
+
)
|
| 219 |
+
|
| 220 |
+
# Return session info for frontend tracking
|
| 221 |
+
return {
|
| 222 |
+
"message": f"Document '{file.filename}' uploaded and processed successfully.",
|
| 223 |
+
"filename": file.filename,
|
| 224 |
+
"document_title": doc_title,
|
| 225 |
+
"chunks_created": rag_result['chunks_created'],
|
| 226 |
+
"total_tokens": rag_result['total_tokens'],
|
| 227 |
+
"file_type": file_type,
|
| 228 |
+
"can_reference_by_name": True,
|
| 229 |
+
"session_id": session_id,
|
| 230 |
+
"chat_session_id": chat_session_id,
|
| 231 |
+
"user_id": str(current_user.id) # ADDED: Include user ID for debugging
|
| 232 |
+
}
|
| 233 |
+
|
| 234 |
+
except HTTPException:
|
| 235 |
+
raise
|
| 236 |
+
except Exception as e:
|
| 237 |
+
logger.error(f"Error processing document upload: {str(e)}")
|
| 238 |
+
raise HTTPException(status_code=500, detail=f"Error processing document: {str(e)}")
|
| 239 |
+
|
| 240 |
+
|
| 241 |
+
@router.post("/search-documents")
|
| 242 |
+
async def search_documents(request: Request, query: str = Body(..., embed=True), persona: str = Body("", embed=True)):
|
| 243 |
+
try:
|
| 244 |
+
session_id = await get_or_create_session_for_request_async(request) # FIXED: Added await
|
| 245 |
+
rag_manager = get_rag_manager()
|
| 246 |
+
|
| 247 |
+
persona_contexts = {
|
| 248 |
+
"methodologist": "methodology research design analysis",
|
| 249 |
+
"theorist": "theory theoretical framework conceptual",
|
| 250 |
+
"pragmatist": "practical application implementation"
|
| 251 |
+
}
|
| 252 |
+
persona_context = persona_contexts.get(persona, "")
|
| 253 |
+
|
| 254 |
+
results = rag_manager.search_documents(
|
| 255 |
+
query=query,
|
| 256 |
+
session_id=session_id,
|
| 257 |
+
persona_context=persona_context,
|
| 258 |
+
n_results=5
|
| 259 |
+
)
|
| 260 |
+
|
| 261 |
+
return {
|
| 262 |
+
"query": query,
|
| 263 |
+
"persona_filter": persona,
|
| 264 |
+
"results_count": len(results),
|
| 265 |
+
"results": results
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
except Exception as e:
|
| 269 |
+
logger.error(f"Error searching documents: {str(e)}")
|
| 270 |
+
return {"query": query, "results_count": 0, "results": [], "error": str(e)}
|
| 271 |
+
|
| 272 |
+
|
| 273 |
+
@router.get("/document-stats")
|
| 274 |
+
async def get_document_stats(request: Request):
|
| 275 |
+
try:
|
| 276 |
+
session_id = await get_or_create_session_for_request_async(request) # FIXED: Added await
|
| 277 |
+
rag_manager = get_rag_manager()
|
| 278 |
+
return rag_manager.get_document_stats(session_id)
|
| 279 |
+
except Exception as e:
|
| 280 |
+
logger.error(f"Error getting document stats: {str(e)}")
|
| 281 |
+
return {"total_chunks": 0, "total_documents": 0, "documents": []}
|
| 282 |
+
|
| 283 |
+
|
| 284 |
+
@router.get("/uploaded-files")
|
| 285 |
+
async def get_uploaded_filenames(request: Request):
|
| 286 |
+
try:
|
| 287 |
+
session_id = await get_or_create_session_for_request_async(request) # FIXED: Added await
|
| 288 |
+
session = session_manager.get_session(session_id)
|
| 289 |
+
return {"files": session.uploaded_files}
|
| 290 |
+
except Exception as e:
|
| 291 |
+
logger.error(f"Error getting uploaded files: {str(e)}")
|
| 292 |
+
return {"files": []}
|
| 293 |
+
|
| 294 |
+
|
| 295 |
+
@router.get("/document-insights/{filename}")
|
| 296 |
+
async def get_document_insights(filename: str, request: Request):
|
| 297 |
+
try:
|
| 298 |
+
session_id = await get_or_create_session_for_request_async(request) # FIXED: Added await
|
| 299 |
+
rag_manager = get_rag_manager()
|
| 300 |
+
stats = rag_manager.get_document_stats(session_id)
|
| 301 |
+
document_info = next((doc for doc in stats.get("documents", []) if doc["filename"] == filename), None)
|
| 302 |
+
|
| 303 |
+
if not document_info:
|
| 304 |
+
raise HTTPException(status_code=404, detail=f"Document {filename} not found")
|
| 305 |
+
|
| 306 |
+
results = rag_manager.collection.get(
|
| 307 |
+
where={"session_id": session_id, "filename": filename},
|
| 308 |
+
limit=3,
|
| 309 |
+
include=["documents", "metadatas"]
|
| 310 |
+
)
|
| 311 |
+
|
| 312 |
+
sample_sections = []
|
| 313 |
+
if results["documents"]:
|
| 314 |
+
for doc, metadata in zip(results["documents"], results["metadatas"]):
|
| 315 |
+
sample_sections.append({
|
| 316 |
+
"section": metadata.get("document_section", "unknown"),
|
| 317 |
+
"content_preview": doc[:200] + "..." if len(doc) > 200 else doc,
|
| 318 |
+
"keywords": metadata.get("keywords", "")
|
| 319 |
+
})
|
| 320 |
+
|
| 321 |
+
return {
|
| 322 |
+
"filename": filename,
|
| 323 |
+
"document_title": document_info.get("title", filename),
|
| 324 |
+
"file_type": document_info.get("file_type", "unknown"),
|
| 325 |
+
"statistics": {
|
| 326 |
+
"total_chunks": document_info["chunks"],
|
| 327 |
+
"estimated_tokens": document_info["estimated_tokens"],
|
| 328 |
+
"sections_identified": document_info["sections"]
|
| 329 |
+
},
|
| 330 |
+
"content_analysis": {
|
| 331 |
+
"has_methodology": document_info.get("has_methodology", False),
|
| 332 |
+
"has_theory": document_info.get("has_theory", False),
|
| 333 |
+
"has_references": document_info.get("has_references", False)
|
| 334 |
+
},
|
| 335 |
+
"sample_sections": sample_sections
|
| 336 |
+
}
|
| 337 |
+
|
| 338 |
+
except HTTPException:
|
| 339 |
+
raise
|
| 340 |
+
except Exception as e:
|
| 341 |
+
logger.error(f"Error getting document insights: {str(e)}")
|
| 342 |
+
raise HTTPException(status_code=500, detail=f"Error analyzing document: {str(e)}")
|
| 343 |
+
|
| 344 |
+
@router.get("/export-chat")
|
| 345 |
+
async def export_chat(
|
| 346 |
+
request: Request,
|
| 347 |
+
format: str = Query(..., regex="^(txt|pdf|docx)$"),
|
| 348 |
+
chat_session_id: str = Query(None, description="Optional: specific chat session ID to export"),
|
| 349 |
+
current_user: User = Depends(get_current_active_user)
|
| 350 |
+
):
|
| 351 |
+
"""
|
| 352 |
+
Export chat messages.
|
| 353 |
+
If chat_session_id is provided, exports that specific stored chat session.
|
| 354 |
+
Otherwise, exports the current in-memory session.
|
| 355 |
+
"""
|
| 356 |
+
try:
|
| 357 |
+
messages = []
|
| 358 |
+
|
| 359 |
+
if chat_session_id:
|
| 360 |
+
# Export specific stored chat session
|
| 361 |
+
db = get_database()
|
| 362 |
+
session_data = await db.chat_sessions.find_one({
|
| 363 |
+
"_id": ObjectId(chat_session_id),
|
| 364 |
+
"user_id": current_user.id,
|
| 365 |
+
"is_active": True
|
| 366 |
+
})
|
| 367 |
+
|
| 368 |
+
if not session_data:
|
| 369 |
+
raise HTTPException(
|
| 370 |
+
status_code=404,
|
| 371 |
+
detail="Chat session not found or you don't have permission to access it"
|
| 372 |
+
)
|
| 373 |
+
|
| 374 |
+
raw_messages = session_data.get("messages", [])
|
| 375 |
+
# Convert stored message format to export-compatible format
|
| 376 |
+
messages = convert_messages_for_export(raw_messages)
|
| 377 |
+
else:
|
| 378 |
+
# Export current in-memory session (existing behavior)
|
| 379 |
+
session_id = await get_or_create_session_for_request_async(request) # FIXED: Added await
|
| 380 |
+
session = session_manager.get_session(session_id)
|
| 381 |
+
# In-memory messages might already be in the right format, but convert to be safe
|
| 382 |
+
messages = convert_messages_for_export(session.messages)
|
| 383 |
+
|
| 384 |
+
if not messages:
|
| 385 |
+
return {"error": "No messages in this session."}
|
| 386 |
+
|
| 387 |
+
try:
|
| 388 |
+
return prepare_export_response(messages, format)
|
| 389 |
+
except Exception as export_error:
|
| 390 |
+
logger.error(f"Error in prepare_export_response: {str(export_error)}")
|
| 391 |
+
# Try with a simplified version of messages if the export fails
|
| 392 |
+
try:
|
| 393 |
+
# Create simplified messages with just basic text content
|
| 394 |
+
simplified_messages = []
|
| 395 |
+
for msg in messages:
|
| 396 |
+
simplified_msg = {
|
| 397 |
+
'id': msg.get('id', 'unknown'),
|
| 398 |
+
'role': msg.get('role', 'system'),
|
| 399 |
+
'content': str(msg.get('content', '')).replace('\n', ' ').strip(),
|
| 400 |
+
'timestamp': msg.get('timestamp', '')
|
| 401 |
+
}
|
| 402 |
+
if 'advisor_name' in msg:
|
| 403 |
+
simplified_msg['advisor_name'] = msg['advisor_name']
|
| 404 |
+
simplified_messages.append(simplified_msg)
|
| 405 |
+
|
| 406 |
+
return prepare_export_response(simplified_messages, format)
|
| 407 |
+
except Exception as fallback_error:
|
| 408 |
+
logger.error(f"Fallback export also failed: {str(fallback_error)}")
|
| 409 |
+
raise HTTPException(
|
| 410 |
+
status_code=500,
|
| 411 |
+
detail=f"Export failed due to content formatting issues. Please try a different format or contact support."
|
| 412 |
+
)
|
| 413 |
+
|
| 414 |
+
except HTTPException:
|
| 415 |
+
raise
|
| 416 |
+
except Exception as e:
|
| 417 |
+
logger.error(f"Error exporting chat: {str(e)}")
|
| 418 |
+
raise HTTPException(
|
| 419 |
+
status_code=500,
|
| 420 |
+
detail=f"Failed to export chat: {str(e)}"
|
| 421 |
+
)
|
| 422 |
+
|
| 423 |
+
@router.get("/chat-summary")
|
| 424 |
+
async def chat_summary(
|
| 425 |
+
request: Request,
|
| 426 |
+
format: str = Query("text", regex="^(txt|pdf|docx)$"),
|
| 427 |
+
chat_session_id: str = Query(None, description="Optional: specific chat session ID to summarize"),
|
| 428 |
+
current_user: User = Depends(get_current_active_user)
|
| 429 |
+
):
|
| 430 |
+
"""
|
| 431 |
+
Generate and return a summary of chat messages.
|
| 432 |
+
If chat_session_id is provided, summarizes that specific stored chat session.
|
| 433 |
+
Otherwise, summarizes the current in-memory session.
|
| 434 |
+
Can return as plain txt, PDF, or DOCX.
|
| 435 |
+
"""
|
| 436 |
+
try:
|
| 437 |
+
messages = []
|
| 438 |
+
|
| 439 |
+
if chat_session_id:
|
| 440 |
+
# Summarize specific stored chat session
|
| 441 |
+
db = get_database()
|
| 442 |
+
session_data = await db.chat_sessions.find_one({
|
| 443 |
+
"_id": ObjectId(chat_session_id),
|
| 444 |
+
"user_id": current_user.id,
|
| 445 |
+
"is_active": True
|
| 446 |
+
})
|
| 447 |
+
|
| 448 |
+
if not session_data:
|
| 449 |
+
raise HTTPException(
|
| 450 |
+
status_code=404,
|
| 451 |
+
detail="Chat session not found or you don't have permission to access it"
|
| 452 |
+
)
|
| 453 |
+
|
| 454 |
+
raw_messages = session_data.get("messages", [])
|
| 455 |
+
# Convert stored message format for summary generation
|
| 456 |
+
messages = convert_messages_for_export(raw_messages)
|
| 457 |
+
else:
|
| 458 |
+
# Summarize current in-memory session (existing behavior)
|
| 459 |
+
session_id = await get_or_create_session_for_request_async(request) # FIXED: Added await
|
| 460 |
+
session = session_manager.get_session(session_id)
|
| 461 |
+
# Convert in-memory messages
|
| 462 |
+
messages = convert_messages_for_export(session.messages)
|
| 463 |
+
|
| 464 |
+
if not messages:
|
| 465 |
+
return {"error": "No messages in this session."}
|
| 466 |
+
|
| 467 |
+
try:
|
| 468 |
+
llm = next(iter(chat_orchestrator.personas.values())).llm
|
| 469 |
+
summary_text = await generate_summary_from_messages(messages, llm)
|
| 470 |
+
|
| 471 |
+
if format == "txt":
|
| 472 |
+
# Use improved formatting for text export
|
| 473 |
+
formatted_summary = format_summary_for_text_export(summary_text)
|
| 474 |
+
return prepare_export_response(formatted_summary, "txt", filename_prefix="chat_summary")
|
| 475 |
+
|
| 476 |
+
elif format == "docx":
|
| 477 |
+
# Use improved formatting for DOCX export
|
| 478 |
+
formatted_summary = format_summary_for_text_export(summary_text)
|
| 479 |
+
return prepare_export_response(formatted_summary, "docx", filename_prefix="chat_summary")
|
| 480 |
+
|
| 481 |
+
elif format == "pdf":
|
| 482 |
+
# Parse and render using block formatting
|
| 483 |
+
blocks = [{"type": "heading", "text": "Chat Summary"}] + parse_summary_to_blocks(summary_text)
|
| 484 |
+
|
| 485 |
+
file_stream = generate_pdf_file_from_blocks(blocks)
|
| 486 |
+
return StreamingResponse(
|
| 487 |
+
file_stream,
|
| 488 |
+
media_type="application/pdf",
|
| 489 |
+
headers={"Content-Disposition": "attachment; filename=chat_summary.pdf"}
|
| 490 |
+
)
|
| 491 |
+
except Exception as summary_error:
|
| 492 |
+
logger.error(f"Error generating summary: {str(summary_error)}")
|
| 493 |
+
# Try with simplified content
|
| 494 |
+
try:
|
| 495 |
+
# Create a basic text summary if AI summary fails
|
| 496 |
+
basic_summary = "Chat Summary\n\n"
|
| 497 |
+
for msg in messages:
|
| 498 |
+
if msg.get('role') == 'user':
|
| 499 |
+
basic_summary += f"User: {msg.get('content', '')[:200]}...\n\n"
|
| 500 |
+
elif msg.get('role') == 'assistant':
|
| 501 |
+
advisor_name = msg.get('advisor_name', 'Advisor')
|
| 502 |
+
basic_summary += f"{advisor_name}: {msg.get('content', '')[:200]}...\n\n"
|
| 503 |
+
|
| 504 |
+
if format == "txt":
|
| 505 |
+
return prepare_export_response(basic_summary, "txt", filename_prefix="chat_summary")
|
| 506 |
+
elif format == "docx":
|
| 507 |
+
return prepare_export_response(basic_summary, "docx", filename_prefix="chat_summary")
|
| 508 |
+
elif format == "pdf":
|
| 509 |
+
blocks = [{"type": "heading", "text": "Chat Summary"}, {"type": "paragraph", "text": basic_summary}]
|
| 510 |
+
file_stream = generate_pdf_file_from_blocks(blocks)
|
| 511 |
+
return StreamingResponse(
|
| 512 |
+
file_stream,
|
| 513 |
+
media_type="application/pdf",
|
| 514 |
+
headers={"Content-Disposition": "attachment; filename=chat_summary.pdf"}
|
| 515 |
+
)
|
| 516 |
+
except Exception as fallback_error:
|
| 517 |
+
logger.error(f"Fallback summary export also failed: {str(fallback_error)}")
|
| 518 |
+
raise HTTPException(
|
| 519 |
+
status_code=500,
|
| 520 |
+
detail=f"Summary generation failed due to content formatting issues. Please try a different format."
|
| 521 |
+
)
|
| 522 |
+
|
| 523 |
+
except HTTPException:
|
| 524 |
+
raise
|
| 525 |
+
except Exception as e:
|
| 526 |
+
logger.error(f"Error in chat-summary endpoint: {str(e)}")
|
| 527 |
+
raise HTTPException(
|
| 528 |
+
status_code=500,
|
| 529 |
+
detail=f"Summary generation failed: {str(e)}"
|
| 530 |
+
)
|
multi_llm_chatbot_backend/app/api/routes/onboarding.py
ADDED
|
@@ -0,0 +1,152 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# NEON AI (TM) SOFTWARE, Software Development Kit & Application Framework
|
| 2 |
+
# All Rights Reserved 2008-2025
|
| 3 |
+
# Licensed under the BSD 3-Clause License
|
| 4 |
+
# https://opensource.org/licenses/BSD-3-Clause
|
| 5 |
+
#
|
| 6 |
+
# Copyright (c) 2008-2025, Neongecko.com Inc.
|
| 7 |
+
#
|
| 8 |
+
# Redistribution and use in source and binary forms, with or without
|
| 9 |
+
# modification, are permitted provided that the following conditions are met:
|
| 10 |
+
# 1. Redistributions of source code must retain the above copyright notice,
|
| 11 |
+
# this list of conditions and the following disclaimer.
|
| 12 |
+
# 2. Redistributions in binary form must reproduce the above copyright notice,
|
| 13 |
+
# this list of conditions and the following disclaimer in the documentation
|
| 14 |
+
# and/or other materials provided with the distribution.
|
| 15 |
+
# 3. Neither the name of the copyright holder nor the names of its contributors
|
| 16 |
+
# may be used to endorse or promote products derived from this software
|
| 17 |
+
# without specific prior written permission.
|
| 18 |
+
#
|
| 19 |
+
# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
|
| 20 |
+
# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
|
| 21 |
+
# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
|
| 22 |
+
# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE
|
| 23 |
+
# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
|
| 24 |
+
# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
|
| 25 |
+
# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
|
| 26 |
+
# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
|
| 27 |
+
# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
|
| 28 |
+
# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
|
| 29 |
+
# POSSIBILITY OF SUCH DAMAGE.
|
| 30 |
+
|
| 31 |
+
import logging
|
| 32 |
+
from datetime import datetime
|
| 33 |
+
from typing import Any, Dict, Optional
|
| 34 |
+
|
| 35 |
+
from fastapi import APIRouter, Depends
|
| 36 |
+
from pydantic import BaseModel
|
| 37 |
+
|
| 38 |
+
from app.api.routes.user_profile import _is_field_filled, enrich_profile_from_user
|
| 39 |
+
from app.core.auth import get_current_active_user
|
| 40 |
+
from app.core.bootstrap import create_llm_client
|
| 41 |
+
from app.core.database import get_database
|
| 42 |
+
from app.core.onboarding_agent import OnboardingAgent, PROFILE_FIELDS
|
| 43 |
+
from app.models.user import User
|
| 44 |
+
|
| 45 |
+
LOG = logging.getLogger(__name__)
|
| 46 |
+
|
| 47 |
+
router = APIRouter()
|
| 48 |
+
|
| 49 |
+
ONBOARDING_COLLECTION = "onboarding_conversations"
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
class OnboardingMessage(BaseModel):
|
| 53 |
+
user_input: str
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _progress(profile: Dict[str, Any]) -> int:
|
| 57 |
+
filled = sum(1 for k, *_ in PROFILE_FIELDS if _is_field_filled(profile.get(k)))
|
| 58 |
+
return int(filled / len(PROFILE_FIELDS) * 100)
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def _next_missing_question(profile: Dict[str, Any]) -> Optional[str]:
|
| 62 |
+
"""Return the human-friendly question for the first unfilled field."""
|
| 63 |
+
for key, question, _desc in PROFILE_FIELDS:
|
| 64 |
+
if not _is_field_filled(profile.get(key)):
|
| 65 |
+
return question
|
| 66 |
+
return None
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@router.get("/onboarding/start")
|
| 70 |
+
async def onboarding_start(
|
| 71 |
+
current_user: User = Depends(get_current_active_user),
|
| 72 |
+
) -> Dict[str, Any]:
|
| 73 |
+
"""Return conversation history (if any) and current progress.
|
| 74 |
+
|
| 75 |
+
If the user has an in-progress conversation it is returned so the
|
| 76 |
+
frontend can restore the chat. Otherwise a fresh contextual welcome
|
| 77 |
+
message is generated based on which fields are still missing.
|
| 78 |
+
"""
|
| 79 |
+
db = get_database()
|
| 80 |
+
doc = await db.user_profiles.find_one({"user_id": current_user.id})
|
| 81 |
+
profile = enrich_profile_from_user(doc, current_user)
|
| 82 |
+
progress = _progress(profile)
|
| 83 |
+
|
| 84 |
+
if progress >= 100:
|
| 85 |
+
await db[ONBOARDING_COLLECTION].delete_many({"user_id": current_user.id})
|
| 86 |
+
return {
|
| 87 |
+
"messages": [{"role": "agent",
|
| 88 |
+
"text": "Your profile is already complete! Feel free to update anything by chatting here."}],
|
| 89 |
+
"progress": 100,
|
| 90 |
+
"complete": True,
|
| 91 |
+
}
|
| 92 |
+
|
| 93 |
+
conv = await db[ONBOARDING_COLLECTION].find_one({"user_id": current_user.id})
|
| 94 |
+
|
| 95 |
+
if conv and conv.get("messages"):
|
| 96 |
+
return {
|
| 97 |
+
"messages": conv["messages"],
|
| 98 |
+
"progress": progress,
|
| 99 |
+
"complete": False,
|
| 100 |
+
}
|
| 101 |
+
|
| 102 |
+
next_q = _next_missing_question(profile)
|
| 103 |
+
if progress == 0:
|
| 104 |
+
greeting = (
|
| 105 |
+
f"Hey {current_user.firstName}! I'd like to learn a bit about your security background so "
|
| 106 |
+
"your advisors can tailor depth and examples. "
|
| 107 |
+
f"Let's start — {next_q.lower() if next_q else 'tell me about your role and goals!'}"
|
| 108 |
+
)
|
| 109 |
+
else:
|
| 110 |
+
greeting = (
|
| 111 |
+
f"Welcome back, {current_user.firstName}! You're {progress}% done. "
|
| 112 |
+
f"Let's pick up where we left off — {next_q.lower() if next_q else 'what else can you tell me?'}"
|
| 113 |
+
)
|
| 114 |
+
|
| 115 |
+
messages = [{"role": "agent", "text": greeting}]
|
| 116 |
+
await db[ONBOARDING_COLLECTION].update_one(
|
| 117 |
+
{"user_id": current_user.id},
|
| 118 |
+
{"$set": {"messages": messages, "updated_at": datetime.utcnow()},
|
| 119 |
+
"$setOnInsert": {"user_id": current_user.id}},
|
| 120 |
+
upsert=True,
|
| 121 |
+
)
|
| 122 |
+
|
| 123 |
+
return {"messages": messages, "progress": progress, "complete": False}
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
@router.post("/onboarding/chat")
|
| 127 |
+
async def onboarding_chat(
|
| 128 |
+
msg: OnboardingMessage,
|
| 129 |
+
current_user: User = Depends(get_current_active_user),
|
| 130 |
+
) -> Dict[str, Any]:
|
| 131 |
+
db = get_database()
|
| 132 |
+
doc = await db.user_profiles.find_one({"user_id": current_user.id})
|
| 133 |
+
profile = enrich_profile_from_user(doc, current_user)
|
| 134 |
+
|
| 135 |
+
agent = OnboardingAgent(create_llm_client())
|
| 136 |
+
result = await agent.chat(msg.user_input, current_user.id, profile)
|
| 137 |
+
|
| 138 |
+
user_msg = {"role": "user", "text": msg.user_input}
|
| 139 |
+
agent_msg = {"role": "agent", "text": result["reply"]}
|
| 140 |
+
|
| 141 |
+
await db[ONBOARDING_COLLECTION].update_one(
|
| 142 |
+
{"user_id": current_user.id},
|
| 143 |
+
{"$push": {"messages": {"$each": [user_msg, agent_msg]}},
|
| 144 |
+
"$set": {"updated_at": datetime.utcnow()},
|
| 145 |
+
"$setOnInsert": {"user_id": current_user.id}},
|
| 146 |
+
upsert=True,
|
| 147 |
+
)
|
| 148 |
+
|
| 149 |
+
if result.get("complete"):
|
| 150 |
+
await db[ONBOARDING_COLLECTION].delete_many({"user_id": current_user.id})
|
| 151 |
+
|
| 152 |
+
return result
|
multi_llm_chatbot_backend/app/api/routes/phd_canvas.py
ADDED
|
@@ -0,0 +1,316 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, HTTPException, Depends, status, BackgroundTasks
|
| 2 |
+
from typing import Dict, Optional
|
| 3 |
+
from datetime import datetime
|
| 4 |
+
import logging
|
| 5 |
+
from bson import ObjectId
|
| 6 |
+
|
| 7 |
+
from app.models.user import User
|
| 8 |
+
from app.models.phd_canvas import PhdCanvas, CanvasResponse, UpdateCanvasRequest
|
| 9 |
+
from app.core.auth import get_current_active_user
|
| 10 |
+
from app.core.database import get_database
|
| 11 |
+
|
| 12 |
+
from pydantic import BaseModel
|
| 13 |
+
|
| 14 |
+
logger = logging.getLogger(__name__)
|
| 15 |
+
router = APIRouter()
|
| 16 |
+
|
| 17 |
+
# Request/Response models
|
| 18 |
+
class CanvasStatsResponse(BaseModel):
|
| 19 |
+
total_insights: int
|
| 20 |
+
total_sections: int
|
| 21 |
+
last_updated: Optional[str] = None
|
| 22 |
+
last_chat_processed: Optional[str] = None
|
| 23 |
+
created_at: Optional[str] = None
|
| 24 |
+
auto_update: bool = True
|
| 25 |
+
sections_breakdown: Dict = {}
|
| 26 |
+
|
| 27 |
+
class PrintCanvasResponse(BaseModel):
|
| 28 |
+
user_id: str
|
| 29 |
+
generated_at: str
|
| 30 |
+
total_insights: int
|
| 31 |
+
last_updated: Optional[str] = None
|
| 32 |
+
sections: list
|
| 33 |
+
metadata: Dict
|
| 34 |
+
|
| 35 |
+
def get_canvas_manager():
|
| 36 |
+
"""Lazy import to avoid circular dependency"""
|
| 37 |
+
from app.core.canvas_manager import get_canvas_manager
|
| 38 |
+
return get_canvas_manager()
|
| 39 |
+
|
| 40 |
+
@router.get("/phd-canvas", response_model=CanvasResponse)
|
| 41 |
+
async def get_phd_canvas(
|
| 42 |
+
current_user: User = Depends(get_current_active_user)
|
| 43 |
+
):
|
| 44 |
+
"""Get the user's PhD Canvas, creating it if it doesn't exist"""
|
| 45 |
+
try:
|
| 46 |
+
canvas_manager = get_canvas_manager()
|
| 47 |
+
canvas = await canvas_manager.get_or_create_canvas(str(current_user.id))
|
| 48 |
+
|
| 49 |
+
# Convert to response model
|
| 50 |
+
return CanvasResponse(
|
| 51 |
+
id=str(canvas.id),
|
| 52 |
+
user_id=str(canvas.user_id),
|
| 53 |
+
sections=canvas.sections,
|
| 54 |
+
created_at=canvas.created_at,
|
| 55 |
+
last_updated=canvas.last_updated,
|
| 56 |
+
last_chat_processed=canvas.last_chat_processed,
|
| 57 |
+
total_insights=canvas.total_insights,
|
| 58 |
+
auto_update=canvas.auto_update,
|
| 59 |
+
print_optimized=canvas.print_optimized
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
except Exception as e:
|
| 63 |
+
logger.error(f"Error getting PhD canvas for user {current_user.id}: {e}")
|
| 64 |
+
raise HTTPException(
|
| 65 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 66 |
+
detail="Failed to retrieve PhD canvas"
|
| 67 |
+
)
|
| 68 |
+
|
| 69 |
+
@router.post("/phd-canvas/update", response_model=CanvasResponse)
|
| 70 |
+
async def update_phd_canvas(
|
| 71 |
+
request: UpdateCanvasRequest,
|
| 72 |
+
background_tasks: BackgroundTasks,
|
| 73 |
+
current_user: User = Depends(get_current_active_user)
|
| 74 |
+
):
|
| 75 |
+
"""Update the PhD Canvas with latest insights from chat sessions"""
|
| 76 |
+
try:
|
| 77 |
+
canvas_manager = get_canvas_manager()
|
| 78 |
+
|
| 79 |
+
# For large updates, run in background
|
| 80 |
+
if request.force_full_update:
|
| 81 |
+
logger.info(f"Queuing background canvas update for user {current_user.id}")
|
| 82 |
+
background_tasks.add_task(
|
| 83 |
+
_background_canvas_update,
|
| 84 |
+
str(current_user.id),
|
| 85 |
+
request
|
| 86 |
+
)
|
| 87 |
+
|
| 88 |
+
# Return current canvas immediately
|
| 89 |
+
canvas = await canvas_manager.get_or_create_canvas(str(current_user.id))
|
| 90 |
+
else:
|
| 91 |
+
# For incremental updates, process immediately
|
| 92 |
+
logger.info(f"Processing incremental canvas update for user {current_user.id}")
|
| 93 |
+
canvas = await canvas_manager.update_canvas(str(current_user.id), request)
|
| 94 |
+
|
| 95 |
+
return CanvasResponse(
|
| 96 |
+
id=str(canvas.id),
|
| 97 |
+
user_id=str(canvas.user_id),
|
| 98 |
+
sections=canvas.sections,
|
| 99 |
+
created_at=canvas.created_at,
|
| 100 |
+
last_updated=canvas.last_updated,
|
| 101 |
+
last_chat_processed=canvas.last_chat_processed,
|
| 102 |
+
total_insights=canvas.total_insights,
|
| 103 |
+
auto_update=canvas.auto_update,
|
| 104 |
+
print_optimized=canvas.print_optimized
|
| 105 |
+
)
|
| 106 |
+
|
| 107 |
+
except Exception as e:
|
| 108 |
+
logger.error(f"Error updating PhD canvas for user {current_user.id}: {e}")
|
| 109 |
+
raise HTTPException(
|
| 110 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 111 |
+
detail="Failed to update PhD canvas"
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
@router.get("/phd-canvas/stats", response_model=CanvasStatsResponse)
|
| 115 |
+
async def get_canvas_stats(
|
| 116 |
+
current_user: User = Depends(get_current_active_user)
|
| 117 |
+
):
|
| 118 |
+
"""Get statistics about the user's PhD Canvas"""
|
| 119 |
+
try:
|
| 120 |
+
canvas_manager = get_canvas_manager()
|
| 121 |
+
stats = await canvas_manager.get_canvas_stats(str(current_user.id))
|
| 122 |
+
|
| 123 |
+
return CanvasStatsResponse(
|
| 124 |
+
total_insights=stats.get("total_insights", 0),
|
| 125 |
+
total_sections=stats.get("total_sections", 0),
|
| 126 |
+
last_updated=stats.get("last_updated").isoformat() if stats.get("last_updated") else None,
|
| 127 |
+
last_chat_processed=stats.get("last_chat_processed").isoformat() if stats.get("last_chat_processed") else None,
|
| 128 |
+
created_at=stats.get("created_at").isoformat() if stats.get("created_at") else None,
|
| 129 |
+
auto_update=stats.get("auto_update", True),
|
| 130 |
+
sections_breakdown=stats.get("sections_breakdown", {})
|
| 131 |
+
)
|
| 132 |
+
|
| 133 |
+
except Exception as e:
|
| 134 |
+
logger.error(f"Error getting canvas stats for user {current_user.id}: {e}")
|
| 135 |
+
raise HTTPException(
|
| 136 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 137 |
+
detail="Failed to retrieve canvas statistics"
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
@router.get("/phd-canvas/print", response_model=PrintCanvasResponse)
|
| 141 |
+
async def get_print_canvas(
|
| 142 |
+
current_user: User = Depends(get_current_active_user)
|
| 143 |
+
):
|
| 144 |
+
"""Get PhD Canvas formatted for printing"""
|
| 145 |
+
try:
|
| 146 |
+
canvas_manager = get_canvas_manager()
|
| 147 |
+
print_data = await canvas_manager.export_canvas_for_printing(str(current_user.id))
|
| 148 |
+
|
| 149 |
+
return PrintCanvasResponse(
|
| 150 |
+
user_id=print_data["user_id"],
|
| 151 |
+
generated_at=print_data["generated_at"].isoformat(),
|
| 152 |
+
total_insights=print_data["total_insights"],
|
| 153 |
+
last_updated=print_data["last_updated"].isoformat() if print_data["last_updated"] else None,
|
| 154 |
+
sections=print_data["sections"],
|
| 155 |
+
metadata=print_data["metadata"]
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
except Exception as e:
|
| 159 |
+
logger.error(f"Error exporting print canvas for user {current_user.id}: {e}")
|
| 160 |
+
raise HTTPException(
|
| 161 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 162 |
+
detail="Failed to export canvas for printing"
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
@router.post("/phd-canvas/auto-update")
|
| 166 |
+
async def trigger_auto_update(
|
| 167 |
+
background_tasks: BackgroundTasks,
|
| 168 |
+
current_user: User = Depends(get_current_active_user)
|
| 169 |
+
):
|
| 170 |
+
"""Trigger automatic canvas update (typically called when user opens canvas page)"""
|
| 171 |
+
try:
|
| 172 |
+
canvas_manager = get_canvas_manager()
|
| 173 |
+
db = get_database()
|
| 174 |
+
|
| 175 |
+
# Get current canvas to check if auto-update is needed
|
| 176 |
+
canvas = await canvas_manager.get_or_create_canvas(str(current_user.id))
|
| 177 |
+
|
| 178 |
+
if not canvas.auto_update:
|
| 179 |
+
return {"message": "Auto-update is disabled for this canvas"}
|
| 180 |
+
|
| 181 |
+
# Check if this is a first-time canvas (never processed any chats)
|
| 182 |
+
is_first_time_canvas = (
|
| 183 |
+
canvas.last_chat_processed is None and
|
| 184 |
+
canvas.total_insights == 0
|
| 185 |
+
)
|
| 186 |
+
|
| 187 |
+
if is_first_time_canvas:
|
| 188 |
+
logger.info(f"First-time canvas detected for user {current_user.id}, triggering full update")
|
| 189 |
+
|
| 190 |
+
# For first-time canvas, always do full update to process all historical chats
|
| 191 |
+
background_tasks.add_task(
|
| 192 |
+
_background_canvas_update,
|
| 193 |
+
str(current_user.id),
|
| 194 |
+
UpdateCanvasRequest(force_full_update=True)
|
| 195 |
+
)
|
| 196 |
+
|
| 197 |
+
return {
|
| 198 |
+
"message": "First-time canvas update initiated",
|
| 199 |
+
"status": "processing",
|
| 200 |
+
"type": "full_update"
|
| 201 |
+
}
|
| 202 |
+
|
| 203 |
+
# For existing canvas, check if there are actually new chats
|
| 204 |
+
user_object_id = ObjectId(str(current_user.id))
|
| 205 |
+
|
| 206 |
+
# Count chats newer than last processed
|
| 207 |
+
query_filter = {
|
| 208 |
+
"user_id": user_object_id,
|
| 209 |
+
"is_active": {"$ne": False},
|
| 210 |
+
"deleted_at": {"$exists": False}
|
| 211 |
+
}
|
| 212 |
+
|
| 213 |
+
if canvas.last_chat_processed:
|
| 214 |
+
query_filter["$or"] = [
|
| 215 |
+
{"created_at": {"$gt": canvas.last_chat_processed}},
|
| 216 |
+
{"updated_at": {"$gt": canvas.last_chat_processed}}
|
| 217 |
+
]
|
| 218 |
+
|
| 219 |
+
new_chat_count = await db.chat_sessions.count_documents(query_filter)
|
| 220 |
+
|
| 221 |
+
if new_chat_count > 0:
|
| 222 |
+
logger.info(f"Found {new_chat_count} new/updated chats for user {current_user.id}, triggering incremental update")
|
| 223 |
+
|
| 224 |
+
# Only update if there are actually new chats
|
| 225 |
+
background_tasks.add_task(
|
| 226 |
+
_background_canvas_update,
|
| 227 |
+
str(current_user.id),
|
| 228 |
+
UpdateCanvasRequest(force_full_update=False)
|
| 229 |
+
)
|
| 230 |
+
|
| 231 |
+
return {
|
| 232 |
+
"message": f"Canvas update queued for {new_chat_count} new chats",
|
| 233 |
+
"status": "updating",
|
| 234 |
+
"type": "incremental_update",
|
| 235 |
+
"new_chats": new_chat_count
|
| 236 |
+
}
|
| 237 |
+
else:
|
| 238 |
+
logger.info(f"No new chats found for user {current_user.id}, skipping update")
|
| 239 |
+
return {
|
| 240 |
+
"message": "Canvas is up to date",
|
| 241 |
+
"status": "current",
|
| 242 |
+
"new_chats": 0
|
| 243 |
+
}
|
| 244 |
+
|
| 245 |
+
except Exception as e:
|
| 246 |
+
logger.error(f"Error triggering auto-update for user {current_user.id}: {e}")
|
| 247 |
+
raise HTTPException(
|
| 248 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 249 |
+
detail="Failed to trigger canvas auto-update"
|
| 250 |
+
)
|
| 251 |
+
|
| 252 |
+
@router.delete("/phd-canvas")
|
| 253 |
+
async def delete_canvas(
|
| 254 |
+
current_user: User = Depends(get_current_active_user)
|
| 255 |
+
):
|
| 256 |
+
"""Delete the user's PhD Canvas"""
|
| 257 |
+
try:
|
| 258 |
+
canvas_manager = get_canvas_manager()
|
| 259 |
+
deleted = await canvas_manager.delete_canvas(str(current_user.id))
|
| 260 |
+
|
| 261 |
+
if deleted:
|
| 262 |
+
return {"message": "PhD Canvas deleted successfully"}
|
| 263 |
+
else:
|
| 264 |
+
return {"message": "No canvas found to delete"}
|
| 265 |
+
|
| 266 |
+
except Exception as e:
|
| 267 |
+
logger.error(f"Error deleting canvas for user {current_user.id}: {e}")
|
| 268 |
+
raise HTTPException(
|
| 269 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 270 |
+
detail="Failed to delete PhD canvas"
|
| 271 |
+
)
|
| 272 |
+
|
| 273 |
+
@router.get("/phd-canvas/refresh")
|
| 274 |
+
async def refresh_canvas_data(
|
| 275 |
+
background_tasks: BackgroundTasks,
|
| 276 |
+
current_user: User = Depends(get_current_active_user)
|
| 277 |
+
):
|
| 278 |
+
"""Force refresh canvas with full update from all chats"""
|
| 279 |
+
try:
|
| 280 |
+
logger.info(f"Force refreshing canvas for user {current_user.id}")
|
| 281 |
+
|
| 282 |
+
# Queue full update in background
|
| 283 |
+
background_tasks.add_task(
|
| 284 |
+
_background_canvas_update,
|
| 285 |
+
str(current_user.id),
|
| 286 |
+
UpdateCanvasRequest(force_full_update=True)
|
| 287 |
+
)
|
| 288 |
+
|
| 289 |
+
return {
|
| 290 |
+
"message": "Full canvas refresh initiated",
|
| 291 |
+
"status": "processing",
|
| 292 |
+
"estimated_completion": "2-3 minutes"
|
| 293 |
+
}
|
| 294 |
+
|
| 295 |
+
except Exception as e:
|
| 296 |
+
logger.error(f"Error refreshing canvas for user {current_user.id}: {e}")
|
| 297 |
+
raise HTTPException(
|
| 298 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 299 |
+
detail="Failed to refresh PhD canvas"
|
| 300 |
+
)
|
| 301 |
+
|
| 302 |
+
# Background task functions
|
| 303 |
+
async def _background_canvas_update(user_id: str, request: UpdateCanvasRequest):
|
| 304 |
+
"""Background task to update canvas without blocking the API response"""
|
| 305 |
+
try:
|
| 306 |
+
logger.info(f"Starting background canvas update for user {user_id}")
|
| 307 |
+
canvas_manager = get_canvas_manager()
|
| 308 |
+
|
| 309 |
+
updated_canvas = await canvas_manager.update_canvas(user_id, request)
|
| 310 |
+
|
| 311 |
+
logger.info(f"Background canvas update completed for user {user_id}. Total insights: {updated_canvas.total_insights}")
|
| 312 |
+
|
| 313 |
+
except Exception as e:
|
| 314 |
+
logger.error(f"Error in background canvas update for user {user_id}: {e}")
|
| 315 |
+
import traceback
|
| 316 |
+
logger.error(f"Traceback: {traceback.format_exc()}")
|
multi_llm_chatbot_backend/app/api/routes/provider.py
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Body, HTTPException
|
| 2 |
+
from app.config import get_settings
|
| 3 |
+
from app.llm.improved_gemini_client import ImprovedGeminiClient
|
| 4 |
+
from app.llm.improved_ollama_client import ImprovedOllamaClient
|
| 5 |
+
from app.llm.improved_vllm_client import ImprovedVllmClient
|
| 6 |
+
from app.models.default_personas import get_default_personas
|
| 7 |
+
from app.core.bootstrap import chat_orchestrator, llm, current_provider, available_providers
|
| 8 |
+
from pydantic import BaseModel
|
| 9 |
+
import os
|
| 10 |
+
import logging
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
router = APIRouter()
|
| 15 |
+
|
| 16 |
+
def create_llm_client(provider: str = None):
|
| 17 |
+
global current_provider
|
| 18 |
+
if provider is None:
|
| 19 |
+
provider = current_provider
|
| 20 |
+
|
| 21 |
+
if provider == "gemini":
|
| 22 |
+
try:
|
| 23 |
+
return ImprovedGeminiClient(model_name=os.getenv("GEMINI_MODEL"))
|
| 24 |
+
except ValueError as e:
|
| 25 |
+
logger.warning(f"Gemini API key not found, falling back to Ollama: {e}")
|
| 26 |
+
return ImprovedOllamaClient(model_name="llama3.2:1b")
|
| 27 |
+
elif provider == "ollama":
|
| 28 |
+
return ImprovedOllamaClient(model_name="llama3.2:1b")
|
| 29 |
+
elif provider == "vllm":
|
| 30 |
+
settings = get_settings()
|
| 31 |
+
if not settings.llm.vllm.api_url:
|
| 32 |
+
raise ValueError("No vLLM endpoint configured. Set llm.vllm.api_url in your config.")
|
| 33 |
+
return ImprovedVllmClient(
|
| 34 |
+
api_url=settings.llm.vllm.api_url,
|
| 35 |
+
api_key=settings.llm.vllm.api_key,
|
| 36 |
+
)
|
| 37 |
+
else:
|
| 38 |
+
raise ValueError(f"Unknown provider: {provider}")
|
| 39 |
+
|
| 40 |
+
# NOTE: Personas and `llm` are already created and registered by
|
| 41 |
+
# app/core/bootstrap.py with the correct ResilientLLMClient wrapping
|
| 42 |
+
# (primary vLLM + OpenAI fallback). Re-registering them here at module
|
| 43 |
+
# import time replaces them with a raw vLLM client that lacks the
|
| 44 |
+
# fallback wrapper AND lacks the api_username/api_key wiring, breaking
|
| 45 |
+
# both auth and failover. Bootstrap already handles initial setup.
|
| 46 |
+
|
| 47 |
+
class ProviderSwitch(BaseModel):
|
| 48 |
+
provider: str
|
| 49 |
+
|
| 50 |
+
@router.get("/current-provider")
|
| 51 |
+
async def get_current_provider():
|
| 52 |
+
return {
|
| 53 |
+
"current_provider": current_provider,
|
| 54 |
+
"available_providers": available_providers,
|
| 55 |
+
"model_info": {
|
| 56 |
+
"name": llm.model_name if hasattr(llm, 'model_name') else "gemini-2.0-flash",
|
| 57 |
+
"provider": current_provider
|
| 58 |
+
}
|
| 59 |
+
}
|
| 60 |
+
|
| 61 |
+
@router.post("/switch-provider")
|
| 62 |
+
async def switch_provider(provider_data: ProviderSwitch):
|
| 63 |
+
global current_provider, llm
|
| 64 |
+
|
| 65 |
+
if provider_data.provider not in available_providers:
|
| 66 |
+
raise HTTPException(status_code=400, detail=f"Unknown provider: {provider_data.provider}. Available: {available_providers}")
|
| 67 |
+
|
| 68 |
+
try:
|
| 69 |
+
from app.core.bootstrap import (
|
| 70 |
+
create_orchestrator_llm,
|
| 71 |
+
create_persona_llm,
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
current_provider = provider_data.provider
|
| 75 |
+
new_llm = create_orchestrator_llm()
|
| 76 |
+
llm = new_llm
|
| 77 |
+
|
| 78 |
+
chat_orchestrator.llm_client = new_llm
|
| 79 |
+
|
| 80 |
+
persona_llm = create_persona_llm()
|
| 81 |
+
new_personas = get_default_personas(persona_llm)
|
| 82 |
+
chat_orchestrator.personas.clear()
|
| 83 |
+
for persona in new_personas:
|
| 84 |
+
chat_orchestrator.register_persona(persona)
|
| 85 |
+
|
| 86 |
+
return {
|
| 87 |
+
"message": f"Successfully switched to {current_provider}",
|
| 88 |
+
"current_provider": current_provider,
|
| 89 |
+
"model_info": {
|
| 90 |
+
"name": new_llm.model_name if hasattr(new_llm, 'model_name') else "gemini-2.0-flash",
|
| 91 |
+
"provider": current_provider
|
| 92 |
+
}
|
| 93 |
+
}
|
| 94 |
+
|
| 95 |
+
except Exception as e:
|
| 96 |
+
raise HTTPException(status_code=500, detail=f"Failed to switch to {provider_data.provider}: {str(e)}")
|
| 97 |
+
|
| 98 |
+
@router.post("/switch-model")
|
| 99 |
+
async def switch_model(model_name: str = Body(...)):
|
| 100 |
+
if "gemini" in model_name.lower():
|
| 101 |
+
return await switch_provider(ProviderSwitch(provider="gemini"))
|
| 102 |
+
else:
|
| 103 |
+
return await switch_provider(ProviderSwitch(provider="ollama"))
|
| 104 |
+
|
| 105 |
+
@router.get("/current-model")
|
| 106 |
+
async def get_current_model():
|
| 107 |
+
model_name = llm.model_name if hasattr(llm, 'model_name') else "gemini-2.0-flash"
|
| 108 |
+
return {
|
| 109 |
+
"model": model_name,
|
| 110 |
+
"provider": current_provider
|
| 111 |
+
}
|
multi_llm_chatbot_backend/app/api/routes/root.py
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter
|
| 2 |
+
from app.config import get_settings
|
| 3 |
+
from app.version import __version__
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
|
| 7 |
+
logger = logging.getLogger(__name__)
|
| 8 |
+
|
| 9 |
+
router = APIRouter()
|
| 10 |
+
|
| 11 |
+
# NB: keep this OFF the bare "/" path. In the single-container / Hugging Face
|
| 12 |
+
# Spaces deployment the React SPA is mounted at "/" as a catch-all, and any
|
| 13 |
+
# API route registered at exactly "/" would shadow the SPA's index.html and
|
| 14 |
+
# leave users staring at this JSON banner instead of the app.
|
| 15 |
+
@router.get("/api/health")
|
| 16 |
+
def root():
|
| 17 |
+
title = get_settings().app.title
|
| 18 |
+
return {
|
| 19 |
+
"message": f"{title} Backend is up and running",
|
| 20 |
+
"version": __version__,
|
| 21 |
+
"features": [
|
| 22 |
+
"Configurable Personas",
|
| 23 |
+
"Improved Session Management",
|
| 24 |
+
"Unified Context Handling",
|
| 25 |
+
"Ollama Support",
|
| 26 |
+
"Gemini API Support",
|
| 27 |
+
"Provider Switching"
|
| 28 |
+
]
|
| 29 |
+
}
|
| 30 |
+
|
multi_llm_chatbot_backend/app/api/routes/sessions.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from fastapi import APIRouter, Request, HTTPException, Depends
|
| 2 |
+
from app.core.session_manager import get_session_manager
|
| 3 |
+
from app.api.utils import get_or_create_session_for_request_async
|
| 4 |
+
from app.core.auth import get_current_active_user
|
| 5 |
+
from app.models.user import User
|
| 6 |
+
from pydantic import BaseModel
|
| 7 |
+
from typing import Optional
|
| 8 |
+
import logging
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
router = APIRouter()
|
| 13 |
+
session_manager = get_session_manager()
|
| 14 |
+
|
| 15 |
+
class ResetSessionRequest(BaseModel):
|
| 16 |
+
chat_session_id: Optional[str] = None
|
| 17 |
+
force_new: bool = False
|
| 18 |
+
|
| 19 |
+
@router.get("/context")
|
| 20 |
+
async def get_context(
|
| 21 |
+
request: Request,
|
| 22 |
+
chat_session_id: Optional[str] = None,
|
| 23 |
+
current_user: User = Depends(get_current_active_user)
|
| 24 |
+
):
|
| 25 |
+
"""
|
| 26 |
+
Get context for current session - ENHANCED with document access fix
|
| 27 |
+
Now properly handles different chat sessions and ensures document access
|
| 28 |
+
"""
|
| 29 |
+
try:
|
| 30 |
+
# Determine which session to get context for with consistent session ID format
|
| 31 |
+
if chat_session_id:
|
| 32 |
+
# Getting context for a specific chat session - use consistent format
|
| 33 |
+
session_id = f"chat_{chat_session_id}"
|
| 34 |
+
logger.info(f"Getting context for specific chat session: {session_id}")
|
| 35 |
+
|
| 36 |
+
# Ensure session is loaded in memory
|
| 37 |
+
if session_id not in session_manager.sessions:
|
| 38 |
+
logger.info(f"Chat session {session_id} not in memory, loading from database")
|
| 39 |
+
loaded_session_id = await get_or_create_session_for_request_async(
|
| 40 |
+
request,
|
| 41 |
+
chat_session_id=chat_session_id,
|
| 42 |
+
user_id=str(current_user.id)
|
| 43 |
+
)
|
| 44 |
+
session_id = loaded_session_id
|
| 45 |
+
logger.info(f"Loaded session ID: {session_id}")
|
| 46 |
+
else:
|
| 47 |
+
# Getting context for current session
|
| 48 |
+
session_id = await get_or_create_session_for_request_async(request)
|
| 49 |
+
logger.info(f"Getting context for current session: {session_id}")
|
| 50 |
+
|
| 51 |
+
session = session_manager.get_session(session_id)
|
| 52 |
+
rag_stats = session.get_rag_stats()
|
| 53 |
+
|
| 54 |
+
# Enhanced logging for document access debugging
|
| 55 |
+
logger.info(f"Retrieved context for session {session_id}:")
|
| 56 |
+
logger.info(f" - Messages: {len(session.messages)}")
|
| 57 |
+
logger.info(f" - Documents: {rag_stats.get('total_documents', 0)}")
|
| 58 |
+
logger.info(f" - Chunks: {rag_stats.get('total_chunks', 0)}")
|
| 59 |
+
logger.info(f" - Uploaded files: {len(session.uploaded_files)}")
|
| 60 |
+
|
| 61 |
+
# Log document details if available
|
| 62 |
+
if rag_stats.get('documents'):
|
| 63 |
+
for doc in rag_stats['documents']:
|
| 64 |
+
logger.info(f" - Available document: {doc.get('filename', 'unknown')} ({doc.get('chunks', 0)} chunks)")
|
| 65 |
+
|
| 66 |
+
# Include session debugging info in response
|
| 67 |
+
context_response = {
|
| 68 |
+
"session_id": session_id,
|
| 69 |
+
"chat_session_id": chat_session_id,
|
| 70 |
+
"messages": session.messages,
|
| 71 |
+
"rag_info": {
|
| 72 |
+
"total_documents": rag_stats.get("total_documents", 0),
|
| 73 |
+
"total_chunks": rag_stats.get("total_chunks", 0),
|
| 74 |
+
"documents": rag_stats.get("documents", [])
|
| 75 |
+
},
|
| 76 |
+
"context_stats": {
|
| 77 |
+
"message_count": len(session.messages),
|
| 78 |
+
"user_messages": len([m for m in session.messages if m.get('role') == 'user']),
|
| 79 |
+
"uploaded_files": session.uploaded_files,
|
| 80 |
+
"total_upload_size": session.total_upload_size,
|
| 81 |
+
"created_at": session.created_at.isoformat(),
|
| 82 |
+
"last_accessed": session.last_accessed.isoformat()
|
| 83 |
+
},
|
| 84 |
+
# Add debugging info
|
| 85 |
+
"debug_info": {
|
| 86 |
+
"session_format": "chat_session" if chat_session_id else "new_session",
|
| 87 |
+
"session_in_memory": session_id in session_manager.sessions,
|
| 88 |
+
"document_access_working": rag_stats.get("total_documents", 0) > 0
|
| 89 |
+
}
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
return context_response
|
| 93 |
+
|
| 94 |
+
except Exception as e:
|
| 95 |
+
logger.error(f"Error getting context for session_id {session_id if 'session_id' in locals() else 'unknown'}: {str(e)}")
|
| 96 |
+
logger.error(f"Chat session ID: {chat_session_id}")
|
| 97 |
+
import traceback
|
| 98 |
+
logger.error(f"Full traceback: {traceback.format_exc()}")
|
| 99 |
+
|
| 100 |
+
return {
|
| 101 |
+
"session_id": session_id if 'session_id' in locals() else None,
|
| 102 |
+
"chat_session_id": chat_session_id,
|
| 103 |
+
"messages": [],
|
| 104 |
+
"rag_info": {"total_documents": 0, "total_chunks": 0, "documents": []},
|
| 105 |
+
"context_stats": {
|
| 106 |
+
"message_count": 0,
|
| 107 |
+
"user_messages": 0,
|
| 108 |
+
"uploaded_files": [],
|
| 109 |
+
"total_upload_size": 0
|
| 110 |
+
},
|
| 111 |
+
"error": str(e),
|
| 112 |
+
"debug_info": {
|
| 113 |
+
"error_occurred": True,
|
| 114 |
+
"error_type": type(e).__name__
|
| 115 |
+
}
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
@router.post("/reset-session")
|
| 119 |
+
async def reset_session(
|
| 120 |
+
reset_request: ResetSessionRequest,
|
| 121 |
+
request: Request,
|
| 122 |
+
current_user: User = Depends(get_current_active_user)
|
| 123 |
+
):
|
| 124 |
+
"""
|
| 125 |
+
Reset session - ENHANCED
|
| 126 |
+
Now properly handles different reset scenarios
|
| 127 |
+
"""
|
| 128 |
+
try:
|
| 129 |
+
if reset_request.force_new:
|
| 130 |
+
# Force create a completely new session
|
| 131 |
+
session_id = session_manager.create_session()
|
| 132 |
+
session = session_manager.get_session(session_id)
|
| 133 |
+
session.clear_all_data()
|
| 134 |
+
|
| 135 |
+
logger.info(f"Force created new session: {session_id}")
|
| 136 |
+
|
| 137 |
+
return {
|
| 138 |
+
"status": "reset",
|
| 139 |
+
"message": "New session created with fresh context",
|
| 140 |
+
"session_id": session_id,
|
| 141 |
+
"chat_session_id": None
|
| 142 |
+
}
|
| 143 |
+
|
| 144 |
+
elif reset_request.chat_session_id:
|
| 145 |
+
# Reset a specific chat session context
|
| 146 |
+
session_id = f"chat_{reset_request.chat_session_id}"
|
| 147 |
+
|
| 148 |
+
if session_id in session_manager.sessions:
|
| 149 |
+
success = session_manager.reset_session_completely(session_id)
|
| 150 |
+
message = "Chat session context reset successfully" if success else "Failed to reset chat session context"
|
| 151 |
+
else:
|
| 152 |
+
# Create fresh context for this chat
|
| 153 |
+
session = session_manager.get_session(session_id)
|
| 154 |
+
session.clear_all_data()
|
| 155 |
+
success = True
|
| 156 |
+
message = "Fresh context created for chat session"
|
| 157 |
+
|
| 158 |
+
logger.info(f"Reset chat session {reset_request.chat_session_id}, memory session: {session_id}")
|
| 159 |
+
|
| 160 |
+
return {
|
| 161 |
+
"status": "reset" if success else "error",
|
| 162 |
+
"message": message,
|
| 163 |
+
"session_id": session_id,
|
| 164 |
+
"chat_session_id": reset_request.chat_session_id
|
| 165 |
+
}
|
| 166 |
+
|
| 167 |
+
else:
|
| 168 |
+
# Reset current session
|
| 169 |
+
session_id = await get_or_create_session_for_request_async(request)
|
| 170 |
+
success = session_manager.reset_session_completely(session_id)
|
| 171 |
+
|
| 172 |
+
logger.info(f"Reset current session: {session_id}")
|
| 173 |
+
|
| 174 |
+
return {
|
| 175 |
+
"status": "reset" if success else "error",
|
| 176 |
+
"message": "Current session reset successfully" if success else "Failed to reset current session",
|
| 177 |
+
"session_id": session_id
|
| 178 |
+
}
|
| 179 |
+
|
| 180 |
+
except Exception as e:
|
| 181 |
+
logger.error(f"Error resetting session: {e}")
|
| 182 |
+
return {"status": "error", "message": f"Failed to reset session: {str(e)}"}
|
| 183 |
+
|
| 184 |
+
@router.get("/session-stats")
|
| 185 |
+
async def get_session_stats(
|
| 186 |
+
request: Request,
|
| 187 |
+
chat_session_id: Optional[str] = None,
|
| 188 |
+
current_user: User = Depends(get_current_active_user)
|
| 189 |
+
):
|
| 190 |
+
"""
|
| 191 |
+
Get session statistics - ENHANCED
|
| 192 |
+
Now provides detailed stats for different session types
|
| 193 |
+
"""
|
| 194 |
+
try:
|
| 195 |
+
if chat_session_id:
|
| 196 |
+
# Stats for specific chat session
|
| 197 |
+
session_id = f"chat_{chat_session_id}"
|
| 198 |
+
else:
|
| 199 |
+
# Stats for current session
|
| 200 |
+
session_id = await get_or_create_session_for_request_async(request)
|
| 201 |
+
|
| 202 |
+
stats = session_manager.get_session_stats(session_id)
|
| 203 |
+
|
| 204 |
+
# Add additional context
|
| 205 |
+
stats["session_type"] = "chat_session" if chat_session_id else "current_session"
|
| 206 |
+
stats["chat_session_id"] = chat_session_id
|
| 207 |
+
|
| 208 |
+
return stats
|
| 209 |
+
|
| 210 |
+
except Exception as e:
|
| 211 |
+
logger.error(f"Error getting session stats: {str(e)}")
|
| 212 |
+
return {"error": str(e)}
|
| 213 |
+
|
| 214 |
+
@router.get("/active-sessions")
|
| 215 |
+
async def get_active_sessions(current_user: User = Depends(get_current_active_user)):
|
| 216 |
+
"""
|
| 217 |
+
Get all active sessions for debugging
|
| 218 |
+
"""
|
| 219 |
+
try:
|
| 220 |
+
active_count = session_manager.get_active_session_count()
|
| 221 |
+
|
| 222 |
+
# Get overview of sessions (don't return full content for privacy)
|
| 223 |
+
session_overview = {}
|
| 224 |
+
for session_id, session in session_manager.sessions.items():
|
| 225 |
+
session_overview[session_id] = {
|
| 226 |
+
"message_count": len(session.messages),
|
| 227 |
+
"uploaded_files": len(session.uploaded_files),
|
| 228 |
+
"created_at": session.created_at.isoformat(),
|
| 229 |
+
"last_accessed": session.last_accessed.isoformat(),
|
| 230 |
+
"is_chat_session": session_id.startswith("chat_")
|
| 231 |
+
}
|
| 232 |
+
|
| 233 |
+
return {
|
| 234 |
+
"active_session_count": active_count,
|
| 235 |
+
"sessions": session_overview
|
| 236 |
+
}
|
| 237 |
+
|
| 238 |
+
except Exception as e:
|
| 239 |
+
logger.error(f"Error getting active sessions: {str(e)}")
|
| 240 |
+
return {"error": str(e)}
|
| 241 |
+
|
| 242 |
+
@router.post("/cleanup-sessions")
|
| 243 |
+
async def cleanup_expired_sessions(current_user: User = Depends(get_current_active_user)):
|
| 244 |
+
"""
|
| 245 |
+
Manually trigger session cleanup
|
| 246 |
+
"""
|
| 247 |
+
try:
|
| 248 |
+
initial_count = session_manager.get_active_session_count()
|
| 249 |
+
|
| 250 |
+
# Force cleanup
|
| 251 |
+
session_manager._cleanup_expired_sessions()
|
| 252 |
+
|
| 253 |
+
final_count = session_manager.get_active_session_count()
|
| 254 |
+
cleaned_count = initial_count - final_count
|
| 255 |
+
|
| 256 |
+
return {
|
| 257 |
+
"status": "success",
|
| 258 |
+
"message": f"Cleaned up {cleaned_count} expired sessions",
|
| 259 |
+
"sessions_before": initial_count,
|
| 260 |
+
"sessions_after": final_count
|
| 261 |
+
}
|
| 262 |
+
|
| 263 |
+
except Exception as e:
|
| 264 |
+
logger.error(f"Error during session cleanup: {str(e)}")
|
| 265 |
+
return {"status": "error", "message": str(e)}
|
multi_llm_chatbot_backend/app/api/routes/user_profile.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from datetime import datetime
|
| 3 |
+
from typing import Any, Dict, List, Optional
|
| 4 |
+
|
| 5 |
+
from fastapi import APIRouter, Depends
|
| 6 |
+
from pydantic import BaseModel
|
| 7 |
+
|
| 8 |
+
from app.core.auth import get_current_active_user
|
| 9 |
+
from app.core.database import get_database
|
| 10 |
+
from app.models.user import User
|
| 11 |
+
from app.models.user_profile import UserProfileResponse, UserProfileUpdate
|
| 12 |
+
|
| 13 |
+
LOG = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
router = APIRouter()
|
| 16 |
+
|
| 17 |
+
PROFILE_FIELDS = [
|
| 18 |
+
"knowledge_level",
|
| 19 |
+
"timezone",
|
| 20 |
+
"cyber_role",
|
| 21 |
+
"organization_type",
|
| 22 |
+
"primary_domains",
|
| 23 |
+
"certifications",
|
| 24 |
+
"tools_stack",
|
| 25 |
+
"compliance_focus",
|
| 26 |
+
"current_goals",
|
| 27 |
+
"learning_preferences",
|
| 28 |
+
]
|
| 29 |
+
|
| 30 |
+
LIST_FIELDS = {"primary_domains", "certifications", "tools_stack"}
|
| 31 |
+
|
| 32 |
+
_SELECT_OPTIONS: Dict[str, List[str]] = {
|
| 33 |
+
# NOTE: field keys are kept generic (cyber_role / organization_type) for
|
| 34 |
+
# storage compatibility; the labels/options are themed for the panel.
|
| 35 |
+
"cyber_role": [
|
| 36 |
+
"Internship search",
|
| 37 |
+
"Full-time / entry-level",
|
| 38 |
+
"Both internship and FT",
|
| 39 |
+
"Career exploration",
|
| 40 |
+
"Return offer conversion",
|
| 41 |
+
"Industry switch / pivot",
|
| 42 |
+
"Graduate school path",
|
| 43 |
+
"Other",
|
| 44 |
+
],
|
| 45 |
+
"organization_type": [
|
| 46 |
+
"Handshake",
|
| 47 |
+
"LinkedIn",
|
| 48 |
+
"Company career pages",
|
| 49 |
+
"Career fairs / campus recruiting",
|
| 50 |
+
"Referrals / alumni",
|
| 51 |
+
"Hybrid (multiple channels)",
|
| 52 |
+
],
|
| 53 |
+
}
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
def _is_field_filled(value: Any) -> bool:
|
| 57 |
+
if value is None:
|
| 58 |
+
return False
|
| 59 |
+
if isinstance(value, str):
|
| 60 |
+
return bool(value.strip())
|
| 61 |
+
if isinstance(value, list):
|
| 62 |
+
return len(value) > 0
|
| 63 |
+
return bool(value)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def enrich_profile_from_user(
|
| 67 |
+
doc: Optional[Dict[str, Any]], user: User
|
| 68 |
+
) -> Dict[str, Any]:
|
| 69 |
+
"""Merge account signup fields into the profile when profile slots are empty."""
|
| 70 |
+
merged = dict(doc or {})
|
| 71 |
+
if user.academicStage and not _is_field_filled(merged.get("knowledge_level")):
|
| 72 |
+
merged["knowledge_level"] = user.academicStage
|
| 73 |
+
if user.researchArea and not _is_field_filled(merged.get("timezone")):
|
| 74 |
+
merged["timezone"] = user.researchArea
|
| 75 |
+
return merged
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _calc_completion(doc: Dict[str, Any]) -> int:
|
| 79 |
+
filled = sum(1 for f in PROFILE_FIELDS if _is_field_filled(doc.get(f)))
|
| 80 |
+
return int(filled / len(PROFILE_FIELDS) * 100)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _profile_response(doc: Dict[str, Any], user: User) -> UserProfileResponse:
|
| 84 |
+
enriched = enrich_profile_from_user(doc, user)
|
| 85 |
+
fields = {k: _normalize_field(k, enriched.get(k)) for k in PROFILE_FIELDS}
|
| 86 |
+
return UserProfileResponse(
|
| 87 |
+
user_id=str(enriched.get("user_id", user.id)),
|
| 88 |
+
**fields,
|
| 89 |
+
advisor_notes=enriched.get("advisor_notes"),
|
| 90 |
+
updated_at=enriched.get("updated_at"),
|
| 91 |
+
completion_pct=_calc_completion(enriched),
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
_SELECT_LOOKUP: Dict[str, Dict[str, str]] = {
|
| 96 |
+
field: {opt.lower(): opt for opt in opts}
|
| 97 |
+
for field, opts in _SELECT_OPTIONS.items()
|
| 98 |
+
}
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
def _normalize_select(key: str, value: str) -> str:
|
| 102 |
+
lookup = _SELECT_LOOKUP.get(key)
|
| 103 |
+
if not lookup or not isinstance(value, str):
|
| 104 |
+
return value
|
| 105 |
+
v = value.strip()
|
| 106 |
+
if v.lower() in lookup:
|
| 107 |
+
return lookup[v.lower()]
|
| 108 |
+
for canonical_lower, canonical in lookup.items():
|
| 109 |
+
if canonical_lower in v.lower():
|
| 110 |
+
return canonical
|
| 111 |
+
return v
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def _normalize_field(key: str, value: Any) -> Any:
|
| 115 |
+
if key in LIST_FIELDS:
|
| 116 |
+
if isinstance(value, str):
|
| 117 |
+
return [s.strip() for s in value.split(",") if s.strip()]
|
| 118 |
+
if isinstance(value, list):
|
| 119 |
+
return value
|
| 120 |
+
return []
|
| 121 |
+
if key in _SELECT_OPTIONS:
|
| 122 |
+
return _normalize_select(key, value)
|
| 123 |
+
if isinstance(value, list):
|
| 124 |
+
return ", ".join(str(v) for v in value if v)
|
| 125 |
+
return value
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
async def _sync_profile_to_user(user: User, update_data: Dict[str, Any]) -> None:
|
| 129 |
+
"""Keep users.academicStage / researchArea aligned with profile edits."""
|
| 130 |
+
user_updates: Dict[str, Any] = {}
|
| 131 |
+
if "knowledge_level" in update_data:
|
| 132 |
+
user_updates["academicStage"] = update_data["knowledge_level"] or None
|
| 133 |
+
if "timezone" in update_data:
|
| 134 |
+
user_updates["researchArea"] = update_data["timezone"] or None
|
| 135 |
+
if user_updates:
|
| 136 |
+
db = get_database()
|
| 137 |
+
await db.users.update_one({"_id": user.id}, {"$set": user_updates})
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
@router.get("/users/me/profile", response_model=UserProfileResponse)
|
| 141 |
+
async def get_my_profile(
|
| 142 |
+
current_user: User = Depends(get_current_active_user),
|
| 143 |
+
) -> UserProfileResponse:
|
| 144 |
+
db = get_database()
|
| 145 |
+
doc = await db.user_profiles.find_one({"user_id": current_user.id})
|
| 146 |
+
if not doc:
|
| 147 |
+
doc = {"user_id": current_user.id}
|
| 148 |
+
return _profile_response(doc, current_user)
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
@router.put("/users/me/profile", response_model=UserProfileResponse)
|
| 152 |
+
async def update_my_profile(
|
| 153 |
+
updates: UserProfileUpdate,
|
| 154 |
+
current_user: User = Depends(get_current_active_user),
|
| 155 |
+
) -> UserProfileResponse:
|
| 156 |
+
db = get_database()
|
| 157 |
+
update_data = {k: v for k, v in updates.dict().items() if v is not None}
|
| 158 |
+
update_data["updated_at"] = datetime.utcnow()
|
| 159 |
+
await db.user_profiles.update_one(
|
| 160 |
+
{"user_id": current_user.id},
|
| 161 |
+
{"$set": update_data, "$setOnInsert": {"user_id": current_user.id}},
|
| 162 |
+
upsert=True,
|
| 163 |
+
)
|
| 164 |
+
await _sync_profile_to_user(current_user, update_data)
|
| 165 |
+
doc = await db.user_profiles.find_one({"user_id": current_user.id}) or {
|
| 166 |
+
"user_id": current_user.id
|
| 167 |
+
}
|
| 168 |
+
return _profile_response(doc, current_user)
|
| 169 |
+
|
| 170 |
+
|
| 171 |
+
class ClearDataRequest(BaseModel):
|
| 172 |
+
profile: bool = False
|
| 173 |
+
chats: bool = False
|
| 174 |
+
canvas: bool = False
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
@router.post("/users/me/clear-data")
|
| 178 |
+
async def clear_user_data(
|
| 179 |
+
req: ClearDataRequest,
|
| 180 |
+
current_user: User = Depends(get_current_active_user),
|
| 181 |
+
) -> Dict[str, List[str]]:
|
| 182 |
+
db = get_database()
|
| 183 |
+
cleared: List[str] = []
|
| 184 |
+
|
| 185 |
+
if req.profile:
|
| 186 |
+
await db.user_profiles.delete_many({"user_id": current_user.id})
|
| 187 |
+
await db.onboarding_conversations.delete_many({"user_id": current_user.id})
|
| 188 |
+
cleared.append("profile")
|
| 189 |
+
|
| 190 |
+
if req.chats:
|
| 191 |
+
result = await db.chat_sessions.update_many(
|
| 192 |
+
{"user_id": current_user.id, "is_active": True},
|
| 193 |
+
{"$set": {"is_active": False, "updated_at": datetime.utcnow()}},
|
| 194 |
+
)
|
| 195 |
+
cleared.append(f"chats ({result.modified_count})")
|
| 196 |
+
|
| 197 |
+
if req.canvas:
|
| 198 |
+
await db.phd_canvases.delete_many({"user_id": str(current_user.id)})
|
| 199 |
+
cleared.append("canvas")
|
| 200 |
+
|
| 201 |
+
return {"cleared": cleared}
|
multi_llm_chatbot_backend/app/api/routes/voice.py
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import asyncio
|
| 2 |
+
import html as html_module
|
| 3 |
+
import logging
|
| 4 |
+
import re
|
| 5 |
+
import struct
|
| 6 |
+
import subprocess
|
| 7 |
+
import tempfile
|
| 8 |
+
from pathlib import Path
|
| 9 |
+
from typing import Dict, List, Optional
|
| 10 |
+
from urllib.parse import quote
|
| 11 |
+
|
| 12 |
+
import httpx
|
| 13 |
+
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
|
| 14 |
+
from fastapi.responses import Response
|
| 15 |
+
from pydantic import BaseModel
|
| 16 |
+
|
| 17 |
+
from app.core.auth import get_current_active_user
|
| 18 |
+
from app.models.user import User
|
| 19 |
+
from app.config import get_settings
|
| 20 |
+
|
| 21 |
+
logger = logging.getLogger(__name__)
|
| 22 |
+
|
| 23 |
+
router = APIRouter()
|
| 24 |
+
|
| 25 |
+
voice_settings = get_settings().voice
|
| 26 |
+
TTS_BASE = voice_settings.tts_endpoint
|
| 27 |
+
STT_BASE = voice_settings.stt_endpoint
|
| 28 |
+
|
| 29 |
+
HTTP_TIMEOUT = 120
|
| 30 |
+
# Coqui uses GET /synthesize/{text}; keep chunks small for URL limits.
|
| 31 |
+
MAX_SYNTH_CHUNK = 200
|
| 32 |
+
|
| 33 |
+
_SECTION_HEADERS = re.compile(
|
| 34 |
+
r"\b(Thought|What to do|Next step)\s*[:.]?\s*", flags=re.IGNORECASE
|
| 35 |
+
)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
class TTSRequest(BaseModel):
|
| 39 |
+
text: str
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def _rough_spoken_text(raw: str) -> str:
|
| 43 |
+
"""Light cleanup so model markdown is tolerable for TTS (no extra deps)."""
|
| 44 |
+
t = raw.strip()
|
| 45 |
+
t = re.sub(r"```[\s\S]*?```", " ", t)
|
| 46 |
+
t = re.sub(r"`([^`]+)`", r"\1", t)
|
| 47 |
+
t = re.sub(r"\[([^\]]+)\]\([^)]+\)", r"\1", t)
|
| 48 |
+
t = re.sub(r"!\[([^\]]*)\]\([^)]+\)", r"\1", t)
|
| 49 |
+
t = re.sub(r"^#{1,6}\s+", "", t, flags=re.MULTILINE)
|
| 50 |
+
t = re.sub(r"[*_]{1,3}", "", t)
|
| 51 |
+
t = html_module.unescape(t)
|
| 52 |
+
t = _SECTION_HEADERS.sub(" ", t)
|
| 53 |
+
t = re.sub(r"\s+", " ", t).strip()
|
| 54 |
+
return t
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def _text_chunks(text: str, max_len: int = MAX_SYNTH_CHUNK) -> List[str]:
|
| 58 |
+
text = text.strip()
|
| 59 |
+
if not text:
|
| 60 |
+
return []
|
| 61 |
+
out: List[str] = []
|
| 62 |
+
while text:
|
| 63 |
+
if len(text) <= max_len:
|
| 64 |
+
out.append(text)
|
| 65 |
+
break
|
| 66 |
+
cut = text.rfind(" ", 0, max_len)
|
| 67 |
+
if cut <= 0:
|
| 68 |
+
cut = max_len
|
| 69 |
+
piece = text[:cut].strip()
|
| 70 |
+
if piece:
|
| 71 |
+
out.append(piece)
|
| 72 |
+
text = text[cut:].strip()
|
| 73 |
+
return out
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
async def _synthesize_one(client: httpx.AsyncClient, chunk: str) -> Optional[bytes]:
|
| 77 |
+
url = f"{TTS_BASE}/synthesize/{quote(chunk, safe='')}"
|
| 78 |
+
try:
|
| 79 |
+
r = await client.get(url)
|
| 80 |
+
r.raise_for_status()
|
| 81 |
+
return r.content
|
| 82 |
+
except Exception as exc:
|
| 83 |
+
logger.warning("TTS chunk failed (%s chars): %s", len(chunk), exc)
|
| 84 |
+
return None
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _concat_wav(segments: List[bytes]) -> bytes:
|
| 88 |
+
if len(segments) == 1:
|
| 89 |
+
return segments[0]
|
| 90 |
+
|
| 91 |
+
pcm_parts: List[bytes] = []
|
| 92 |
+
first_header = b""
|
| 93 |
+
|
| 94 |
+
for i, seg in enumerate(segments):
|
| 95 |
+
# PCM WAV files begin with a 44-byte header; if the segment is shorter,
|
| 96 |
+
# then it cannot be valid WAV audio
|
| 97 |
+
if len(seg) < 44:
|
| 98 |
+
continue
|
| 99 |
+
if i == 0:
|
| 100 |
+
first_header = seg[:44]
|
| 101 |
+
data_offset = 44
|
| 102 |
+
idx = seg.find(b"data")
|
| 103 |
+
if idx != -1 and idx + 8 <= len(seg):
|
| 104 |
+
data_offset = idx + 8
|
| 105 |
+
pcm_parts.append(seg[data_offset:])
|
| 106 |
+
|
| 107 |
+
if not pcm_parts or not first_header:
|
| 108 |
+
return segments[0] if segments else b""
|
| 109 |
+
|
| 110 |
+
all_pcm = b"".join(pcm_parts)
|
| 111 |
+
pcm_len = len(all_pcm)
|
| 112 |
+
|
| 113 |
+
header = bytearray(first_header)
|
| 114 |
+
struct.pack_into("<I", header, 4, pcm_len + 36)
|
| 115 |
+
struct.pack_into("<I", header, 40, pcm_len)
|
| 116 |
+
return bytes(header) + all_pcm
|
| 117 |
+
|
| 118 |
+
|
| 119 |
+
def _convert_to_wav(audio_bytes: bytes, src_mime: str) -> bytes:
|
| 120 |
+
with tempfile.TemporaryDirectory() as tmp:
|
| 121 |
+
ext = "webm" if "webm" in (src_mime or "") else "ogg"
|
| 122 |
+
src = Path(tmp) / f"in.{ext}"
|
| 123 |
+
dst = Path(tmp) / "out.wav"
|
| 124 |
+
src.write_bytes(audio_bytes)
|
| 125 |
+
result = subprocess.run(
|
| 126 |
+
[
|
| 127 |
+
"ffmpeg",
|
| 128 |
+
"-y",
|
| 129 |
+
"-i",
|
| 130 |
+
str(src),
|
| 131 |
+
"-ar",
|
| 132 |
+
"16000",
|
| 133 |
+
"-ac",
|
| 134 |
+
"1",
|
| 135 |
+
"-f",
|
| 136 |
+
"wav",
|
| 137 |
+
str(dst),
|
| 138 |
+
],
|
| 139 |
+
capture_output=True,
|
| 140 |
+
timeout=30,
|
| 141 |
+
)
|
| 142 |
+
if result.returncode != 0:
|
| 143 |
+
logger.warning(
|
| 144 |
+
"ffmpeg stderr: %s",
|
| 145 |
+
result.stderr.decode(errors="replace")[-500:],
|
| 146 |
+
)
|
| 147 |
+
raise RuntimeError("ffmpeg conversion failed")
|
| 148 |
+
return dst.read_bytes()
|
| 149 |
+
|
| 150 |
+
|
| 151 |
+
@router.get("/voice/status")
|
| 152 |
+
async def voice_status(
|
| 153 |
+
current_user: User = Depends(get_current_active_user),
|
| 154 |
+
) -> Dict[str, bool]:
|
| 155 |
+
tts_ready = False
|
| 156 |
+
stt_ready = False
|
| 157 |
+
async with httpx.AsyncClient(timeout=5) as client:
|
| 158 |
+
if TTS_BASE not in ("", None):
|
| 159 |
+
try:
|
| 160 |
+
resp = await client.get(f"{TTS_BASE}/status")
|
| 161 |
+
tts_ready = resp.status_code == 200
|
| 162 |
+
except Exception:
|
| 163 |
+
tts_ready = False
|
| 164 |
+
if STT_BASE not in ("", None):
|
| 165 |
+
try:
|
| 166 |
+
resp = await client.get(f"{STT_BASE}/status")
|
| 167 |
+
stt_ready = resp.status_code == 200
|
| 168 |
+
except Exception:
|
| 169 |
+
stt_ready = False
|
| 170 |
+
return {"tts_ready": tts_ready, "stt_ready": stt_ready}
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
@router.post("/voice/transcribe")
|
| 174 |
+
async def transcribe_audio(
|
| 175 |
+
audio: UploadFile = File(...),
|
| 176 |
+
current_user: User = Depends(get_current_active_user),
|
| 177 |
+
) -> Dict[str, str]:
|
| 178 |
+
"""
|
| 179 |
+
Transcribe an uploaded audio file and return the text.
|
| 180 |
+
@param audio: The uploaded audio file (from a browser recording)
|
| 181 |
+
@param current_user: The authenticated user making the request
|
| 182 |
+
@return: A dictionary with the transcribed text under key "text"
|
| 183 |
+
"""
|
| 184 |
+
contents = await audio.read()
|
| 185 |
+
if not contents:
|
| 186 |
+
return {"text": ""}
|
| 187 |
+
|
| 188 |
+
mime = audio.content_type or "audio/webm"
|
| 189 |
+
logger.info("STT: received %s bytes (%s)", len(contents), mime)
|
| 190 |
+
|
| 191 |
+
need_convert = "wav" not in mime.lower()
|
| 192 |
+
if need_convert:
|
| 193 |
+
try:
|
| 194 |
+
loop = asyncio.get_running_loop()
|
| 195 |
+
wav_bytes = await loop.run_in_executor(
|
| 196 |
+
None, _convert_to_wav, contents, mime
|
| 197 |
+
)
|
| 198 |
+
logger.info("STT: converted to WAV (%s bytes)", len(wav_bytes))
|
| 199 |
+
except Exception as e:
|
| 200 |
+
logger.error("STT conversion error: %s", e)
|
| 201 |
+
raise HTTPException(status_code=500, detail="Audio conversion failed")
|
| 202 |
+
else:
|
| 203 |
+
wav_bytes = contents
|
| 204 |
+
|
| 205 |
+
try:
|
| 206 |
+
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
|
| 207 |
+
resp = await client.post(
|
| 208 |
+
f"{STT_BASE}/stt",
|
| 209 |
+
content=wav_bytes,
|
| 210 |
+
headers={"Content-Type": "audio/wav"},
|
| 211 |
+
)
|
| 212 |
+
resp.raise_for_status()
|
| 213 |
+
text = resp.text.strip().strip('"')
|
| 214 |
+
logger.info("STT result: %r", text[:100])
|
| 215 |
+
return {"text": text}
|
| 216 |
+
except httpx.TimeoutException:
|
| 217 |
+
raise HTTPException(status_code=504, detail="STT service timed out")
|
| 218 |
+
except Exception as e:
|
| 219 |
+
logger.error("STT proxy error: %s", e)
|
| 220 |
+
raise HTTPException(status_code=502, detail="STT service unavailable")
|
| 221 |
+
|
| 222 |
+
|
| 223 |
+
@router.post("/voice/tts")
|
| 224 |
+
async def text_to_speech(
|
| 225 |
+
req: TTSRequest,
|
| 226 |
+
current_user: User = Depends(get_current_active_user),
|
| 227 |
+
) -> Response:
|
| 228 |
+
"""
|
| 229 |
+
Generate spoken audio for the input text
|
| 230 |
+
@param req: A TTSRequest containing the text to synthesize
|
| 231 |
+
@param current_user: The authenticated user making the request
|
| 232 |
+
@return: A Response containing the synthesized audio in WAV format
|
| 233 |
+
"""
|
| 234 |
+
raw = req.text.strip()
|
| 235 |
+
if not raw:
|
| 236 |
+
raise HTTPException(status_code=400, detail="Text is required")
|
| 237 |
+
|
| 238 |
+
if len(raw) > 5000:
|
| 239 |
+
raw = raw[:5000]
|
| 240 |
+
|
| 241 |
+
spoken = _rough_spoken_text(raw)
|
| 242 |
+
chunks = _text_chunks(spoken)
|
| 243 |
+
logger.info(
|
| 244 |
+
"TTS: %s chunk(s) from %s input chars → %s spoken chars",
|
| 245 |
+
len(chunks),
|
| 246 |
+
len(req.text),
|
| 247 |
+
len(spoken),
|
| 248 |
+
)
|
| 249 |
+
|
| 250 |
+
try:
|
| 251 |
+
async with httpx.AsyncClient(timeout=HTTP_TIMEOUT) as client:
|
| 252 |
+
results = await asyncio.gather(
|
| 253 |
+
*[_synthesize_one(client, c) for c in chunks]
|
| 254 |
+
)
|
| 255 |
+
wav_segments = [r for r in results if r and len(r) > 44]
|
| 256 |
+
|
| 257 |
+
if not wav_segments:
|
| 258 |
+
raise HTTPException(
|
| 259 |
+
status_code=502, detail="TTS synthesis failed for all chunks"
|
| 260 |
+
)
|
| 261 |
+
|
| 262 |
+
combined = _concat_wav(wav_segments)
|
| 263 |
+
return Response(content=combined, media_type="audio/wav")
|
| 264 |
+
except HTTPException:
|
| 265 |
+
raise
|
| 266 |
+
except httpx.TimeoutException:
|
| 267 |
+
raise HTTPException(status_code=504, detail="TTS service timed out")
|
| 268 |
+
except Exception as e:
|
| 269 |
+
logger.error("TTS proxy error: %s", e)
|
| 270 |
+
raise HTTPException(status_code=502, detail="TTS service unavailable")
|
multi_llm_chatbot_backend/app/api/utils.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional
|
| 2 |
+
from fastapi import Request
|
| 3 |
+
from app.core.session_manager import get_session_manager
|
| 4 |
+
from app.core.database import get_database
|
| 5 |
+
from bson import ObjectId
|
| 6 |
+
import logging
|
| 7 |
+
|
| 8 |
+
logger = logging.getLogger(__name__)
|
| 9 |
+
session_manager = get_session_manager()
|
| 10 |
+
|
| 11 |
+
async def load_chat_session_into_context(chat_session_id: str, user_id: str) -> str:
|
| 12 |
+
"""
|
| 13 |
+
Load a chat session from MongoDB into memory context - ENHANCED DEBUG VERSION
|
| 14 |
+
"""
|
| 15 |
+
try:
|
| 16 |
+
db = get_database()
|
| 17 |
+
|
| 18 |
+
# Add enhanced debugging
|
| 19 |
+
logger.info(f"=== LOADING CHAT SESSION DEBUG ===")
|
| 20 |
+
logger.info(f"Attempting to load chat_session_id: {chat_session_id}")
|
| 21 |
+
logger.info(f"For user_id: {user_id}")
|
| 22 |
+
|
| 23 |
+
# Try to find the session with enhanced debugging
|
| 24 |
+
chat_session = await db.chat_sessions.find_one({
|
| 25 |
+
"_id": ObjectId(chat_session_id),
|
| 26 |
+
"user_id": ObjectId(user_id),
|
| 27 |
+
"deleted_at": {"$exists": False}
|
| 28 |
+
})
|
| 29 |
+
|
| 30 |
+
if not chat_session:
|
| 31 |
+
logger.warning(f"Chat session {chat_session_id} not found for user {user_id}")
|
| 32 |
+
|
| 33 |
+
# Debug: Check if session exists for any user
|
| 34 |
+
try:
|
| 35 |
+
session_exists = await db.chat_sessions.find_one({"_id": ObjectId(chat_session_id)})
|
| 36 |
+
if session_exists:
|
| 37 |
+
logger.warning(f"Session exists but for different user: {session_exists.get('user_id')}")
|
| 38 |
+
logger.warning(f"Expected user: {user_id}")
|
| 39 |
+
logger.warning(f"Session user: {session_exists.get('user_id')}")
|
| 40 |
+
logger.warning(f"User ID types - Expected: {type(user_id)}, Found: {type(session_exists.get('user_id'))}")
|
| 41 |
+
else:
|
| 42 |
+
logger.warning(f"Session {chat_session_id} does not exist in database at all")
|
| 43 |
+
except Exception as debug_error:
|
| 44 |
+
logger.error(f"Error during session debug: {debug_error}")
|
| 45 |
+
|
| 46 |
+
# Debug: List recent sessions for this user
|
| 47 |
+
try:
|
| 48 |
+
recent_sessions = await db.chat_sessions.find(
|
| 49 |
+
{"user_id": ObjectId(user_id), "deleted_at": {"$exists": False}}
|
| 50 |
+
).limit(5).to_list(5)
|
| 51 |
+
logger.info(f"Recent sessions for user {user_id}: {[str(s['_id']) for s in recent_sessions]}")
|
| 52 |
+
except Exception as debug_error:
|
| 53 |
+
logger.error(f"Error listing recent sessions: {debug_error}")
|
| 54 |
+
|
| 55 |
+
return None
|
| 56 |
+
|
| 57 |
+
logger.info(f"✅ Found chat session: {chat_session.get('title', 'Untitled')}")
|
| 58 |
+
logger.info(f"✅ Message count: {len(chat_session.get('messages', []))}")
|
| 59 |
+
|
| 60 |
+
# Create consistent memory session ID
|
| 61 |
+
memory_session_id = f"chat_{chat_session_id}"
|
| 62 |
+
logger.info(f"✅ Creating memory session: {memory_session_id}")
|
| 63 |
+
|
| 64 |
+
# Get session manager and create memory session
|
| 65 |
+
session_manager = get_session_manager()
|
| 66 |
+
memory_session = session_manager.get_session(memory_session_id)
|
| 67 |
+
|
| 68 |
+
# Clear any existing data
|
| 69 |
+
memory_session.clear_all_data()
|
| 70 |
+
|
| 71 |
+
# Load messages into memory session
|
| 72 |
+
messages = chat_session.get('messages', [])
|
| 73 |
+
for msg_data in messages:
|
| 74 |
+
try:
|
| 75 |
+
msg_type = msg_data.get('type', '')
|
| 76 |
+
if msg_type == 'user':
|
| 77 |
+
role = 'user'
|
| 78 |
+
elif msg_type == 'advisor':
|
| 79 |
+
role = msg_data.get('persona_id') or 'assistant'
|
| 80 |
+
else:
|
| 81 |
+
role = 'assistant'
|
| 82 |
+
message = {
|
| 83 |
+
'id': msg_data.get('id', 'unknown'),
|
| 84 |
+
'role': role,
|
| 85 |
+
'content': msg_data.get('content', ''),
|
| 86 |
+
'timestamp': msg_data.get('timestamp', '')
|
| 87 |
+
}
|
| 88 |
+
memory_session.append_message(message['role'], message['content'])
|
| 89 |
+
|
| 90 |
+
# Store original message for export
|
| 91 |
+
if not hasattr(memory_session, 'original_messages'):
|
| 92 |
+
memory_session.original_messages = []
|
| 93 |
+
|
| 94 |
+
memory_session.original_messages.append(message)
|
| 95 |
+
except Exception as msg_error:
|
| 96 |
+
logger.error(f"Error loading message: {msg_error}")
|
| 97 |
+
continue
|
| 98 |
+
|
| 99 |
+
logger.info(f"Loaded {len(messages)} messages into session {memory_session_id}")
|
| 100 |
+
return memory_session_id
|
| 101 |
+
|
| 102 |
+
except Exception as e:
|
| 103 |
+
logger.error(f"Error loading chat session into context: {e}")
|
| 104 |
+
import traceback
|
| 105 |
+
logger.error(f"Full traceback: {traceback.format_exc()}")
|
| 106 |
+
return None
|
| 107 |
+
|
| 108 |
+
async def get_or_create_session_for_request_async(
|
| 109 |
+
request: Request,
|
| 110 |
+
session_id_override: Optional[str] = None,
|
| 111 |
+
chat_session_id: Optional[str] = None,
|
| 112 |
+
user_id: Optional[str] = None
|
| 113 |
+
) -> str:
|
| 114 |
+
"""
|
| 115 |
+
Async version that properly handles chat session loading
|
| 116 |
+
"""
|
| 117 |
+
|
| 118 |
+
# Case 1: Loading an existing chat session
|
| 119 |
+
if chat_session_id and user_id:
|
| 120 |
+
memory_session_id = f"chat_{chat_session_id}"
|
| 121 |
+
loaded = await load_chat_session_into_context(chat_session_id, user_id)
|
| 122 |
+
if loaded:
|
| 123 |
+
return loaded
|
| 124 |
+
# Keep a stable session id even when MongoDB load fails (e.g. race on first message)
|
| 125 |
+
session_manager.get_session(memory_session_id)
|
| 126 |
+
return memory_session_id
|
| 127 |
+
|
| 128 |
+
# Case 2: Explicit session ID provided
|
| 129 |
+
if session_id_override:
|
| 130 |
+
return session_id_override
|
| 131 |
+
|
| 132 |
+
# Case 3: Check for session header
|
| 133 |
+
session_header = request.headers.get("X-Session-ID")
|
| 134 |
+
if session_header:
|
| 135 |
+
return session_header
|
| 136 |
+
|
| 137 |
+
# Case 4: Create a truly new session
|
| 138 |
+
new_session_id = session_manager.create_session()
|
| 139 |
+
logger.info(f"Created new session: {new_session_id}")
|
| 140 |
+
return new_session_id
|
multi_llm_chatbot_backend/app/assets/avatars/advisor1.png
ADDED
|
|
Git LFS Details
|
multi_llm_chatbot_backend/app/assets/avatars/advisor2.png
ADDED
|
|
Git LFS Details
|
multi_llm_chatbot_backend/app/assets/avatars/advisor3.png
ADDED
|
|
Git LFS Details
|
multi_llm_chatbot_backend/app/assets/avatars/advisor4.png
ADDED
|
|
Git LFS Details
|
multi_llm_chatbot_backend/app/assets/avatars/advisor5.png
ADDED
|
|
Git LFS Details
|
multi_llm_chatbot_backend/app/assets/avatars/advisor6.png
ADDED
|
|
Git LFS Details
|
multi_llm_chatbot_backend/app/assets/avatars/advisor7.png
ADDED
|
|
Git LFS Details
|
multi_llm_chatbot_backend/app/config.py
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Centralized application configuration.
|
| 3 |
+
|
| 4 |
+
Reads ``config.yaml`` from the project root (two levels above this file) and
|
| 5 |
+
validates it with Pydantic models. Every setting falls back to environment
|
| 6 |
+
variables when the YAML value is empty, so existing ``.env`` workflows keep
|
| 7 |
+
working.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import os
|
| 11 |
+
import logging
|
| 12 |
+
import colorsys
|
| 13 |
+
from pathlib import Path
|
| 14 |
+
from typing import Any, Dict, List, Optional
|
| 15 |
+
from colorhash import ColorHash
|
| 16 |
+
|
| 17 |
+
import httpx
|
| 18 |
+
import yaml
|
| 19 |
+
from pydantic import BaseModel, validator, Field, model_validator
|
| 20 |
+
|
| 21 |
+
from app.utils.avatar_helpers import get_bundled_avatar_path
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
# ---------------------------------------------------------------------------
|
| 26 |
+
# Pydantic models
|
| 27 |
+
# ---------------------------------------------------------------------------
|
| 28 |
+
|
| 29 |
+
class _IconValidatorMixin(BaseModel):
|
| 30 |
+
"""Validates that the ``icon`` field is a known Lucide icon name."""
|
| 31 |
+
|
| 32 |
+
@model_validator(mode="after")
|
| 33 |
+
def _validate_icon(self):
|
| 34 |
+
from app.utils.lucide_icons import get_valid_icon_names
|
| 35 |
+
|
| 36 |
+
valid = get_valid_icon_names()
|
| 37 |
+
if valid and self.icon not in valid:
|
| 38 |
+
raise ValueError(
|
| 39 |
+
f"Unknown icon {self.icon!r}. "
|
| 40 |
+
f"Must be a valid Lucide icon name."
|
| 41 |
+
)
|
| 42 |
+
return self
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
class FeatureConfig(_IconValidatorMixin):
|
| 46 |
+
title: str = ""
|
| 47 |
+
description: str = ""
|
| 48 |
+
icon: str = "HelpCircle"
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
class UserAvatarOption(BaseModel):
|
| 52 |
+
id: str
|
| 53 |
+
icon: str = "User"
|
| 54 |
+
color: str = "#2563EB"
|
| 55 |
+
bg: str = "#EFF6FF"
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
class AppConfig(BaseModel):
|
| 59 |
+
title: str = "Advisor Canvas"
|
| 60 |
+
subtitle: str = "AI-Powered Guidance"
|
| 61 |
+
primary_color: str = "#7C3AED"
|
| 62 |
+
logo_icon: str = "Users"
|
| 63 |
+
footer_text: str = ""
|
| 64 |
+
user_avatars: List[UserAvatarOption] = []
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
class HomepageConfig(BaseModel):
|
| 68 |
+
headline_prefix: str = "Get Guidance from"
|
| 69 |
+
headline_highlight: str = "Advisor Personas"
|
| 70 |
+
description: str = ""
|
| 71 |
+
features_title: str = "Why Choose Our Advisory Panel?"
|
| 72 |
+
features: List[FeatureConfig] = []
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class AcademicStage(BaseModel):
|
| 76 |
+
value: str = ""
|
| 77 |
+
label: str = ""
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
class LoginConfig(BaseModel):
|
| 81 |
+
subtitle: str = "Sign in to continue"
|
| 82 |
+
signup_subtitle: str = "Create your account to get personalized guidance from expert advisors"
|
| 83 |
+
academic_stages: List[AcademicStage] = []
|
| 84 |
+
knowledge_levels: List[AcademicStage] = []
|
| 85 |
+
timezones: List[AcademicStage] = []
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class ExampleCategory(_IconValidatorMixin):
|
| 89 |
+
title: str
|
| 90 |
+
icon: str = "BookOpen"
|
| 91 |
+
color: str = "#3B82F6"
|
| 92 |
+
bg_color: str = "#EFF6FF"
|
| 93 |
+
suggestions: List[str] = []
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class ChatPageConfig(BaseModel):
|
| 97 |
+
placeholder: str = "Ask your advisors anything..."
|
| 98 |
+
examples: List[ExampleCategory] = []
|
| 99 |
+
|
| 100 |
+
|
| 101 |
+
class PersonaItemConfig(_IconValidatorMixin):
|
| 102 |
+
id: str
|
| 103 |
+
name: str
|
| 104 |
+
enabled: bool = True
|
| 105 |
+
role: str = ""
|
| 106 |
+
summary: str = ""
|
| 107 |
+
color: Optional[str] = None
|
| 108 |
+
bg_color: Optional[str] = None
|
| 109 |
+
dark_color: Optional[str] = None
|
| 110 |
+
dark_bg_color: Optional[str] = None
|
| 111 |
+
icon: str = "HelpCircle"
|
| 112 |
+
avatar: Optional[str] = None
|
| 113 |
+
temperature: int = 5
|
| 114 |
+
persona_prompt: str = ""
|
| 115 |
+
|
| 116 |
+
@model_validator(mode='after')
|
| 117 |
+
def _auto_generate_colors(self):
|
| 118 |
+
if self.color is None:
|
| 119 |
+
generated = generate_persona_colors(self.name)
|
| 120 |
+
self.color = generated["color"]
|
| 121 |
+
self.bg_color = generated["bg_color"]
|
| 122 |
+
self.dark_color = generated["dark_color"]
|
| 123 |
+
self.dark_bg_color = generated["dark_bg_color"]
|
| 124 |
+
return self
|
| 125 |
+
|
| 126 |
+
def _resolve_image(self) -> str:
|
| 127 |
+
"""Resolve the persona's visual representation as a URI string.
|
| 128 |
+
|
| 129 |
+
Returns the avatar as a URI the frontend can dispatch on by scheme:
|
| 130 |
+
|
| 131 |
+
- ``https://…`` / ``http://…`` - external image URL
|
| 132 |
+
- ``/api/avatars/bundled/…`` - server-relative path for a bundled file
|
| 133 |
+
- ``icon://<LucideIconName>`` - render a Lucide icon component
|
| 134 |
+
|
| 135 |
+
Falls back to ``icon://`` when a bundled avatar name doesn't match a
|
| 136 |
+
file on disk or when an external URL is unreachable.
|
| 137 |
+
"""
|
| 138 |
+
if self.avatar is None:
|
| 139 |
+
return f"icon://{self.icon}"
|
| 140 |
+
if self.avatar.startswith(("http://", "https://")):
|
| 141 |
+
try:
|
| 142 |
+
resp = httpx.head(self.avatar, timeout=5, follow_redirects=True)
|
| 143 |
+
if resp.is_success:
|
| 144 |
+
return self.avatar
|
| 145 |
+
logger.warning(
|
| 146 |
+
"Avatar URL %r returned status %d for persona %r, falling back to icon.",
|
| 147 |
+
self.avatar, resp.status_code, self.id,
|
| 148 |
+
)
|
| 149 |
+
except httpx.HTTPError as exc:
|
| 150 |
+
logger.warning(
|
| 151 |
+
"Avatar URL %r unreachable for persona %r (%s), falling back to icon.",
|
| 152 |
+
self.avatar, self.id, exc,
|
| 153 |
+
)
|
| 154 |
+
return f"icon://{self.icon}"
|
| 155 |
+
if get_bundled_avatar_path(self.avatar) is None:
|
| 156 |
+
logger.warning(
|
| 157 |
+
"Bundled avatar %r not found for persona %r, falling back to icon.",
|
| 158 |
+
self.avatar, self.id,
|
| 159 |
+
)
|
| 160 |
+
return f"icon://{self.icon}"
|
| 161 |
+
# Default to empty string (→ relative URL) so single-origin Spaces
|
| 162 |
+
# deployments serve avatars off the same host as the SPA. Local
|
| 163 |
+
# ``npm start`` development setups can still set REACT_APP_API_URL
|
| 164 |
+
# explicitly to point at the backend on a different port.
|
| 165 |
+
base = os.getenv("REACT_APP_API_URL", "").rstrip("/")
|
| 166 |
+
return f"{base}/api/avatars/bundled/{self.avatar}"
|
| 167 |
+
|
| 168 |
+
def to_frontend_config(self) -> dict:
|
| 169 |
+
return {
|
| 170 |
+
"id": self.id,
|
| 171 |
+
"name": self.name,
|
| 172 |
+
"role": self.role,
|
| 173 |
+
"summary": self.summary,
|
| 174 |
+
"color": self.color,
|
| 175 |
+
"bg_color": self.bg_color,
|
| 176 |
+
"dark_color": self.dark_color,
|
| 177 |
+
"dark_bg_color": self.dark_bg_color,
|
| 178 |
+
"image": self._resolve_image(),
|
| 179 |
+
}
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
class PersonasConfig(BaseModel):
|
| 183 |
+
base_prompt: str = ""
|
| 184 |
+
personas_dir: str = ""
|
| 185 |
+
config_dir: str = ""
|
| 186 |
+
items: List[PersonaItemConfig] = []
|
| 187 |
+
|
| 188 |
+
@model_validator(mode='after')
|
| 189 |
+
def _load_personas_from_directory(self):
|
| 190 |
+
if self.personas_dir:
|
| 191 |
+
dir_path = Path(self.personas_dir)
|
| 192 |
+
if not dir_path.is_absolute() and self.config_dir:
|
| 193 |
+
dir_path = Path(self.config_dir) / dir_path
|
| 194 |
+
loaded = load_personas_from_dir(str(dir_path))
|
| 195 |
+
if loaded:
|
| 196 |
+
self.items = loaded
|
| 197 |
+
logger.info(f"Loaded {len(loaded)} personas.")
|
| 198 |
+
else:
|
| 199 |
+
logger.warning(f"No personas found in {self.personas_dir}. falling back to personas.items config")
|
| 200 |
+
return self
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
class OrchestratorConfig(BaseModel):
|
| 204 |
+
min_words_without_keywords: int = 6
|
| 205 |
+
conversation_history_token_threshold: int = 4000
|
| 206 |
+
specific_keywords: List[str] = []
|
| 207 |
+
clarification_questions: List[str] = [
|
| 208 |
+
"Could you provide more details about what you need help with?"]
|
| 209 |
+
clarification_suggestions: List[str] = [
|
| 210 |
+
"Provide more details about your question."]
|
| 211 |
+
|
| 212 |
+
@model_validator(mode="after")
|
| 213 |
+
def validate_clarificaiton_questions(self):
|
| 214 |
+
if len(self.clarification_questions) < 1:
|
| 215 |
+
raise ValueError("At least one clarification question is required.")
|
| 216 |
+
return self
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
def resolve_jwt_secret(explicit: str = "") -> str:
|
| 220 |
+
"""Resolve a stable JWT signing secret across restarts.
|
| 221 |
+
|
| 222 |
+
Precedence: config value → JWT_SECRET_KEY env → persisted file under
|
| 223 |
+
DATA_DIR (generated on first run) → insecure dev fallback.
|
| 224 |
+
"""
|
| 225 |
+
if explicit and explicit.strip():
|
| 226 |
+
return explicit.strip()
|
| 227 |
+
|
| 228 |
+
env_secret = os.getenv("JWT_SECRET_KEY", "").strip()
|
| 229 |
+
if env_secret:
|
| 230 |
+
return env_secret
|
| 231 |
+
|
| 232 |
+
data_dir = Path(os.environ.get("DATA_DIR") or Path.home() / "data")
|
| 233 |
+
secret_path = data_dir / "jwt_secret"
|
| 234 |
+
try:
|
| 235 |
+
data_dir.mkdir(parents=True, exist_ok=True)
|
| 236 |
+
if secret_path.is_file():
|
| 237 |
+
stored = secret_path.read_text(encoding="utf-8").strip()
|
| 238 |
+
if stored:
|
| 239 |
+
return stored
|
| 240 |
+
import secrets
|
| 241 |
+
|
| 242 |
+
generated = secrets.token_urlsafe(48)
|
| 243 |
+
secret_path.write_text(generated, encoding="utf-8")
|
| 244 |
+
logger.info("Generated persistent JWT secret at %s", secret_path)
|
| 245 |
+
return generated
|
| 246 |
+
except OSError as exc:
|
| 247 |
+
logger.warning(
|
| 248 |
+
"Cannot persist JWT secret at %s (%s); using insecure default. "
|
| 249 |
+
"Set JWT_SECRET_KEY or auth.jwt_secret for production.",
|
| 250 |
+
secret_path,
|
| 251 |
+
exc,
|
| 252 |
+
)
|
| 253 |
+
return "your-secret-key-change-me"
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
class AuthConfig(BaseModel):
|
| 257 |
+
jwt_secret: str = Field(default=os.getenv("JWT_SECRET_KEY", ""))
|
| 258 |
+
algorithm: str = "HS256"
|
| 259 |
+
token_expiry_minutes: int = 43200 # 30 days
|
| 260 |
+
|
| 261 |
+
@model_validator(mode="after")
|
| 262 |
+
def _validate_jwt_secret(self):
|
| 263 |
+
self.jwt_secret = resolve_jwt_secret(self.jwt_secret)
|
| 264 |
+
return self
|
| 265 |
+
|
| 266 |
+
|
| 267 |
+
class MongoDBConfig(BaseModel):
|
| 268 |
+
connection_string: str = Field(default=os.getenv("MONGODB_CONNECTION_STRING"))
|
| 269 |
+
database_name: str = "phd_advisor"
|
| 270 |
+
|
| 271 |
+
@model_validator(mode="after")
|
| 272 |
+
def _warn_connection_envvar(self):
|
| 273 |
+
if os.getenv("MONGODB_CONNECTION_STRING"):
|
| 274 |
+
if self.connection_string != os.getenv("MONGODB_CONNECTION_STRING"):
|
| 275 |
+
logger.warning(
|
| 276 |
+
"MONGODB_CONNECTION_STRING envvar is overridden in "
|
| 277 |
+
"config.yaml"
|
| 278 |
+
)
|
| 279 |
+
else:
|
| 280 |
+
logger.warning(
|
| 281 |
+
"MongoDB connection string not set in config.yaml. "
|
| 282 |
+
"Falling back to MONGODB_CONNECTION_STRING envvar."
|
| 283 |
+
)
|
| 284 |
+
return self
|
| 285 |
+
|
| 286 |
+
|
| 287 |
+
class GeminiConfig(BaseModel):
|
| 288 |
+
api_key: str = Field(default=os.getenv("GEMINI_API_KEY"))
|
| 289 |
+
model: str = "gemini-2.5-flash"
|
| 290 |
+
|
| 291 |
+
@model_validator(mode="after")
|
| 292 |
+
def _warn_gemini_envvar(self):
|
| 293 |
+
if os.getenv("GEMINI_API_KEY"):
|
| 294 |
+
if self.api_key != os.getenv("GEMINI_API_KEY"):
|
| 295 |
+
logger.warning(
|
| 296 |
+
"GEMINI_API_KEY envvar is overridden in config.yaml"
|
| 297 |
+
)
|
| 298 |
+
else:
|
| 299 |
+
logger.warning(
|
| 300 |
+
"Gemini API key not set in config.yaml. "
|
| 301 |
+
"Falling back to GEMINI_API_KEY environment variable."
|
| 302 |
+
)
|
| 303 |
+
return self
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
class OllamaConfig(BaseModel):
|
| 307 |
+
model: str = "llama3.2:1b"
|
| 308 |
+
# TODO: Drop support for `OLLAMA_BASE_URL` envvar handling
|
| 309 |
+
base_url: str = Field(default=os.getenv("OLLAMA_BASE_URL", "http://localhost:11434"))
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
class VllmConfig(BaseModel):
|
| 313 |
+
api_url: str = ""
|
| 314 |
+
api_key: str = Field(default=os.getenv("VLLM_API_KEY", ""))
|
| 315 |
+
api_username: str = Field(default=os.getenv("VLLM_API_USERNAME", ""))
|
| 316 |
+
model_id: str = ""
|
| 317 |
+
neon_persona_orchestrator: str = "vanilla"
|
| 318 |
+
neon_persona_advisors: str = "CybersecurityExpert"
|
| 319 |
+
|
| 320 |
+
|
| 321 |
+
class OpenAIConfig(BaseModel):
|
| 322 |
+
api_key: str = Field(default=os.getenv("OPENAI_API_KEY", ""))
|
| 323 |
+
model: str = "gpt-5.4"
|
| 324 |
+
orchestrator_reasoning_effort: str = "low"
|
| 325 |
+
persona_reasoning_effort: str = "none"
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
class ResilientConfig(BaseModel):
|
| 329 |
+
race_timeout_seconds: float = 3.0
|
| 330 |
+
|
| 331 |
+
|
| 332 |
+
class LLMConfig(BaseModel):
|
| 333 |
+
provider: str = "gemini"
|
| 334 |
+
gemini: GeminiConfig = GeminiConfig()
|
| 335 |
+
ollama: OllamaConfig = OllamaConfig()
|
| 336 |
+
vllm: VllmConfig = VllmConfig()
|
| 337 |
+
openai: OpenAIConfig = OpenAIConfig()
|
| 338 |
+
resilient: ResilientConfig = ResilientConfig()
|
| 339 |
+
|
| 340 |
+
|
| 341 |
+
class RAGConfig(BaseModel):
|
| 342 |
+
embedding_model: str = "all-MiniLM-L6-v2"
|
| 343 |
+
chroma_collection: str = "phd_advisor_documents"
|
| 344 |
+
|
| 345 |
+
|
| 346 |
+
class ToolsConfig(BaseModel):
|
| 347 |
+
model_config = {"extra": "allow"}
|
| 348 |
+
|
| 349 |
+
def get_enabled_names(self) -> List[str]:
|
| 350 |
+
"""Return tool names whose config has ``enabled: true``."""
|
| 351 |
+
return [
|
| 352 |
+
name
|
| 353 |
+
for name, cfg in self.__pydantic_extra__.items()
|
| 354 |
+
if isinstance(cfg, dict) and cfg.get("enabled", True)
|
| 355 |
+
]
|
| 356 |
+
|
| 357 |
+
def get_tool_config(self, name: str) -> Dict[str, Any]:
|
| 358 |
+
"""Return the raw config dict for a single tool, or ``{}``."""
|
| 359 |
+
cfg = self.__pydantic_extra__.get(name, {})
|
| 360 |
+
return cfg if isinstance(cfg, dict) else {}
|
| 361 |
+
|
| 362 |
+
|
| 363 |
+
class VoiceConfig(BaseModel):
|
| 364 |
+
stt_endpoint: str = "https://whisper.neonaiservices.com"
|
| 365 |
+
tts_endpoint: str = "https://coqui.neonaiservices.com"
|
| 366 |
+
|
| 367 |
+
|
| 368 |
+
class AppSettings(BaseModel):
|
| 369 |
+
"""Top-level container that mirrors the YAML structure."""
|
| 370 |
+
app: AppConfig = AppConfig()
|
| 371 |
+
homepage: HomepageConfig = HomepageConfig()
|
| 372 |
+
login: LoginConfig = LoginConfig()
|
| 373 |
+
chat_page: ChatPageConfig = ChatPageConfig()
|
| 374 |
+
personas: PersonasConfig = PersonasConfig()
|
| 375 |
+
orchestrator: OrchestratorConfig = OrchestratorConfig()
|
| 376 |
+
auth: AuthConfig = AuthConfig()
|
| 377 |
+
mongodb: MongoDBConfig = MongoDBConfig()
|
| 378 |
+
llm: LLMConfig = LLMConfig()
|
| 379 |
+
rag: RAGConfig = RAGConfig()
|
| 380 |
+
tools: ToolsConfig = ToolsConfig()
|
| 381 |
+
voice: VoiceConfig = VoiceConfig()
|
| 382 |
+
|
| 383 |
+
# ------------------------------------------------------------------
|
| 384 |
+
# Convenience helpers
|
| 385 |
+
# ------------------------------------------------------------------
|
| 386 |
+
|
| 387 |
+
def get_frontend_config(self) -> dict:
|
| 388 |
+
"""Return the subset of configuration safe to expose to the frontend
|
| 389 |
+
via ``GET /api/config``. Secrets are excluded."""
|
| 390 |
+
return {
|
| 391 |
+
"app": self.app.dict(),
|
| 392 |
+
"homepage": self.homepage.dict(),
|
| 393 |
+
"login": self.login.dict(),
|
| 394 |
+
"chat_page": self.chat_page.dict(),
|
| 395 |
+
"personas": {
|
| 396 |
+
"items": [p.to_frontend_config() for p in self.personas.items],
|
| 397 |
+
},
|
| 398 |
+
}
|
| 399 |
+
|
| 400 |
+
|
| 401 |
+
# ---------------------------------------------------------------------------
|
| 402 |
+
# Singleton loader
|
| 403 |
+
# ---------------------------------------------------------------------------
|
| 404 |
+
|
| 405 |
+
_settings: Optional[AppSettings] = None
|
| 406 |
+
|
| 407 |
+
|
| 408 |
+
def load_settings(config_path: Optional[str] = None) -> AppSettings:
|
| 409 |
+
"""Load and validate ``config.yaml``, returning an ``AppSettings`` object.
|
| 410 |
+
|
| 411 |
+
The result is cached as a module-level singleton so subsequent calls are
|
| 412 |
+
free. Pass *config_path* to override the auto-detected location (useful
|
| 413 |
+
for tests).
|
| 414 |
+
"""
|
| 415 |
+
global _settings
|
| 416 |
+
if _settings is not None:
|
| 417 |
+
return _settings
|
| 418 |
+
|
| 419 |
+
config_path = config_path or os.getenv("CONFIG_PATH")
|
| 420 |
+
if not config_path:
|
| 421 |
+
logger.warning("No CONFIG_PATH specified. Using default values")
|
| 422 |
+
raw = {}
|
| 423 |
+
else:
|
| 424 |
+
path = Path(config_path)
|
| 425 |
+
if not path.exists():
|
| 426 |
+
raise FileNotFoundError(f"Configuration file not found at {config_path}")
|
| 427 |
+
logger.info(f"Loading configuration from {path}")
|
| 428 |
+
with open(path, "r", encoding="utf-8") as fh:
|
| 429 |
+
raw = yaml.safe_load(fh) or {}
|
| 430 |
+
|
| 431 |
+
personas_cfg = raw.setdefault("personas", {})
|
| 432 |
+
|
| 433 |
+
if config_path:
|
| 434 |
+
personas_cfg["config_dir"] = str(Path(config_path).parent)
|
| 435 |
+
|
| 436 |
+
_settings = AppSettings(**raw)
|
| 437 |
+
logger.info(f"Configuration loaded: app.title={_settings.app.title}")
|
| 438 |
+
return _settings
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
def get_settings() -> AppSettings:
|
| 442 |
+
"""Return the cached settings singleton (loads on first call)."""
|
| 443 |
+
return load_settings()
|
| 444 |
+
|
| 445 |
+
|
| 446 |
+
# ---------------------------------------------------------------------------
|
| 447 |
+
# Helper Functions
|
| 448 |
+
# ---------------------------------------------------------------------------
|
| 449 |
+
|
| 450 |
+
|
| 451 |
+
def load_personas_from_dir(personas_dir: str) -> List[PersonaItemConfig]:
|
| 452 |
+
"""Load persona configs from individual YAML files in a directory.
|
| 453 |
+
|
| 454 |
+
Each file is validated independently — invalid files are skipped with a
|
| 455 |
+
warning. Duplicate ids/names and disabled personas are filtered out.
|
| 456 |
+
"""
|
| 457 |
+
|
| 458 |
+
dir_path = Path(personas_dir)
|
| 459 |
+
if not dir_path.is_dir():
|
| 460 |
+
logger.warning(f"Personas directory not found: {personas_dir}")
|
| 461 |
+
return []
|
| 462 |
+
|
| 463 |
+
personas: List[PersonaItemConfig] = []
|
| 464 |
+
seen_ids: dict[str, str] = {} # id -> filename that defined it
|
| 465 |
+
seen_names: dict[str, str] = {} # name -> filename that defined it
|
| 466 |
+
|
| 467 |
+
# sorting files alphabetically ensures consistent and predictable loading order
|
| 468 |
+
for filepath in sorted(dir_path.glob("*.yaml")):
|
| 469 |
+
try:
|
| 470 |
+
with open(filepath, "r", encoding="utf-8") as fh:
|
| 471 |
+
raw = yaml.safe_load(fh) or {}
|
| 472 |
+
persona = PersonaItemConfig(**raw)
|
| 473 |
+
except Exception as exc:
|
| 474 |
+
logger.warning(f"Skipping invalid persona file {filepath.name}: {exc}")
|
| 475 |
+
continue
|
| 476 |
+
|
| 477 |
+
if not persona.enabled:
|
| 478 |
+
logger.info(f"Persona '{persona.id}' is disabled, skipping")
|
| 479 |
+
continue
|
| 480 |
+
|
| 481 |
+
if persona.id in seen_ids:
|
| 482 |
+
logger.warning(
|
| 483 |
+
f"Duplicate persona id '{persona.id}' in {filepath.name} "
|
| 484 |
+
f"(already defined in {seen_ids[persona.id]}), skipping"
|
| 485 |
+
)
|
| 486 |
+
continue
|
| 487 |
+
|
| 488 |
+
if persona.name in seen_names:
|
| 489 |
+
logger.warning(
|
| 490 |
+
f"Duplicate persona name '{persona.name}' in {filepath.name} "
|
| 491 |
+
f"(already defined in {seen_names[persona.name]}), skipping"
|
| 492 |
+
)
|
| 493 |
+
continue
|
| 494 |
+
|
| 495 |
+
seen_ids[persona.id] = filepath.name
|
| 496 |
+
seen_names[persona.name] = filepath.name
|
| 497 |
+
personas.append(persona)
|
| 498 |
+
|
| 499 |
+
logger.info(f"Loaded {len(personas)} persona(s) from {personas_dir}")
|
| 500 |
+
return personas
|
| 501 |
+
|
| 502 |
+
|
| 503 |
+
def generate_persona_colors(name: str) -> dict:
|
| 504 |
+
"""Deterministically generate four theme colors from a persona name."""
|
| 505 |
+
|
| 506 |
+
ch = ColorHash(name.lower(), lightness=[0.55], saturation=[0.65])
|
| 507 |
+
hue = ch.hsl[0] # grab the hue colorhash picked
|
| 508 |
+
h = hue / 360
|
| 509 |
+
|
| 510 |
+
def hsl_to_hex(h, s, l):
|
| 511 |
+
r, g, b = colorsys.hls_to_rgb(h, l, s)
|
| 512 |
+
return f"#{int(r*255):02X}{int(g*255):02X}{int(b*255):02X}"
|
| 513 |
+
|
| 514 |
+
return {
|
| 515 |
+
"color": hsl_to_hex(h, 0.65, 0.55),
|
| 516 |
+
"bg_color": hsl_to_hex(h, 0.60, 0.95),
|
| 517 |
+
"dark_color": hsl_to_hex(h, 0.70, 0.70),
|
| 518 |
+
"dark_bg_color": hsl_to_hex(h, 0.65, 0.25),
|
| 519 |
+
}
|
multi_llm_chatbot_backend/app/core/README.md
ADDED
|
@@ -0,0 +1,200 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# `app/core` – Application Core Logic
|
| 2 |
+
|
| 3 |
+
This is the **central brain** of the multi-LLM chatbot system. It orchestrates user interaction, persona logic, context management, document-based retrieval (RAG), session tracking, authentication, and initialization.
|
| 4 |
+
|
| 5 |
+
---
|
| 6 |
+
|
| 7 |
+
## Overview of Modules
|
| 8 |
+
|
| 9 |
+
| Module | Responsibility |
|
| 10 |
+
|--------|----------------|
|
| 11 |
+
| `auth.py` | Authentication (JWT, password hashing, user resolution) |
|
| 12 |
+
| `bootstrap.py` | System startup logic: loads LLMs, personas, orchestrators |
|
| 13 |
+
| `context.py` | Global per-session context (simplified storage) |
|
| 14 |
+
| `context_manager.py` | Core context formatting & windowing for Gemini/Ollama |
|
| 15 |
+
| `database.py` | MongoDB connection & index management |
|
| 16 |
+
| `improved_orchestrator.py` | Main message routing engine: document-aware, multi-persona orchestrator |
|
| 17 |
+
| `rag_manager.py` | RAG with ChromaDB: chunking, storage, semantic search |
|
| 18 |
+
| `session_manager.py` | Full chat lifecycle tracker (in-memory) with RAG hooks |
|
| 19 |
+
|
| 20 |
+
---
|
| 21 |
+
|
| 22 |
+
## `auth.py` – Authentication System
|
| 23 |
+
|
| 24 |
+
Handles secure authentication via:
|
| 25 |
+
- Bcrypt hashing (`passlib`)
|
| 26 |
+
- JWT creation and validation (`python-jose`)
|
| 27 |
+
- Secure route access using FastAPI’s `Depends`
|
| 28 |
+
|
| 29 |
+
### Functions
|
| 30 |
+
|
| 31 |
+
- `get_password_hash(password)` – Hash password using bcrypt
|
| 32 |
+
- `verify_password(plain, hashed)` – Verify password
|
| 33 |
+
- `create_access_token(data)` – Return JWT (30-day expiry default)
|
| 34 |
+
- `get_current_user()` – Decodes token and returns `User` model
|
| 35 |
+
- `authenticate_user(email, password)` – Checks login credentials
|
| 36 |
+
- `create_user_response(user)` – Returns `UserResponse` for frontend
|
| 37 |
+
|
| 38 |
+
---
|
| 39 |
+
|
| 40 |
+
## `bootstrap.py` – System Bootstrap
|
| 41 |
+
|
| 42 |
+
Runs once on app startup to:
|
| 43 |
+
- Determine the default LLM provider (Gemini or Ollama)
|
| 44 |
+
- Initialize `ImprovedChatOrchestrator`
|
| 45 |
+
- Inject personas using `get_default_personas(llm)`
|
| 46 |
+
|
| 47 |
+
```python
|
| 48 |
+
llm = create_llm_client() # Gemini or Ollama
|
| 49 |
+
chat_orchestrator = ImprovedChatOrchestrator()
|
| 50 |
+
DEFAULT_PERSONAS = get_default_personas(llm)
|
| 51 |
+
```
|
| 52 |
+
|
| 53 |
+
Each persona is **registered** into the orchestrator using `.register_persona()`.
|
| 54 |
+
|
| 55 |
+
---
|
| 56 |
+
|
| 57 |
+
## `context.py` – Global Per-Session Context
|
| 58 |
+
|
| 59 |
+
A basic context storage class (`GlobalSessionContext`) that keeps:
|
| 60 |
+
- `full_log`: List of all messages
|
| 61 |
+
- `uploaded_files`: Tracked files per session
|
| 62 |
+
- `total_upload_size`: Helps enforce limits
|
| 63 |
+
|
| 64 |
+
Used primarily in earlier versions or smaller contexts.
|
| 65 |
+
|
| 66 |
+
---
|
| 67 |
+
|
| 68 |
+
## `context_manager.py` – LLM Context Window Formatter
|
| 69 |
+
|
| 70 |
+
This class builds optimized context windows for both Gemini and Ollama:
|
| 71 |
+
|
| 72 |
+
### `ContextManager.prepare_context_for_llm()`
|
| 73 |
+
Returns a `ContextWindow(messages, token_count, truncated)` with:
|
| 74 |
+
- LLM-specific formatting
|
| 75 |
+
- Automatic message pruning based on token limits
|
| 76 |
+
- Recency- and relevance-weighted scoring for old messages
|
| 77 |
+
- Automatic stop tokens, system prompts, and formatting
|
| 78 |
+
|
| 79 |
+
### Key Features
|
| 80 |
+
|
| 81 |
+
| Feature | Gemini | Ollama |
|
| 82 |
+
|--------|--------|--------|
|
| 83 |
+
| Format | JSON roles + parts | Flat prompt string |
|
| 84 |
+
| Role Mapping | 'user', 'model' | 'User:', 'Assistant:' |
|
| 85 |
+
| Chunking Strategy | Full doc as `Context Document:` | Plain text injection |
|
| 86 |
+
| Stop Sequences | Customizable | Enforced via `stop[]` |
|
| 87 |
+
|
| 88 |
+
Used **by all LLM clients** (Ollama/Gemini) and the **orchestrator**.
|
| 89 |
+
|
| 90 |
+
---
|
| 91 |
+
|
| 92 |
+
## `database.py` – MongoDB Connector
|
| 93 |
+
|
| 94 |
+
- Uses `motor` for async MongoDB
|
| 95 |
+
- Exposes `get_database()` to other modules
|
| 96 |
+
- Automatically creates indexes on `users` and `chat_sessions`
|
| 97 |
+
- Controlled via `.env` (`MONGODB_CONNECTION_STRING`)
|
| 98 |
+
|
| 99 |
+
```python
|
| 100 |
+
await connect_to_mongo()
|
| 101 |
+
await close_mongo_connection()
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
---
|
| 105 |
+
|
| 106 |
+
## `improved_orchestrator.py` – Brain of the Chatbot
|
| 107 |
+
|
| 108 |
+
This is the main **message routing engine**.
|
| 109 |
+
|
| 110 |
+
### Main Responsibilities
|
| 111 |
+
|
| 112 |
+
- Route user input through:
|
| 113 |
+
- Clarification detection
|
| 114 |
+
- Document-aware context building
|
| 115 |
+
- Persona-level response generation
|
| 116 |
+
- Aggregate responses from **multiple advisors**
|
| 117 |
+
- Embed document-based context (RAG)
|
| 118 |
+
|
| 119 |
+
### Key Functions
|
| 120 |
+
|
| 121 |
+
- `process_message()` – Entry point for chat flow (calls all advisors)
|
| 122 |
+
- `chat_with_persona()` – Talk to one specific advisor
|
| 123 |
+
- `_generate_persona_responses()` – Routes through each registered persona
|
| 124 |
+
- `_build_enhanced_context_for_persona()` – Combines conversation + document info
|
| 125 |
+
|
| 126 |
+
### Extras
|
| 127 |
+
- Document parsing hints (`"my thesis"`, `"section 2"`, etc.)
|
| 128 |
+
- Top-K persona ranking (`get_top_personas()`)
|
| 129 |
+
- Persona-specific fallback logic
|
| 130 |
+
- Session reset/deletion
|
| 131 |
+
|
| 132 |
+
Used by `/chat-stream`, `/reply-to-advisor`, etc.
|
| 133 |
+
|
| 134 |
+
---
|
| 135 |
+
|
| 136 |
+
## `rag_manager.py` – RAG System for Docs
|
| 137 |
+
|
| 138 |
+
Supports **vector-based retrieval** using:
|
| 139 |
+
|
| 140 |
+
- Sentence Transformers (`all-MiniLM-L6-v2`)
|
| 141 |
+
- ChromaDB (`PersistentClient` with metadata)
|
| 142 |
+
- Metadata-aware enhanced chunking
|
| 143 |
+
- Overlapping token window strategy
|
| 144 |
+
- Section-wise classification
|
| 145 |
+
|
| 146 |
+
### Core Components
|
| 147 |
+
|
| 148 |
+
| Class | Role |
|
| 149 |
+
|-------|------|
|
| 150 |
+
| `RAGManager` | Standard chunking, basic RAG |
|
| 151 |
+
| `EnhancedRAGManager` | Persona-aware + metadata-annotated vector chunks |
|
| 152 |
+
|
| 153 |
+
### `EnhancedRAGManager` supports:
|
| 154 |
+
- Section tagging (`methodology`, `theory`, etc.)
|
| 155 |
+
- Multi-level filters (`session_id`, `filename`)
|
| 156 |
+
- Attribution fields (`chunk_position`, `has_methodology`)
|
| 157 |
+
- Relevance scoring and ranking
|
| 158 |
+
|
| 159 |
+
Used by orchestrator when generating document-aware responses.
|
| 160 |
+
|
| 161 |
+
---
|
| 162 |
+
|
| 163 |
+
## `session_manager.py` – Chat Lifecycle Controller
|
| 164 |
+
|
| 165 |
+
Handles:
|
| 166 |
+
- In-memory session creation + cleanup (with expiration)
|
| 167 |
+
- Tracks uploaded files and size
|
| 168 |
+
- Holds message logs for each session
|
| 169 |
+
- Links to RAG via `add_uploaded_file()` and `get_rag_stats()`
|
| 170 |
+
|
| 171 |
+
### `ConversationContext`
|
| 172 |
+
|
| 173 |
+
| Attribute | Description |
|
| 174 |
+
|-----------|-------------|
|
| 175 |
+
| `messages` | List of role-message entries |
|
| 176 |
+
| `uploaded_files` | Filenames (content stored in RAG DB) |
|
| 177 |
+
| `document_chunks_count` | Count of indexed doc chunks |
|
| 178 |
+
| `last_retrieval_stats` | From last RAG search |
|
| 179 |
+
| `created_at`, `last_accessed` | Session activity tracking |
|
| 180 |
+
|
| 181 |
+
Includes:
|
| 182 |
+
- Reset functions (`clear_all_data()`)
|
| 183 |
+
- File-level message logging (`append_message()`)
|
| 184 |
+
|
| 185 |
+
### `SessionManager`
|
| 186 |
+
|
| 187 |
+
- Thread-safe via locks
|
| 188 |
+
- Handles cleanup of expired sessions (`_cleanup_expired_sessions()`)
|
| 189 |
+
- Returns statistics via `get_session_stats()`
|
| 190 |
+
|
| 191 |
+
---
|
| 192 |
+
|
| 193 |
+
## Interactions Summary
|
| 194 |
+
|
| 195 |
+
```text
|
| 196 |
+
User Input → Orchestrator
|
| 197 |
+
↳ SessionManager → Context
|
| 198 |
+
↳ RAGManager → Relevant Docs
|
| 199 |
+
↳ LLMClient (Gemini/Ollama) ← ContextManager
|
| 200 |
+
```
|
multi_llm_chatbot_backend/app/core/auth.py
ADDED
|
@@ -0,0 +1,119 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
from datetime import datetime, timedelta
|
| 3 |
+
from typing import Optional
|
| 4 |
+
from fastapi import HTTPException, Depends, status
|
| 5 |
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
| 6 |
+
from passlib.context import CryptContext
|
| 7 |
+
from jose import JWTError, jwt
|
| 8 |
+
from bson import ObjectId
|
| 9 |
+
from app.core.database import get_database
|
| 10 |
+
from app.models.user import User, UserResponse
|
| 11 |
+
from app.config import get_settings
|
| 12 |
+
|
| 13 |
+
# Password hashing
|
| 14 |
+
pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
|
| 15 |
+
|
| 16 |
+
# JWT settings — driven by config.yaml with env-var fallback
|
| 17 |
+
_cfg = get_settings().auth
|
| 18 |
+
SECRET_KEY = _cfg.jwt_secret
|
| 19 |
+
ALGORITHM = _cfg.algorithm
|
| 20 |
+
ACCESS_TOKEN_EXPIRE_MINUTES = _cfg.token_expiry_minutes
|
| 21 |
+
|
| 22 |
+
# Security scheme
|
| 23 |
+
security = HTTPBearer()
|
| 24 |
+
|
| 25 |
+
def verify_password(plain_password: str, hashed_password: str) -> bool:
|
| 26 |
+
"""Verify a password against its hash"""
|
| 27 |
+
return pwd_context.verify(plain_password, hashed_password)
|
| 28 |
+
|
| 29 |
+
def get_password_hash(password: str) -> str:
|
| 30 |
+
"""Hash a password"""
|
| 31 |
+
return pwd_context.hash(password)
|
| 32 |
+
|
| 33 |
+
def create_access_token(data: dict, expires_delta: Optional[timedelta] = None):
|
| 34 |
+
"""Create a JWT access token"""
|
| 35 |
+
to_encode = data.copy()
|
| 36 |
+
if expires_delta:
|
| 37 |
+
expire = datetime.utcnow() + expires_delta
|
| 38 |
+
else:
|
| 39 |
+
expire = datetime.utcnow() + timedelta(minutes=ACCESS_TOKEN_EXPIRE_MINUTES)
|
| 40 |
+
|
| 41 |
+
# JWT NumericDate must be a Unix timestamp (RFC 7519).
|
| 42 |
+
to_encode.update({"exp": int(expire.timestamp())})
|
| 43 |
+
encoded_jwt = jwt.encode(to_encode, SECRET_KEY, algorithm=ALGORITHM)
|
| 44 |
+
return encoded_jwt
|
| 45 |
+
|
| 46 |
+
async def get_user_by_email(email: str) -> Optional[User]:
|
| 47 |
+
"""Get user by email from database"""
|
| 48 |
+
db = get_database()
|
| 49 |
+
user_data = await db.users.find_one({"email": email})
|
| 50 |
+
if user_data:
|
| 51 |
+
return User(**user_data)
|
| 52 |
+
return None
|
| 53 |
+
|
| 54 |
+
async def get_user_by_id(user_id: str) -> Optional[User]:
|
| 55 |
+
"""Get user by ID from database"""
|
| 56 |
+
try:
|
| 57 |
+
db = get_database()
|
| 58 |
+
user_data = await db.users.find_one({"_id": ObjectId(user_id)})
|
| 59 |
+
if user_data:
|
| 60 |
+
return User(**user_data)
|
| 61 |
+
return None
|
| 62 |
+
except Exception:
|
| 63 |
+
return None
|
| 64 |
+
|
| 65 |
+
async def authenticate_user(email: str, password: str) -> Optional[User]:
|
| 66 |
+
"""Authenticate user with email and password"""
|
| 67 |
+
user = await get_user_by_email(email)
|
| 68 |
+
if not user:
|
| 69 |
+
return None
|
| 70 |
+
if not verify_password(password, user.hashed_password):
|
| 71 |
+
return None
|
| 72 |
+
return user
|
| 73 |
+
|
| 74 |
+
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)) -> User:
|
| 75 |
+
"""Get current user from JWT token"""
|
| 76 |
+
credentials_exception = HTTPException(
|
| 77 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 78 |
+
detail="Could not validate credentials",
|
| 79 |
+
headers={"WWW-Authenticate": "Bearer"},
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
try:
|
| 83 |
+
payload = jwt.decode(credentials.credentials, SECRET_KEY, algorithms=[ALGORITHM])
|
| 84 |
+
user_id: str = payload.get("sub")
|
| 85 |
+
if user_id is None:
|
| 86 |
+
raise credentials_exception
|
| 87 |
+
except JWTError:
|
| 88 |
+
raise credentials_exception
|
| 89 |
+
|
| 90 |
+
user = await get_user_by_id(user_id)
|
| 91 |
+
if user is None:
|
| 92 |
+
raise credentials_exception
|
| 93 |
+
|
| 94 |
+
if not user.is_active:
|
| 95 |
+
raise HTTPException(
|
| 96 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 97 |
+
detail="Inactive user"
|
| 98 |
+
)
|
| 99 |
+
return user
|
| 100 |
+
|
| 101 |
+
async def get_current_active_user(current_user: User = Depends(get_current_user)) -> User:
|
| 102 |
+
"""Get current active user"""
|
| 103 |
+
if not current_user.is_active:
|
| 104 |
+
raise HTTPException(status_code=400, detail="Inactive user")
|
| 105 |
+
return current_user
|
| 106 |
+
|
| 107 |
+
def create_user_response(user: User) -> UserResponse:
|
| 108 |
+
"""Create user response model"""
|
| 109 |
+
return UserResponse(
|
| 110 |
+
id=str(user.id),
|
| 111 |
+
firstName=user.firstName,
|
| 112 |
+
lastName=user.lastName,
|
| 113 |
+
email=user.email,
|
| 114 |
+
academicStage=user.academicStage,
|
| 115 |
+
researchArea=user.researchArea,
|
| 116 |
+
avatarId=user.avatarId,
|
| 117 |
+
created_at=user.created_at,
|
| 118 |
+
last_login=user.last_login
|
| 119 |
+
)
|
multi_llm_chatbot_backend/app/core/bootstrap.py
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# app/core/bootstrap.py
|
| 2 |
+
import os
|
| 3 |
+
from pathlib import Path
|
| 4 |
+
|
| 5 |
+
from app.core.env_loader import load_application_env
|
| 6 |
+
|
| 7 |
+
load_application_env()
|
| 8 |
+
|
| 9 |
+
from app.config import get_settings
|
| 10 |
+
from app.llm.improved_gemini_client import ImprovedGeminiClient
|
| 11 |
+
from app.llm.improved_ollama_client import ImprovedOllamaClient
|
| 12 |
+
from app.llm.improved_vllm_client import ImprovedVllmClient
|
| 13 |
+
from app.llm.openai_fallback_client import OpenAIFallbackClient
|
| 14 |
+
from app.llm.resilient_client import ResilientLLMClient
|
| 15 |
+
from app.core.improved_orchestrator import ImprovedChatOrchestrator
|
| 16 |
+
from app.models.default_personas import get_default_personas
|
| 17 |
+
|
| 18 |
+
settings = get_settings()
|
| 19 |
+
|
| 20 |
+
current_provider = settings.llm.provider or "vllm"
|
| 21 |
+
available_providers = ["ollama", "gemini", "vllm", "openai"]
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _load_shared_env_var(name: str) -> str:
|
| 25 |
+
explicit = os.environ.get("SHARED_ENV", "").strip()
|
| 26 |
+
candidates = [Path(explicit)] if explicit else []
|
| 27 |
+
candidates.append(Path.home() / ".secrets" / "shared.env")
|
| 28 |
+
prefix = f"{name}="
|
| 29 |
+
for shared in candidates:
|
| 30 |
+
if not shared.is_file():
|
| 31 |
+
continue
|
| 32 |
+
for line in shared.read_text(encoding="utf-8").splitlines():
|
| 33 |
+
line = line.strip()
|
| 34 |
+
if line.startswith(prefix):
|
| 35 |
+
return line.split("=", 1)[1].strip()
|
| 36 |
+
return ""
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def _vllm_api_username() -> str:
|
| 40 |
+
"""Optional HTTP Basic auth username for the vLLM endpoint.
|
| 41 |
+
|
| 42 |
+
The Neon BrainForge/Security endpoint at 4090-x1-3 requires HTTP Basic
|
| 43 |
+
auth using HANA_USERNAME_KLATCHAT / HANA_KLATCHAT_PASSWORD from
|
| 44 |
+
~/.secrets/shared.env. We allow explicit override via api_username
|
| 45 |
+
in config or VLLM_API_USERNAME env var, falling back to the shared
|
| 46 |
+
HANA_USERNAME_KLATCHAT entry.
|
| 47 |
+
"""
|
| 48 |
+
return (
|
| 49 |
+
settings.llm.vllm.api_username
|
| 50 |
+
or os.getenv("VLLM_API_USERNAME", "")
|
| 51 |
+
or _load_shared_env_var("HANA_USERNAME_KLATCHAT")
|
| 52 |
+
)
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def _vllm_api_key() -> str:
|
| 56 |
+
"""vLLM key/password. If api_username is set (HANA Basic auth), prefer
|
| 57 |
+
HANA_KLATCHAT_PASSWORD; otherwise use the generic VLLM_API_KEY Bearer
|
| 58 |
+
token. This matches the dual-auth nature of the Neon endpoints.
|
| 59 |
+
"""
|
| 60 |
+
if _vllm_api_username():
|
| 61 |
+
return (
|
| 62 |
+
settings.llm.vllm.api_key
|
| 63 |
+
or os.getenv("HANA_KLATCHAT_PASSWORD", "")
|
| 64 |
+
or _load_shared_env_var("HANA_KLATCHAT_PASSWORD")
|
| 65 |
+
or os.getenv("VLLM_API_KEY", "")
|
| 66 |
+
or _load_shared_env_var("VLLM_API_KEY")
|
| 67 |
+
)
|
| 68 |
+
return (
|
| 69 |
+
settings.llm.vllm.api_key
|
| 70 |
+
or os.getenv("VLLM_API_KEY", "")
|
| 71 |
+
or _load_shared_env_var("VLLM_API_KEY")
|
| 72 |
+
)
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def _openai_api_key() -> str:
|
| 76 |
+
return (
|
| 77 |
+
settings.llm.openai.api_key
|
| 78 |
+
or os.getenv("OPENAI_API_KEY", "")
|
| 79 |
+
or _load_shared_env_var("OPENAI_API_KEY")
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _build_neon_vllm(neon_persona: str | None) -> ImprovedVllmClient:
|
| 84 |
+
vllm = settings.llm.vllm
|
| 85 |
+
if not vllm.api_url:
|
| 86 |
+
raise ValueError("No vLLM endpoint configured. Set llm.vllm.api_url in your config.")
|
| 87 |
+
model_name = vllm.model_id or None
|
| 88 |
+
return ImprovedVllmClient(
|
| 89 |
+
api_url=vllm.api_url,
|
| 90 |
+
api_key=_vllm_api_key(),
|
| 91 |
+
model_name=model_name,
|
| 92 |
+
neon_persona=neon_persona,
|
| 93 |
+
api_username=_vllm_api_username() or None,
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
def _build_openai(reasoning_effort: str) -> OpenAIFallbackClient:
|
| 98 |
+
return OpenAIFallbackClient(
|
| 99 |
+
api_key=_openai_api_key(),
|
| 100 |
+
model=settings.llm.openai.model,
|
| 101 |
+
reasoning_effort=reasoning_effort,
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
def _wrap_resilient(primary: ImprovedVllmClient, fallback: OpenAIFallbackClient, label: str):
|
| 106 |
+
return ResilientLLMClient(
|
| 107 |
+
primary=primary,
|
| 108 |
+
fallback=fallback,
|
| 109 |
+
race_timeout_seconds=settings.llm.resilient.race_timeout_seconds,
|
| 110 |
+
primary_label=label,
|
| 111 |
+
)
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def create_llm_client(provider=None):
|
| 115 |
+
if provider is None:
|
| 116 |
+
provider = current_provider
|
| 117 |
+
if provider == "openai":
|
| 118 |
+
# Standalone OpenAI (GPT) client. OpenAIFallbackClient implements the same
|
| 119 |
+
# LLMClient interface as the Gemini client, so it works as the primary
|
| 120 |
+
# orchestrator/persona LLM, not just a vLLM fallback.
|
| 121 |
+
return _build_openai(settings.llm.openai.orchestrator_reasoning_effort)
|
| 122 |
+
if provider == "gemini":
|
| 123 |
+
return ImprovedGeminiClient(model_name=settings.llm.gemini.model)
|
| 124 |
+
if provider == "vllm":
|
| 125 |
+
neon = settings.llm.vllm.neon_persona_orchestrator
|
| 126 |
+
primary = _build_neon_vllm(neon if neon != "vanilla" else None)
|
| 127 |
+
fallback = _build_openai(settings.llm.openai.orchestrator_reasoning_effort)
|
| 128 |
+
return _wrap_resilient(primary, fallback, "orchestrator")
|
| 129 |
+
return ImprovedOllamaClient(
|
| 130 |
+
model_name=settings.llm.ollama.model,
|
| 131 |
+
base_url=settings.llm.ollama.base_url,
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def create_orchestrator_llm():
|
| 136 |
+
if current_provider != "vllm":
|
| 137 |
+
return create_llm_client()
|
| 138 |
+
neon = settings.llm.vllm.neon_persona_orchestrator
|
| 139 |
+
primary = _build_neon_vllm(neon if neon != "vanilla" else None)
|
| 140 |
+
fallback = _build_openai(settings.llm.openai.orchestrator_reasoning_effort)
|
| 141 |
+
return _wrap_resilient(primary, fallback, "orchestrator")
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def create_persona_llm():
|
| 145 |
+
if current_provider != "vllm":
|
| 146 |
+
return create_llm_client()
|
| 147 |
+
neon = settings.llm.vllm.neon_persona_advisors
|
| 148 |
+
primary = _build_neon_vllm(neon)
|
| 149 |
+
fallback = _build_openai(settings.llm.openai.persona_reasoning_effort)
|
| 150 |
+
return _wrap_resilient(primary, fallback, "persona")
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
llm = create_orchestrator_llm()
|
| 154 |
+
chat_orchestrator = ImprovedChatOrchestrator(llm_client=llm)
|
| 155 |
+
|
| 156 |
+
persona_llm = create_persona_llm() if current_provider == "vllm" else llm
|
| 157 |
+
DEFAULT_PERSONAS = get_default_personas(persona_llm)
|
| 158 |
+
for persona in DEFAULT_PERSONAS:
|
| 159 |
+
chat_orchestrator.register_persona(persona)
|
multi_llm_chatbot_backend/app/core/canvas_analysis.py
ADDED
|
@@ -0,0 +1,287 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import re
|
| 2 |
+
import json
|
| 3 |
+
import logging
|
| 4 |
+
from typing import Dict, List
|
| 5 |
+
from collections import defaultdict
|
| 6 |
+
|
| 7 |
+
from app.models.phd_canvas import CanvasInsight
|
| 8 |
+
from app.config import get_settings
|
| 9 |
+
|
| 10 |
+
logger = logging.getLogger(__name__)
|
| 11 |
+
|
| 12 |
+
class CanvasAnalysisService:
|
| 13 |
+
"""Service for extracting and categorizing insights from chat messages"""
|
| 14 |
+
|
| 15 |
+
def __init__(self, llm_client=None):
|
| 16 |
+
self.llm_client = llm_client
|
| 17 |
+
|
| 18 |
+
# Predefined section mappings for semantic analysis
|
| 19 |
+
self.section_keywords = {
|
| 20 |
+
"research_progress": [
|
| 21 |
+
"progress", "milestone", "completed", "finished", "accomplished",
|
| 22 |
+
"achieved", "timeline", "deadline", "chapter", "draft", "version"
|
| 23 |
+
],
|
| 24 |
+
"methodology": [
|
| 25 |
+
"method", "methodology", "approach", "design", "data collection",
|
| 26 |
+
"survey", "interview", "experiment", "analysis", "statistical",
|
| 27 |
+
"qualitative", "quantitative", "mixed methods"
|
| 28 |
+
],
|
| 29 |
+
"theoretical_framework": [
|
| 30 |
+
"theory", "theoretical", "framework", "concept", "model",
|
| 31 |
+
"literature", "author", "philosophy", "paradigm", "assumption"
|
| 32 |
+
],
|
| 33 |
+
"challenges_obstacles": [
|
| 34 |
+
"challenge", "problem", "difficulty", "obstacle", "stuck",
|
| 35 |
+
"confused", "frustrated", "struggle", "barrier", "issue"
|
| 36 |
+
],
|
| 37 |
+
"next_steps": [
|
| 38 |
+
"next", "plan", "should", "will", "going to", "need to",
|
| 39 |
+
"action", "step", "priority", "focus", "goal", "objective"
|
| 40 |
+
],
|
| 41 |
+
"writing_communication": [
|
| 42 |
+
"write", "writing", "paper", "thesis", "dissertation", "publication",
|
| 43 |
+
"communication", "presentation", "defense", "draft", "revision"
|
| 44 |
+
],
|
| 45 |
+
"career_development": [
|
| 46 |
+
"career", "job", "position", "networking", "conference",
|
| 47 |
+
"skills", "CV", "resume", "application", "fellowship", "grant"
|
| 48 |
+
],
|
| 49 |
+
"literature_review": [
|
| 50 |
+
"literature", "sources", "papers", "articles", "bibliography",
|
| 51 |
+
"citation", "author", "study", "research", "gap", "review"
|
| 52 |
+
],
|
| 53 |
+
"data_analysis": [
|
| 54 |
+
"data", "analysis", "results", "findings", "statistics",
|
| 55 |
+
"coding", "software", "tool", "visualization", "pattern"
|
| 56 |
+
],
|
| 57 |
+
"motivation_mindset": [
|
| 58 |
+
"motivation", "confidence", "stress", "anxiety", "balance",
|
| 59 |
+
"mental health", "mindset", "support", "overwhelmed", "burnout"
|
| 60 |
+
]
|
| 61 |
+
}
|
| 62 |
+
|
| 63 |
+
async def extract_insights_from_messages(self, messages: List[Dict], chat_session_id: str) -> List[CanvasInsight]:
|
| 64 |
+
"""Extract actionable insights from chat messages using semantic analysis"""
|
| 65 |
+
try:
|
| 66 |
+
insights = []
|
| 67 |
+
|
| 68 |
+
for message in messages:
|
| 69 |
+
# Handle the actual stored message format from your database
|
| 70 |
+
msg_type = message.get("type", "")
|
| 71 |
+
|
| 72 |
+
if msg_type == "advisor":
|
| 73 |
+
# This is the format used in your database
|
| 74 |
+
content = message.get("content", "")
|
| 75 |
+
persona_id = message.get("advisorName", message.get("persona", "advisor"))
|
| 76 |
+
message_id = message.get("id", str(message.get("_id", "")))
|
| 77 |
+
|
| 78 |
+
if content and len(content.strip()) > 20: # Only process substantial content
|
| 79 |
+
persona_insights = await self._extract_insights_from_content(
|
| 80 |
+
content, persona_id, message_id, chat_session_id
|
| 81 |
+
)
|
| 82 |
+
insights.extend(persona_insights)
|
| 83 |
+
logger.debug(f"Extracted {len(persona_insights)} insights from {persona_id} message")
|
| 84 |
+
|
| 85 |
+
elif msg_type == "assistant" and "responses" in message:
|
| 86 |
+
# Handle multi-persona responses (if this format exists)
|
| 87 |
+
for response in message.get("responses", []):
|
| 88 |
+
persona_insights = await self._extract_insights_from_persona_response(
|
| 89 |
+
response, message.get("id"), chat_session_id
|
| 90 |
+
)
|
| 91 |
+
insights.extend(persona_insights)
|
| 92 |
+
|
| 93 |
+
elif message.get("role") == "assistant" and message.get("content"):
|
| 94 |
+
# Handle converted message format (from export functions)
|
| 95 |
+
persona_insights = await self._extract_insights_from_content(
|
| 96 |
+
message.get("content", ""), "assistant",
|
| 97 |
+
message.get("id"), chat_session_id
|
| 98 |
+
)
|
| 99 |
+
insights.extend(persona_insights)
|
| 100 |
+
|
| 101 |
+
# Remove duplicates and low-confidence insights
|
| 102 |
+
unique_insights = self._deduplicate_insights(insights)
|
| 103 |
+
high_confidence_insights = [i for i in unique_insights if i.confidence_score >= 0.5] # Lowered threshold
|
| 104 |
+
|
| 105 |
+
logger.info(f"Extracted {len(high_confidence_insights)} insights from {len(messages)} messages")
|
| 106 |
+
return high_confidence_insights
|
| 107 |
+
|
| 108 |
+
except Exception as e:
|
| 109 |
+
logger.error(f"Error extracting insights from messages: {e}")
|
| 110 |
+
import traceback
|
| 111 |
+
logger.error(f"Full traceback: {traceback.format_exc()}")
|
| 112 |
+
return []
|
| 113 |
+
|
| 114 |
+
async def _extract_insights_from_persona_response(self, response: Dict, message_id: str, chat_session_id: str) -> List[CanvasInsight]:
|
| 115 |
+
"""Extract insights from a single persona response"""
|
| 116 |
+
persona_id = response.get("persona_id", "unknown")
|
| 117 |
+
content = response.get("content", "")
|
| 118 |
+
|
| 119 |
+
return await self._extract_insights_from_content(content, persona_id, message_id, chat_session_id)
|
| 120 |
+
|
| 121 |
+
async def _extract_insights_from_content(self, content: str, persona_id: str, message_id: str, chat_session_id: str) -> List[CanvasInsight]:
|
| 122 |
+
"""Extract actionable insights from content using LLM analysis"""
|
| 123 |
+
if not content or len(content.strip()) < 30:
|
| 124 |
+
return []
|
| 125 |
+
|
| 126 |
+
try:
|
| 127 |
+
app_title = get_settings().app.title
|
| 128 |
+
extraction_prompt = f"""
|
| 129 |
+
Extract actionable insights from this {app_title} advisor response that would be valuable for a user's progress summary:
|
| 130 |
+
|
| 131 |
+
PERSONA: {persona_id}
|
| 132 |
+
CONTENT: {content}
|
| 133 |
+
|
| 134 |
+
Return a JSON list of insights. Each insight should be:
|
| 135 |
+
- Actionable and specific to the user's progress
|
| 136 |
+
- 1-2 sentences long
|
| 137 |
+
- Valuable for advisor meetings
|
| 138 |
+
- Not generic advice
|
| 139 |
+
|
| 140 |
+
Format: [{{"insight": "specific actionable insight here", "keywords": ["keyword1", "keyword2"]}}]
|
| 141 |
+
|
| 142 |
+
Return ONLY the JSON array, no other text.
|
| 143 |
+
"""
|
| 144 |
+
|
| 145 |
+
if self.llm_client:
|
| 146 |
+
try:
|
| 147 |
+
llm_response = await self.llm_client.generate(
|
| 148 |
+
system_prompt=f"You are an expert at extracting actionable guidance from {app_title} advisor responses.",
|
| 149 |
+
context=[{"role": "user", "content": extraction_prompt}],
|
| 150 |
+
temperature=0.3,
|
| 151 |
+
max_tokens=1000,
|
| 152 |
+
response_mime_type="application/json"
|
| 153 |
+
)
|
| 154 |
+
|
| 155 |
+
insights_data = json.loads(llm_response)
|
| 156 |
+
insights = []
|
| 157 |
+
|
| 158 |
+
for item in insights_data:
|
| 159 |
+
if isinstance(item, dict) and "insight" in item:
|
| 160 |
+
insight = CanvasInsight(
|
| 161 |
+
content=item["insight"],
|
| 162 |
+
source_persona=persona_id,
|
| 163 |
+
source_message_id=message_id,
|
| 164 |
+
source_chat_session=chat_session_id,
|
| 165 |
+
confidence_score=0.8, # High confidence from LLM extraction
|
| 166 |
+
keywords=item.get("keywords", [])
|
| 167 |
+
)
|
| 168 |
+
insights.append(insight)
|
| 169 |
+
|
| 170 |
+
if insights:
|
| 171 |
+
logger.debug(f"LLM extracted {len(insights)} insights from {persona_id}")
|
| 172 |
+
return insights
|
| 173 |
+
|
| 174 |
+
logger.warning(f"LLM returned no parseable insights for {persona_id}, falling back to rule-based")
|
| 175 |
+
|
| 176 |
+
except Exception as llm_error:
|
| 177 |
+
logger.warning(f"LLM extraction failed for {persona_id}: {llm_error}")
|
| 178 |
+
|
| 179 |
+
# Fallback if no LLM available
|
| 180 |
+
return self._extract_insights_rule_based(content, persona_id, message_id, chat_session_id)
|
| 181 |
+
|
| 182 |
+
except Exception as e:
|
| 183 |
+
logger.error(f"Error extracting insights from {persona_id} content: {e}")
|
| 184 |
+
return []
|
| 185 |
+
|
| 186 |
+
def _extract_insights_rule_based(self, content: str, persona_id: str, message_id: str, chat_session_id: str) -> List[CanvasInsight]:
|
| 187 |
+
"""Fallback rule-based insight extraction"""
|
| 188 |
+
insights = []
|
| 189 |
+
|
| 190 |
+
# Extract actionable sentences
|
| 191 |
+
sentences = re.split(r'[.!?]+', content)
|
| 192 |
+
actionable_patterns = [
|
| 193 |
+
r"(?:you should|consider|try|focus on|prioritize|next step|recommended?|suggest)",
|
| 194 |
+
r"(?:action item|goal|objective|plan|strategy)",
|
| 195 |
+
r"(?:deadline|timeline|schedule|by \w+)",
|
| 196 |
+
r"(?:complete|finish|work on|tackle|address)"
|
| 197 |
+
]
|
| 198 |
+
|
| 199 |
+
for sentence in sentences:
|
| 200 |
+
sentence = sentence.strip()
|
| 201 |
+
if len(sentence) < 15: # Lowered minimum length
|
| 202 |
+
continue
|
| 203 |
+
|
| 204 |
+
match_count = sum(1 for pattern in actionable_patterns if re.search(pattern, sentence, re.IGNORECASE))
|
| 205 |
+
|
| 206 |
+
if match_count > 0:
|
| 207 |
+
keywords = self._extract_keywords_from_sentence(sentence)
|
| 208 |
+
confidence = round(0.55 + min(match_count * 0.05, 0.15), 2)
|
| 209 |
+
|
| 210 |
+
insight = CanvasInsight(
|
| 211 |
+
content=sentence,
|
| 212 |
+
source_persona=persona_id,
|
| 213 |
+
source_message_id=message_id,
|
| 214 |
+
source_chat_session=chat_session_id,
|
| 215 |
+
confidence_score=confidence,
|
| 216 |
+
keywords=keywords
|
| 217 |
+
)
|
| 218 |
+
insights.append(insight)
|
| 219 |
+
|
| 220 |
+
logger.debug(f"Rule-based extracted {len(insights)} insights from {persona_id}")
|
| 221 |
+
return insights[:3] # Limit to 3 insights per message to avoid noise
|
| 222 |
+
|
| 223 |
+
def _extract_keywords_from_sentence(self, sentence: str) -> List[str]:
|
| 224 |
+
"""Extract relevant keywords from a sentence"""
|
| 225 |
+
# Simple keyword extraction
|
| 226 |
+
words = re.findall(r'\b\w{4,}\b', sentence.lower()) # Words with 4+ chars
|
| 227 |
+
|
| 228 |
+
# Filter out common words
|
| 229 |
+
stop_words = {
|
| 230 |
+
"should", "could", "would", "your", "research", "work", "study",
|
| 231 |
+
"consider", "think", "important", "also", "really", "maybe",
|
| 232 |
+
"probably", "definitely", "certainly", "particular", "specific"
|
| 233 |
+
}
|
| 234 |
+
|
| 235 |
+
keywords = [word for word in words if word not in stop_words]
|
| 236 |
+
return keywords[:5] # Limit to 5 keywords
|
| 237 |
+
|
| 238 |
+
def categorize_insights(self, insights: List[CanvasInsight]) -> Dict[str, List[CanvasInsight]]:
|
| 239 |
+
"""Categorize insights into canvas sections using semantic analysis"""
|
| 240 |
+
categorized = defaultdict(list)
|
| 241 |
+
|
| 242 |
+
for insight in insights:
|
| 243 |
+
section = self._determine_section(insight)
|
| 244 |
+
categorized[section].append(insight)
|
| 245 |
+
|
| 246 |
+
return dict(categorized)
|
| 247 |
+
|
| 248 |
+
def _determine_section(self, insight: CanvasInsight) -> str:
|
| 249 |
+
"""Determine which section an insight belongs to"""
|
| 250 |
+
content_lower = insight.content.lower()
|
| 251 |
+
keywords_lower = [k.lower() for k in insight.keywords]
|
| 252 |
+
all_text = content_lower + " " + " ".join(keywords_lower)
|
| 253 |
+
|
| 254 |
+
# Score each section based on keyword matches
|
| 255 |
+
section_scores = {}
|
| 256 |
+
for section, keywords in self.section_keywords.items():
|
| 257 |
+
score = sum(1 for keyword in keywords if keyword in all_text)
|
| 258 |
+
if score > 0:
|
| 259 |
+
section_scores[section] = score
|
| 260 |
+
|
| 261 |
+
# Return the section with highest score, or default
|
| 262 |
+
if section_scores:
|
| 263 |
+
return max(section_scores, key=section_scores.get)
|
| 264 |
+
else:
|
| 265 |
+
return "general_notes" # Default section
|
| 266 |
+
|
| 267 |
+
def prioritize_insights(self, insights: List[CanvasInsight]) -> List[CanvasInsight]:
|
| 268 |
+
"""Sort insights by priority (confidence score and recency)"""
|
| 269 |
+
return sorted(insights, key=lambda x: (x.confidence_score, x.extracted_at), reverse=True)
|
| 270 |
+
|
| 271 |
+
def _deduplicate_insights(self, insights: List[CanvasInsight]) -> List[CanvasInsight]:
|
| 272 |
+
"""Remove duplicate or very similar insights"""
|
| 273 |
+
if not insights:
|
| 274 |
+
return insights
|
| 275 |
+
|
| 276 |
+
unique_insights = []
|
| 277 |
+
seen_content = set()
|
| 278 |
+
|
| 279 |
+
for insight in insights:
|
| 280 |
+
# Simple deduplication based on content similarity
|
| 281 |
+
content_key = insight.content.lower().strip()[:100] # First 100 chars
|
| 282 |
+
|
| 283 |
+
if content_key not in seen_content:
|
| 284 |
+
seen_content.add(content_key)
|
| 285 |
+
unique_insights.append(insight)
|
| 286 |
+
|
| 287 |
+
return unique_insights
|
multi_llm_chatbot_backend/app/core/canvas_database.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PhD Canvas database setup — SQLite-shim aware.
|
| 2 |
+
|
| 3 |
+
The original implementation was a Mongo-only module that created compound
|
| 4 |
+
indexes and a TTL index on the ``phd_canvases`` collection at startup. With
|
| 5 |
+
the SQLite-on-HF-bucket persistence shim, the schema (including the
|
| 6 |
+
``user_id`` index) is declared once at connection time inside
|
| 7 |
+
``app.core.db``, so this module becomes a compatibility no-op.
|
| 8 |
+
|
| 9 |
+
We keep the function signatures so any caller that still imports
|
| 10 |
+
``setup_canvas_collections`` or ``cleanup_old_canvas_data`` keeps working.
|
| 11 |
+
"""
|
| 12 |
+
|
| 13 |
+
import logging
|
| 14 |
+
from typing import Any
|
| 15 |
+
|
| 16 |
+
logger = logging.getLogger(__name__)
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
async def setup_canvas_collections(db: Any) -> None:
|
| 20 |
+
"""No-op on the SQLite shim — indexes are created by ``app.core.db``."""
|
| 21 |
+
logger.info(
|
| 22 |
+
"PhD Canvas setup: schema and indexes are managed by the SQLite shim; "
|
| 23 |
+
"skipping legacy Mongo index creation."
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
async def cleanup_old_canvas_data(db: Any) -> None:
|
| 28 |
+
"""Maintenance helper — disabled on the SQLite shim.
|
| 29 |
+
|
| 30 |
+
The original used a Mongo ``$lookup`` aggregation against the ``users``
|
| 31 |
+
collection, which the in-process aggregation runner doesn't support. If
|
| 32 |
+
you need the orphan cleanup, run it via a separate script that does two
|
| 33 |
+
passes (``users``, then ``phd_canvases``) and calls ``delete_many``.
|
| 34 |
+
"""
|
| 35 |
+
logger.info("cleanup_old_canvas_data is a no-op on the SQLite shim.")
|
multi_llm_chatbot_backend/app/core/canvas_manager.py
ADDED
|
@@ -0,0 +1,408 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import logging
|
| 2 |
+
from typing import Dict, List, Optional
|
| 3 |
+
from datetime import datetime, timedelta
|
| 4 |
+
from bson import ObjectId
|
| 5 |
+
import asyncio
|
| 6 |
+
import hashlib
|
| 7 |
+
|
| 8 |
+
from app.models.phd_canvas import PhdCanvas, CanvasInsight, UpdateCanvasRequest
|
| 9 |
+
from app.core.canvas_analysis import CanvasAnalysisService
|
| 10 |
+
from app.core.bootstrap import llm
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
class CanvasManager:
|
| 15 |
+
"""Manages PhD Canvas creation, updates, and incremental processing"""
|
| 16 |
+
|
| 17 |
+
def __init__(self):
|
| 18 |
+
self.analysis_service = CanvasAnalysisService(llm_client=llm)
|
| 19 |
+
self._db = None
|
| 20 |
+
# Add lock dictionary to prevent concurrent updates for the same user
|
| 21 |
+
self._update_locks = {}
|
| 22 |
+
|
| 23 |
+
def get_database(self):
|
| 24 |
+
"""Lazy database connection to avoid circular imports"""
|
| 25 |
+
if self._db is None:
|
| 26 |
+
# Import here to avoid circular import
|
| 27 |
+
from app.core.database import get_database
|
| 28 |
+
self._db = get_database()
|
| 29 |
+
return self._db
|
| 30 |
+
|
| 31 |
+
async def get_or_create_canvas(self, user_id: str) -> PhdCanvas:
|
| 32 |
+
"""Get existing canvas or create new one for user"""
|
| 33 |
+
try:
|
| 34 |
+
db = self.get_database()
|
| 35 |
+
user_object_id = ObjectId(user_id)
|
| 36 |
+
|
| 37 |
+
# Try to find existing canvas
|
| 38 |
+
canvas_doc = await db.phd_canvases.find_one({"user_id": user_object_id})
|
| 39 |
+
|
| 40 |
+
if canvas_doc:
|
| 41 |
+
# Convert to PhdCanvas model
|
| 42 |
+
canvas = PhdCanvas(**canvas_doc)
|
| 43 |
+
logger.info(f"Found existing canvas for user {user_id} with {canvas.total_insights} insights")
|
| 44 |
+
return canvas
|
| 45 |
+
else:
|
| 46 |
+
# Create new canvas
|
| 47 |
+
canvas = PhdCanvas(user_id=user_object_id)
|
| 48 |
+
|
| 49 |
+
# Insert into database
|
| 50 |
+
result = await db.phd_canvases.insert_one(canvas.dict(by_alias=True))
|
| 51 |
+
canvas.id = result.inserted_id
|
| 52 |
+
|
| 53 |
+
logger.info(f"Created new canvas for user {user_id}")
|
| 54 |
+
return canvas
|
| 55 |
+
|
| 56 |
+
except Exception as e:
|
| 57 |
+
logger.error(f"Error getting/creating canvas for user {user_id}: {e}")
|
| 58 |
+
# Return empty canvas as fallback
|
| 59 |
+
return PhdCanvas(user_id=ObjectId(user_id))
|
| 60 |
+
|
| 61 |
+
async def update_canvas(self, user_id: str, request: UpdateCanvasRequest) -> PhdCanvas:
|
| 62 |
+
"""Update canvas with latest insights from chat sessions"""
|
| 63 |
+
|
| 64 |
+
# Get or create a lock for this user
|
| 65 |
+
if user_id not in self._update_locks:
|
| 66 |
+
self._update_locks[user_id] = asyncio.Lock()
|
| 67 |
+
|
| 68 |
+
# Check if an update is already in progress
|
| 69 |
+
if self._update_locks[user_id].locked():
|
| 70 |
+
logger.warning(f"Canvas update already in progress for user {user_id}, skipping duplicate request")
|
| 71 |
+
# Return current canvas without updating
|
| 72 |
+
return await self.get_or_create_canvas(user_id)
|
| 73 |
+
|
| 74 |
+
# Acquire lock to prevent concurrent updates
|
| 75 |
+
async with self._update_locks[user_id]:
|
| 76 |
+
try:
|
| 77 |
+
db = self.get_database()
|
| 78 |
+
canvas = await self.get_or_create_canvas(user_id)
|
| 79 |
+
|
| 80 |
+
logger.info(f"Updating canvas for user {user_id}, force_full={request.force_full_update}")
|
| 81 |
+
|
| 82 |
+
# IMPORTANT: Auto-detect if this should be a full update for first-time canvas
|
| 83 |
+
is_first_time_update = (
|
| 84 |
+
canvas.last_chat_processed is None and
|
| 85 |
+
canvas.total_insights == 0 and
|
| 86 |
+
not request.force_full_update
|
| 87 |
+
)
|
| 88 |
+
|
| 89 |
+
if is_first_time_update:
|
| 90 |
+
logger.info(f"Auto-detecting first-time canvas update for user {user_id}. Converting to full update.")
|
| 91 |
+
request.force_full_update = True
|
| 92 |
+
|
| 93 |
+
# Store the timestamp BEFORE we start processing
|
| 94 |
+
update_started_at = datetime.utcnow()
|
| 95 |
+
|
| 96 |
+
# Determine which chats to process
|
| 97 |
+
if request.force_full_update:
|
| 98 |
+
# Process all chats
|
| 99 |
+
chat_sessions = await self._get_all_user_chat_sessions(user_id)
|
| 100 |
+
logger.info(f"Force full update: processing {len(chat_sessions)} total chat sessions")
|
| 101 |
+
else:
|
| 102 |
+
# Process only chats created/updated after last canvas update
|
| 103 |
+
chat_sessions = await self._get_new_chat_sessions(user_id, canvas.last_chat_processed)
|
| 104 |
+
logger.info(f"Incremental update: processing {len(chat_sessions)} new chat sessions since {canvas.last_chat_processed}")
|
| 105 |
+
|
| 106 |
+
if not chat_sessions:
|
| 107 |
+
logger.info("No new chat sessions to process")
|
| 108 |
+
return canvas
|
| 109 |
+
|
| 110 |
+
# Filter chat sessions if specific ones requested
|
| 111 |
+
if request.include_chat_sessions:
|
| 112 |
+
chat_sessions = [
|
| 113 |
+
chat for chat in chat_sessions
|
| 114 |
+
if str(chat["_id"]) in request.include_chat_sessions
|
| 115 |
+
]
|
| 116 |
+
logger.info(f"Filtered to {len(chat_sessions)} specifically requested chat sessions")
|
| 117 |
+
|
| 118 |
+
# Track processed chat+message combinations to prevent duplicates
|
| 119 |
+
processed_sources = set()
|
| 120 |
+
|
| 121 |
+
# Get existing processed sources from canvas
|
| 122 |
+
for section in canvas.sections.values():
|
| 123 |
+
for insight in section.insights:
|
| 124 |
+
if insight.source_chat_session and insight.source_message_id:
|
| 125 |
+
processed_sources.add((insight.source_chat_session, insight.source_message_id))
|
| 126 |
+
|
| 127 |
+
# Process each chat session for insights
|
| 128 |
+
all_new_insights = []
|
| 129 |
+
processed_chat_ids = []
|
| 130 |
+
|
| 131 |
+
for chat_session in chat_sessions:
|
| 132 |
+
try:
|
| 133 |
+
chat_id = str(chat_session["_id"])
|
| 134 |
+
messages = chat_session.get("messages", [])
|
| 135 |
+
|
| 136 |
+
if not messages:
|
| 137 |
+
continue
|
| 138 |
+
|
| 139 |
+
# Check if we've already processed these messages
|
| 140 |
+
messages_to_process = []
|
| 141 |
+
for msg in messages:
|
| 142 |
+
msg_id = msg.get('id', '')
|
| 143 |
+
if (chat_id, msg_id) not in processed_sources:
|
| 144 |
+
messages_to_process.append(msg)
|
| 145 |
+
|
| 146 |
+
if not messages_to_process:
|
| 147 |
+
logger.info(f"All messages from chat {chat_id} already processed, skipping")
|
| 148 |
+
continue
|
| 149 |
+
|
| 150 |
+
logger.info(f"Processing {len(messages_to_process)} new messages from chat {chat_id}")
|
| 151 |
+
|
| 152 |
+
# Extract insights from new messages only
|
| 153 |
+
session_insights = await self.analysis_service.extract_insights_from_messages(
|
| 154 |
+
messages_to_process, chat_id
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
if session_insights:
|
| 158 |
+
all_new_insights.extend(session_insights)
|
| 159 |
+
processed_chat_ids.append(chat_id)
|
| 160 |
+
logger.info(f"Extracted {len(session_insights)} insights from chat {chat_id}")
|
| 161 |
+
|
| 162 |
+
except Exception as e:
|
| 163 |
+
logger.error(f"Error processing chat session {chat_session.get('_id')}: {e}")
|
| 164 |
+
continue
|
| 165 |
+
|
| 166 |
+
if all_new_insights:
|
| 167 |
+
# Categorize insights by section
|
| 168 |
+
categorized_insights = self.analysis_service.categorize_insights(all_new_insights)
|
| 169 |
+
|
| 170 |
+
# Update canvas sections
|
| 171 |
+
sections_updated = 0
|
| 172 |
+
for section_key, insights in categorized_insights.items():
|
| 173 |
+
if request.exclude_sections and section_key in request.exclude_sections:
|
| 174 |
+
continue
|
| 175 |
+
|
| 176 |
+
# Prioritize insights before adding
|
| 177 |
+
prioritized_insights = self.analysis_service.prioritize_insights(insights)
|
| 178 |
+
|
| 179 |
+
# Limit insights per section to avoid overwhelming canvas
|
| 180 |
+
max_insights_per_section = 10
|
| 181 |
+
limited_insights = prioritized_insights[:max_insights_per_section]
|
| 182 |
+
|
| 183 |
+
if limited_insights:
|
| 184 |
+
canvas.update_section(section_key, limited_insights)
|
| 185 |
+
sections_updated += 1
|
| 186 |
+
logger.info(f"Updated section '{section_key}' with {len(limited_insights)} insights")
|
| 187 |
+
|
| 188 |
+
# Update canvas metadata with the timestamp from BEFORE processing
|
| 189 |
+
canvas.last_chat_processed = update_started_at
|
| 190 |
+
canvas.last_updated = datetime.utcnow()
|
| 191 |
+
|
| 192 |
+
# Save updated canvas to database
|
| 193 |
+
await self._save_canvas(canvas)
|
| 194 |
+
|
| 195 |
+
logger.info(f"Canvas update completed: {len(all_new_insights)} new insights, {sections_updated} sections updated")
|
| 196 |
+
else:
|
| 197 |
+
logger.info("No insights extracted from chat sessions")
|
| 198 |
+
|
| 199 |
+
return canvas
|
| 200 |
+
|
| 201 |
+
except Exception as e:
|
| 202 |
+
logger.error(f"Error updating canvas for user {user_id}: {e}")
|
| 203 |
+
import traceback
|
| 204 |
+
logger.error(f"Full traceback: {traceback.format_exc()}")
|
| 205 |
+
raise
|
| 206 |
+
finally:
|
| 207 |
+
# Clean up lock if no longer needed
|
| 208 |
+
if user_id in self._update_locks and not self._update_locks[user_id].locked():
|
| 209 |
+
# Remove lock after some time to prevent memory buildup
|
| 210 |
+
pass # Keep lock for potential future use
|
| 211 |
+
|
| 212 |
+
async def _get_all_user_chat_sessions(self, user_id: str) -> List[Dict]:
|
| 213 |
+
"""Get all chat sessions for a user"""
|
| 214 |
+
try:
|
| 215 |
+
db = self.get_database()
|
| 216 |
+
user_object_id = ObjectId(user_id)
|
| 217 |
+
|
| 218 |
+
cursor = db.chat_sessions.find({
|
| 219 |
+
"user_id": user_object_id,
|
| 220 |
+
"is_active": {"$ne": False}, # Include active sessions
|
| 221 |
+
"deleted_at": {"$exists": False} # Exclude deleted sessions
|
| 222 |
+
}).sort("created_at", -1) # Most recent first
|
| 223 |
+
|
| 224 |
+
chat_sessions = await cursor.to_list(length=100) # Limit to last 100 chats
|
| 225 |
+
return chat_sessions
|
| 226 |
+
|
| 227 |
+
except Exception as e:
|
| 228 |
+
logger.error(f"Error getting all chat sessions for user {user_id}: {e}")
|
| 229 |
+
return []
|
| 230 |
+
|
| 231 |
+
async def _get_new_chat_sessions(self, user_id: str, since: Optional[datetime]) -> List[Dict]:
|
| 232 |
+
"""Get chat sessions created or updated after a specific time"""
|
| 233 |
+
try:
|
| 234 |
+
db = self.get_database()
|
| 235 |
+
user_object_id = ObjectId(user_id)
|
| 236 |
+
|
| 237 |
+
# Build query filter
|
| 238 |
+
query_filter = {
|
| 239 |
+
"user_id": user_object_id,
|
| 240 |
+
"is_active": {"$ne": False},
|
| 241 |
+
"deleted_at": {"$exists": False}
|
| 242 |
+
}
|
| 243 |
+
|
| 244 |
+
if since:
|
| 245 |
+
# Get chats created or updated after the since timestamp
|
| 246 |
+
query_filter["$or"] = [
|
| 247 |
+
{"created_at": {"$gt": since}},
|
| 248 |
+
{"updated_at": {"$gt": since}}
|
| 249 |
+
]
|
| 250 |
+
else:
|
| 251 |
+
# IMPORTANT FIX: If no since time and this is being called for incremental update,
|
| 252 |
+
# we should still get more chats for proper first-time processing
|
| 253 |
+
# The caller should handle the force_full_update logic properly
|
| 254 |
+
|
| 255 |
+
# For now, get chats from last 30 days instead of 7 as a safer fallback
|
| 256 |
+
# But ideally, first-time canvas should always use force_full_update=True
|
| 257 |
+
one_month_ago = datetime.utcnow() - timedelta(days=30)
|
| 258 |
+
query_filter["created_at"] = {"$gte": one_month_ago}
|
| 259 |
+
|
| 260 |
+
logger.warning(f"Getting new chat sessions for user {user_id} with no 'since' timestamp. "
|
| 261 |
+
f"This should only happen for incremental updates. Consider using force_full_update=True for first-time canvas.")
|
| 262 |
+
|
| 263 |
+
# Increase limit for incremental updates to catch more historical chats
|
| 264 |
+
limit = 100 # Increased from 50
|
| 265 |
+
cursor = db.chat_sessions.find(query_filter).sort("created_at", -1)
|
| 266 |
+
chat_sessions = await cursor.to_list(length=limit)
|
| 267 |
+
|
| 268 |
+
return chat_sessions
|
| 269 |
+
|
| 270 |
+
except Exception as e:
|
| 271 |
+
logger.error(f"Error getting new chat sessions for user {user_id}: {e}")
|
| 272 |
+
return []
|
| 273 |
+
|
| 274 |
+
async def _save_canvas(self, canvas: PhdCanvas):
|
| 275 |
+
"""Save canvas to database"""
|
| 276 |
+
try:
|
| 277 |
+
db = self.get_database()
|
| 278 |
+
|
| 279 |
+
# Update existing canvas
|
| 280 |
+
await db.phd_canvases.replace_one(
|
| 281 |
+
{"_id": canvas.id},
|
| 282 |
+
canvas.dict(by_alias=True),
|
| 283 |
+
upsert=True
|
| 284 |
+
)
|
| 285 |
+
|
| 286 |
+
logger.info(f"Saved canvas {canvas.id} to database")
|
| 287 |
+
|
| 288 |
+
except Exception as e:
|
| 289 |
+
logger.error(f"Error saving canvas to database: {e}")
|
| 290 |
+
raise
|
| 291 |
+
|
| 292 |
+
async def delete_canvas(self, user_id: str) -> bool:
|
| 293 |
+
"""Delete canvas for a user"""
|
| 294 |
+
try:
|
| 295 |
+
db = self.get_database()
|
| 296 |
+
user_object_id = ObjectId(user_id)
|
| 297 |
+
|
| 298 |
+
result = await db.phd_canvases.delete_one({"user_id": user_object_id})
|
| 299 |
+
|
| 300 |
+
if result.deleted_count > 0:
|
| 301 |
+
logger.info(f"Deleted canvas for user {user_id}")
|
| 302 |
+
return True
|
| 303 |
+
else:
|
| 304 |
+
logger.info(f"No canvas found to delete for user {user_id}")
|
| 305 |
+
return False
|
| 306 |
+
|
| 307 |
+
except Exception as e:
|
| 308 |
+
logger.error(f"Error deleting canvas for user {user_id}: {e}")
|
| 309 |
+
return False
|
| 310 |
+
|
| 311 |
+
async def get_canvas_stats(self, user_id: str) -> Dict:
|
| 312 |
+
"""Get statistics about the user's PhD Canvas"""
|
| 313 |
+
try:
|
| 314 |
+
canvas = await self.get_or_create_canvas(user_id)
|
| 315 |
+
|
| 316 |
+
# Calculate section breakdown
|
| 317 |
+
sections_breakdown = {}
|
| 318 |
+
for section_key, section in canvas.sections.items():
|
| 319 |
+
sections_breakdown[section_key] = {
|
| 320 |
+
"title": section.title,
|
| 321 |
+
"insight_count": len(section.insights),
|
| 322 |
+
"priority": section.priority,
|
| 323 |
+
"last_updated": section.updated_at
|
| 324 |
+
}
|
| 325 |
+
|
| 326 |
+
return {
|
| 327 |
+
"total_insights": canvas.total_insights,
|
| 328 |
+
"total_sections": len(canvas.sections),
|
| 329 |
+
"last_updated": canvas.last_updated,
|
| 330 |
+
"last_chat_processed": canvas.last_chat_processed,
|
| 331 |
+
"created_at": canvas.created_at,
|
| 332 |
+
"auto_update": canvas.auto_update,
|
| 333 |
+
"sections_breakdown": sections_breakdown
|
| 334 |
+
}
|
| 335 |
+
|
| 336 |
+
except Exception as e:
|
| 337 |
+
logger.error(f"Error getting canvas stats for user {user_id}: {e}")
|
| 338 |
+
return {
|
| 339 |
+
"total_insights": 0,
|
| 340 |
+
"total_sections": 0,
|
| 341 |
+
"sections_breakdown": {}
|
| 342 |
+
}
|
| 343 |
+
|
| 344 |
+
async def export_canvas_for_printing(self, user_id: str) -> Dict:
|
| 345 |
+
"""Export canvas in a format optimized for printing"""
|
| 346 |
+
try:
|
| 347 |
+
canvas = await self.get_or_create_canvas(user_id)
|
| 348 |
+
|
| 349 |
+
# Format sections for printing
|
| 350 |
+
sections = []
|
| 351 |
+
for section_key, section in canvas.sections.items():
|
| 352 |
+
formatted_section = {
|
| 353 |
+
"title": section.title,
|
| 354 |
+
"description": section.description,
|
| 355 |
+
"insights": [
|
| 356 |
+
{
|
| 357 |
+
"content": insight.content,
|
| 358 |
+
"source": insight.source_persona,
|
| 359 |
+
"confidence": insight.confidence_score
|
| 360 |
+
}
|
| 361 |
+
for insight in section.insights[:5] # Limit to top 5 for printing
|
| 362 |
+
]
|
| 363 |
+
}
|
| 364 |
+
sections.append(formatted_section)
|
| 365 |
+
|
| 366 |
+
return {
|
| 367 |
+
"user_id": str(canvas.user_id),
|
| 368 |
+
"generated_at": datetime.utcnow(),
|
| 369 |
+
"total_insights": canvas.total_insights,
|
| 370 |
+
"last_updated": canvas.last_updated,
|
| 371 |
+
"sections": sections,
|
| 372 |
+
"metadata": {
|
| 373 |
+
"created_at": canvas.created_at,
|
| 374 |
+
"last_chat_processed": canvas.last_chat_processed,
|
| 375 |
+
"print_optimized": True
|
| 376 |
+
}
|
| 377 |
+
}
|
| 378 |
+
|
| 379 |
+
except Exception as e:
|
| 380 |
+
logger.error(f"Error exporting canvas for printing for user {user_id}: {e}")
|
| 381 |
+
raise
|
| 382 |
+
|
| 383 |
+
async def toggle_auto_update(self, user_id: str, enabled: bool) -> bool:
|
| 384 |
+
"""Toggle auto-update setting for a canvas"""
|
| 385 |
+
try:
|
| 386 |
+
db = self.get_database()
|
| 387 |
+
user_object_id = ObjectId(user_id)
|
| 388 |
+
|
| 389 |
+
result = await db.phd_canvases.update_one(
|
| 390 |
+
{"user_id": user_object_id},
|
| 391 |
+
{"$set": {"auto_update": enabled}}
|
| 392 |
+
)
|
| 393 |
+
|
| 394 |
+
return result.modified_count > 0
|
| 395 |
+
|
| 396 |
+
except Exception as e:
|
| 397 |
+
logger.error(f"Error toggling auto-update for user {user_id}: {e}")
|
| 398 |
+
return False
|
| 399 |
+
|
| 400 |
+
# Singleton instance
|
| 401 |
+
_canvas_manager_instance = None
|
| 402 |
+
|
| 403 |
+
def get_canvas_manager() -> CanvasManager:
|
| 404 |
+
"""Get singleton instance of CanvasManager"""
|
| 405 |
+
global _canvas_manager_instance
|
| 406 |
+
if _canvas_manager_instance is None:
|
| 407 |
+
_canvas_manager_instance = CanvasManager()
|
| 408 |
+
return _canvas_manager_instance
|
multi_llm_chatbot_backend/app/core/context.py
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Global context storage
|
| 2 |
+
class GlobalSessionContext:
|
| 3 |
+
def __init__(self):
|
| 4 |
+
self.full_log: list[dict] = []
|
| 5 |
+
self.uploaded_files: list[str] = []
|
| 6 |
+
self.total_upload_size: int = 0
|
| 7 |
+
|
| 8 |
+
def append(self, role: str, content: str):
|
| 9 |
+
self.full_log.append({"role": role, "content": content})
|
| 10 |
+
|
| 11 |
+
def filter_by_persona(self, persona_id: str):
|
| 12 |
+
return self.full_log
|
| 13 |
+
|
| 14 |
+
def clear(self):
|
| 15 |
+
self.full_log = []
|
| 16 |
+
self.uploaded_files = []
|
| 17 |
+
self.total_upload_size = 0
|
multi_llm_chatbot_backend/app/core/context_manager.py
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import List, Dict, Optional, Tuple
|
| 2 |
+
from dataclasses import dataclass
|
| 3 |
+
import re
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
|
| 6 |
+
@dataclass
|
| 7 |
+
class ContextWindow:
|
| 8 |
+
"""Represents a context window for LLM processing"""
|
| 9 |
+
messages: List[dict]
|
| 10 |
+
total_tokens: int
|
| 11 |
+
truncated: bool = False
|
| 12 |
+
|
| 13 |
+
class ContextManager:
|
| 14 |
+
"""Unified context management for consistent LLM behavior"""
|
| 15 |
+
|
| 16 |
+
def __init__(self,
|
| 17 |
+
max_context_tokens: int = 8000,
|
| 18 |
+
preserve_recent_messages: int = 5,
|
| 19 |
+
chars_per_token: float = 4.0):
|
| 20 |
+
self.max_context_tokens = max_context_tokens
|
| 21 |
+
self.preserve_recent_messages = preserve_recent_messages
|
| 22 |
+
self.chars_per_token = chars_per_token
|
| 23 |
+
|
| 24 |
+
def prepare_context_for_llm(self,
|
| 25 |
+
messages: List[dict],
|
| 26 |
+
system_prompt: str,
|
| 27 |
+
llm_provider: str = "gemini") -> ContextWindow:
|
| 28 |
+
"""
|
| 29 |
+
Prepare context for LLM with intelligent windowing and formatting
|
| 30 |
+
"""
|
| 31 |
+
# Calculate token budget
|
| 32 |
+
system_tokens = self._estimate_tokens(system_prompt)
|
| 33 |
+
available_tokens = self.max_context_tokens - system_tokens - 500 # Reserve for response
|
| 34 |
+
|
| 35 |
+
# Get relevant context window
|
| 36 |
+
context_messages = self._get_optimal_context_window(messages, available_tokens)
|
| 37 |
+
|
| 38 |
+
# Format for specific LLM provider
|
| 39 |
+
formatted_messages = self._format_for_provider(context_messages, system_prompt, llm_provider)
|
| 40 |
+
|
| 41 |
+
return ContextWindow(
|
| 42 |
+
messages=formatted_messages,
|
| 43 |
+
total_tokens=self._estimate_tokens_for_messages(formatted_messages),
|
| 44 |
+
truncated=len(context_messages) < len(messages)
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
def _get_optimal_context_window(self, messages: List[dict], token_budget: int) -> List[dict]:
|
| 48 |
+
"""
|
| 49 |
+
Select optimal messages for context window using recency + relevance
|
| 50 |
+
"""
|
| 51 |
+
if not messages:
|
| 52 |
+
return []
|
| 53 |
+
|
| 54 |
+
# Always preserve the most recent messages
|
| 55 |
+
recent_messages = messages[-self.preserve_recent_messages:]
|
| 56 |
+
recent_tokens = self._estimate_tokens_for_messages(recent_messages)
|
| 57 |
+
|
| 58 |
+
if recent_tokens >= token_budget:
|
| 59 |
+
# If recent messages exceed budget, truncate to fit
|
| 60 |
+
return self._truncate_to_fit(recent_messages, token_budget)
|
| 61 |
+
|
| 62 |
+
# Add older messages if we have token budget remaining
|
| 63 |
+
remaining_budget = token_budget - recent_tokens
|
| 64 |
+
older_messages = messages[:-self.preserve_recent_messages] if len(messages) > self.preserve_recent_messages else []
|
| 65 |
+
|
| 66 |
+
# Score older messages by relevance and recency
|
| 67 |
+
scored_messages = self._score_messages_for_relevance(older_messages, messages[-1]['content'] if messages else "")
|
| 68 |
+
|
| 69 |
+
# Add highest scoring messages that fit in budget
|
| 70 |
+
selected_older = []
|
| 71 |
+
for message, score in scored_messages:
|
| 72 |
+
message_tokens = self._estimate_tokens(message['content'])
|
| 73 |
+
if message_tokens <= remaining_budget:
|
| 74 |
+
selected_older.append(message)
|
| 75 |
+
remaining_budget -= message_tokens
|
| 76 |
+
else:
|
| 77 |
+
break
|
| 78 |
+
|
| 79 |
+
# Combine in chronological order
|
| 80 |
+
return selected_older + recent_messages
|
| 81 |
+
|
| 82 |
+
def _score_messages_for_relevance(self, messages: List[dict], current_query: str) -> List[Tuple[dict, float]]:
|
| 83 |
+
"""
|
| 84 |
+
Score messages by relevance to current query and recency
|
| 85 |
+
"""
|
| 86 |
+
scored = []
|
| 87 |
+
current_query_lower = current_query.lower()
|
| 88 |
+
|
| 89 |
+
for i, message in enumerate(messages):
|
| 90 |
+
score = 0.0
|
| 91 |
+
content_lower = message['content'].lower()
|
| 92 |
+
|
| 93 |
+
# Recency score (more recent = higher score)
|
| 94 |
+
recency_score = (i + 1) / len(messages) * 0.3
|
| 95 |
+
|
| 96 |
+
# Keyword overlap score
|
| 97 |
+
current_words = set(current_query_lower.split())
|
| 98 |
+
message_words = set(content_lower.split())
|
| 99 |
+
overlap = len(current_words.intersection(message_words))
|
| 100 |
+
keyword_score = min(overlap / max(len(current_words), 1) * 0.4, 0.4)
|
| 101 |
+
|
| 102 |
+
# Role importance (user questions and document content more important)
|
| 103 |
+
role_score = 0.3 if message['role'] in ['user', 'document'] else 0.1
|
| 104 |
+
|
| 105 |
+
score = recency_score + keyword_score + role_score
|
| 106 |
+
scored.append((message, score))
|
| 107 |
+
|
| 108 |
+
# Sort by score descending
|
| 109 |
+
return sorted(scored, key=lambda x: x[1], reverse=True)
|
| 110 |
+
|
| 111 |
+
def _truncate_to_fit(self, messages: List[dict], token_budget: int) -> List[dict]:
|
| 112 |
+
"""
|
| 113 |
+
Truncate messages to fit within token budget, preserving most recent
|
| 114 |
+
"""
|
| 115 |
+
result = []
|
| 116 |
+
current_tokens = 0
|
| 117 |
+
|
| 118 |
+
# Add messages from most recent backward
|
| 119 |
+
for message in reversed(messages):
|
| 120 |
+
message_tokens = self._estimate_tokens(message['content'])
|
| 121 |
+
if current_tokens + message_tokens <= token_budget:
|
| 122 |
+
result.insert(0, message)
|
| 123 |
+
current_tokens += message_tokens
|
| 124 |
+
else:
|
| 125 |
+
break
|
| 126 |
+
|
| 127 |
+
return result
|
| 128 |
+
|
| 129 |
+
def _format_for_provider(self, messages: List[dict], system_prompt: str, provider: str) -> List[dict]:
|
| 130 |
+
"""
|
| 131 |
+
Format messages for specific LLM provider
|
| 132 |
+
"""
|
| 133 |
+
if provider.lower() == "gemini":
|
| 134 |
+
return self._format_for_gemini(messages, system_prompt)
|
| 135 |
+
elif provider.lower() in ["ollama", "mistral"]:
|
| 136 |
+
return self._format_for_ollama(messages, system_prompt)
|
| 137 |
+
elif provider.lower() == "vllm":
|
| 138 |
+
return self._format_for_vllm(messages, system_prompt)
|
| 139 |
+
else:
|
| 140 |
+
# Default format
|
| 141 |
+
return [{"role": "system", "content": system_prompt}] + messages
|
| 142 |
+
|
| 143 |
+
def _format_for_vllm(self, messages: List[dict], system_prompt: str) -> List[dict]:
|
| 144 |
+
"""
|
| 145 |
+
Format messages for vLLM's OpenAI-compatible API.
|
| 146 |
+
Normalizes custom persona roles to 'assistant' since the API
|
| 147 |
+
only accepts system/user/assistant.
|
| 148 |
+
"""
|
| 149 |
+
formatted = [{"role": "system", "content": system_prompt}]
|
| 150 |
+
for message in messages:
|
| 151 |
+
role = message["role"]
|
| 152 |
+
content = message["content"]
|
| 153 |
+
if role == "system":
|
| 154 |
+
continue
|
| 155 |
+
if role not in ("user", "assistant"):
|
| 156 |
+
content = f"[{role.title()} Advisor]: {content}"
|
| 157 |
+
role = "assistant"
|
| 158 |
+
formatted.append({"role": role, "content": content})
|
| 159 |
+
return formatted
|
| 160 |
+
|
| 161 |
+
def _format_for_gemini(self, messages: List[dict], system_prompt: str) -> List[dict]:
|
| 162 |
+
"""
|
| 163 |
+
Format messages for Gemini API (uses user/model roles with parts structure)
|
| 164 |
+
FIXED VERSION - Better handles system messages and document context
|
| 165 |
+
"""
|
| 166 |
+
formatted = []
|
| 167 |
+
|
| 168 |
+
# Process all messages, handling system messages properly
|
| 169 |
+
system_message_content = ""
|
| 170 |
+
conversation_messages = []
|
| 171 |
+
|
| 172 |
+
# Add initial system prompt if provided
|
| 173 |
+
if system_prompt:
|
| 174 |
+
system_message_content = system_prompt
|
| 175 |
+
|
| 176 |
+
for message in messages:
|
| 177 |
+
role = message['role']
|
| 178 |
+
content = message['content']
|
| 179 |
+
|
| 180 |
+
if role == 'system':
|
| 181 |
+
# Accumulate system messages
|
| 182 |
+
if system_message_content:
|
| 183 |
+
system_message_content += "\n\n" + content
|
| 184 |
+
else:
|
| 185 |
+
system_message_content = content
|
| 186 |
+
else:
|
| 187 |
+
conversation_messages.append(message)
|
| 188 |
+
|
| 189 |
+
# Add accumulated system content as initial exchange
|
| 190 |
+
if system_message_content:
|
| 191 |
+
formatted.extend([
|
| 192 |
+
{
|
| 193 |
+
"role": "user",
|
| 194 |
+
"parts": [{"text": system_message_content}]
|
| 195 |
+
},
|
| 196 |
+
{
|
| 197 |
+
"role": "model",
|
| 198 |
+
"parts": [{"text": "I understand. I'll follow these instructions and use the document context you've provided."}]
|
| 199 |
+
}
|
| 200 |
+
])
|
| 201 |
+
|
| 202 |
+
# Convert conversation messages to Gemini format
|
| 203 |
+
for message in conversation_messages:
|
| 204 |
+
role = message['role']
|
| 205 |
+
content = message['content']
|
| 206 |
+
|
| 207 |
+
if role == 'user':
|
| 208 |
+
formatted.append({
|
| 209 |
+
"role": "user",
|
| 210 |
+
"parts": [{"text": content}]
|
| 211 |
+
})
|
| 212 |
+
elif role == 'document':
|
| 213 |
+
formatted.append({
|
| 214 |
+
"role": "user",
|
| 215 |
+
"parts": [{"text": f"[Context Document] {content}"}]
|
| 216 |
+
})
|
| 217 |
+
else:
|
| 218 |
+
formatted.append({
|
| 219 |
+
"role": "model",
|
| 220 |
+
"parts": [{"text": content}]
|
| 221 |
+
})
|
| 222 |
+
|
| 223 |
+
return formatted
|
| 224 |
+
|
| 225 |
+
def _format_for_ollama(self, messages: List[dict], system_prompt: str) -> str:
|
| 226 |
+
"""
|
| 227 |
+
Format messages for Ollama (returns formatted prompt string)
|
| 228 |
+
"""
|
| 229 |
+
parts = [system_prompt] if system_prompt else []
|
| 230 |
+
|
| 231 |
+
for message in messages:
|
| 232 |
+
role = message['role'].capitalize()
|
| 233 |
+
content = message['content']
|
| 234 |
+
|
| 235 |
+
if role == 'Document':
|
| 236 |
+
parts.append(f"Context: {content}")
|
| 237 |
+
else:
|
| 238 |
+
parts.append(f"{role}: {content}")
|
| 239 |
+
|
| 240 |
+
parts.append("Assistant:")
|
| 241 |
+
return "\n\n".join(parts)
|
| 242 |
+
|
| 243 |
+
def _estimate_tokens(self, text: str) -> int:
|
| 244 |
+
"""
|
| 245 |
+
Estimate token count for text
|
| 246 |
+
"""
|
| 247 |
+
return int(len(text) / self.chars_per_token)
|
| 248 |
+
|
| 249 |
+
def _estimate_tokens_for_messages(self, messages: List[dict]) -> int:
|
| 250 |
+
"""
|
| 251 |
+
Estimate total tokens for list of messages
|
| 252 |
+
"""
|
| 253 |
+
if isinstance(messages, str):
|
| 254 |
+
return self._estimate_tokens(messages)
|
| 255 |
+
|
| 256 |
+
total = 0
|
| 257 |
+
for message in messages:
|
| 258 |
+
if isinstance(message, dict):
|
| 259 |
+
if 'content' in message:
|
| 260 |
+
total += self._estimate_tokens(message['content'])
|
| 261 |
+
elif 'parts' in message:
|
| 262 |
+
# Gemini format
|
| 263 |
+
for part in message['parts']:
|
| 264 |
+
if 'text' in part:
|
| 265 |
+
total += self._estimate_tokens(part['text'])
|
| 266 |
+
else:
|
| 267 |
+
total += self._estimate_tokens(str(message))
|
| 268 |
+
|
| 269 |
+
return total
|
| 270 |
+
|
| 271 |
+
def get_context_summary(self, messages: List[dict]) -> Dict[str, any]:
|
| 272 |
+
"""
|
| 273 |
+
Get summary information about context
|
| 274 |
+
"""
|
| 275 |
+
if not messages:
|
| 276 |
+
return {"total_messages": 0, "estimated_tokens": 0, "roles": {}}
|
| 277 |
+
|
| 278 |
+
role_counts = {}
|
| 279 |
+
for message in messages:
|
| 280 |
+
role = message.get('role', 'unknown')
|
| 281 |
+
role_counts[role] = role_counts.get(role, 0) + 1
|
| 282 |
+
|
| 283 |
+
return {
|
| 284 |
+
"total_messages": len(messages),
|
| 285 |
+
"estimated_tokens": self._estimate_tokens_for_messages(messages),
|
| 286 |
+
"roles": role_counts,
|
| 287 |
+
"oldest_message": messages[0].get('timestamp', 'unknown') if messages else None,
|
| 288 |
+
"newest_message": messages[-1].get('timestamp', 'unknown') if messages else None
|
| 289 |
+
}
|
| 290 |
+
|
| 291 |
+
# Global context manager instance
|
| 292 |
+
context_manager = ContextManager()
|
| 293 |
+
|
| 294 |
+
def get_context_manager() -> ContextManager:
|
| 295 |
+
"""Get the global context manager instance"""
|
| 296 |
+
return context_manager
|
multi_llm_chatbot_backend/app/core/database.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Database connection facade.
|
| 2 |
+
|
| 3 |
+
Used to wrap pymongo / motor against a real MongoDB instance. For Hugging
|
| 4 |
+
Face Spaces deployment we now back the same public API with SQLite on a HF
|
| 5 |
+
Storage Bucket mount (see ``app.core.db``). The shim emulates the Mongo
|
| 6 |
+
idioms the routers and services use, so this module simply forwards to it
|
| 7 |
+
and keeps the legacy ``connect_to_mongo`` / ``close_mongo_connection`` /
|
| 8 |
+
``get_database`` API intact for ``main.py`` and the route files.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import logging
|
| 12 |
+
|
| 13 |
+
from app.core.db import _close, _open, get_database as _get_database
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
async def connect_to_mongo() -> None:
|
| 19 |
+
"""Open the SQLite-backed database and run schema bootstrap."""
|
| 20 |
+
await _open()
|
| 21 |
+
logger.info("SQLite-backed database ready (Mongo API shim active).")
|
| 22 |
+
# Legacy hook: previously called ``setup_canvas_collections`` here.
|
| 23 |
+
# The phd_canvases table is already declared in the shim's schema, so
|
| 24 |
+
# the call below is a no-op but kept for symmetry with the original
|
| 25 |
+
# startup path.
|
| 26 |
+
try:
|
| 27 |
+
from app.core.canvas_database import setup_canvas_collections
|
| 28 |
+
|
| 29 |
+
await setup_canvas_collections(_get_database())
|
| 30 |
+
logger.info("Canvas database initialization completed (no-op on SQLite shim).")
|
| 31 |
+
except Exception as canvas_error: # pragma: no cover
|
| 32 |
+
logger.error(f"Canvas database initialization failed: {canvas_error}")
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
async def close_mongo_connection() -> None:
|
| 36 |
+
"""Best-effort connection close on FastAPI shutdown."""
|
| 37 |
+
await _close()
|
| 38 |
+
logger.info("Disconnected from SQLite-backed database.")
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
async def create_indexes() -> None:
|
| 42 |
+
"""Index creation is handled by the shim's schema bootstrap; kept for
|
| 43 |
+
backwards compatibility so any out-of-process maintenance script that
|
| 44 |
+
imports this name keeps working."""
|
| 45 |
+
return None
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def get_database():
|
| 49 |
+
"""Return the SQLite-backed Mongo-API-compatible Database object."""
|
| 50 |
+
return _get_database()
|