Kennedy Johnson Cursor commited on
Commit
de852c2
·
0 Parent(s):

Add LaunchPadAI undergraduate internship advisor panel.

Browse files

Rebrand 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
Files changed (50) hide show
  1. .dockerignore +20 -0
  2. .gitattributes +36 -0
  3. .github/workflows/propose_release.yml +29 -0
  4. .github/workflows/publish_release.yml +31 -0
  5. .github/workflows/publish_test_build.yml +24 -0
  6. .github/workflows/unit_tests.yml +37 -0
  7. .gitignore +27 -0
  8. CHANGELOG.md +74 -0
  9. Dockerfile +76 -0
  10. README.md +98 -0
  11. advisor-panel-draft.json +111 -0
  12. docker-compose.yml +33 -0
  13. docs/nutrition_tools.md +35 -0
  14. launchpad_config.yaml +202 -0
  15. multi_llm_chatbot_backend/.env.example +24 -0
  16. multi_llm_chatbot_backend/README.md +178 -0
  17. multi_llm_chatbot_backend/app/__init__.py +0 -0
  18. multi_llm_chatbot_backend/app/api/README.md +175 -0
  19. multi_llm_chatbot_backend/app/api/old_routes.py +1001 -0
  20. multi_llm_chatbot_backend/app/api/routes/__init__.py +19 -0
  21. multi_llm_chatbot_backend/app/api/routes/auth.py +319 -0
  22. multi_llm_chatbot_backend/app/api/routes/chat.py +504 -0
  23. multi_llm_chatbot_backend/app/api/routes/chat_sessions.py +293 -0
  24. multi_llm_chatbot_backend/app/api/routes/debug.py +102 -0
  25. multi_llm_chatbot_backend/app/api/routes/documents.py +530 -0
  26. multi_llm_chatbot_backend/app/api/routes/onboarding.py +152 -0
  27. multi_llm_chatbot_backend/app/api/routes/phd_canvas.py +316 -0
  28. multi_llm_chatbot_backend/app/api/routes/provider.py +111 -0
  29. multi_llm_chatbot_backend/app/api/routes/root.py +30 -0
  30. multi_llm_chatbot_backend/app/api/routes/sessions.py +265 -0
  31. multi_llm_chatbot_backend/app/api/routes/user_profile.py +201 -0
  32. multi_llm_chatbot_backend/app/api/routes/voice.py +270 -0
  33. multi_llm_chatbot_backend/app/api/utils.py +140 -0
  34. multi_llm_chatbot_backend/app/assets/avatars/advisor1.png +3 -0
  35. multi_llm_chatbot_backend/app/assets/avatars/advisor2.png +3 -0
  36. multi_llm_chatbot_backend/app/assets/avatars/advisor3.png +3 -0
  37. multi_llm_chatbot_backend/app/assets/avatars/advisor4.png +3 -0
  38. multi_llm_chatbot_backend/app/assets/avatars/advisor5.png +3 -0
  39. multi_llm_chatbot_backend/app/assets/avatars/advisor6.png +3 -0
  40. multi_llm_chatbot_backend/app/assets/avatars/advisor7.png +3 -0
  41. multi_llm_chatbot_backend/app/config.py +519 -0
  42. multi_llm_chatbot_backend/app/core/README.md +200 -0
  43. multi_llm_chatbot_backend/app/core/auth.py +119 -0
  44. multi_llm_chatbot_backend/app/core/bootstrap.py +159 -0
  45. multi_llm_chatbot_backend/app/core/canvas_analysis.py +287 -0
  46. multi_llm_chatbot_backend/app/core/canvas_database.py +35 -0
  47. multi_llm_chatbot_backend/app/core/canvas_manager.py +408 -0
  48. multi_llm_chatbot_backend/app/core/context.py +17 -0
  49. multi_llm_chatbot_backend/app/core/context_manager.py +296 -0
  50. 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

  • SHA256: 8def49b76592eb14f37b308542534720dd5df516fe8c9b95dff00f2534f51727
  • Pointer size: 132 Bytes
  • Size of remote file: 5.79 MB
multi_llm_chatbot_backend/app/assets/avatars/advisor2.png ADDED

Git LFS Details

  • SHA256: 676f77e3d01c0a905658e965be55f3b6d3a91370debc1ba930f9e5a7ed7a80a2
  • Pointer size: 132 Bytes
  • Size of remote file: 5.33 MB
multi_llm_chatbot_backend/app/assets/avatars/advisor3.png ADDED

Git LFS Details

  • SHA256: 3e4e59ac55e3bc490f36b5f85b03c8634ebe1ae7727e888093c4b7e1879c4473
  • Pointer size: 132 Bytes
  • Size of remote file: 5.57 MB
multi_llm_chatbot_backend/app/assets/avatars/advisor4.png ADDED

Git LFS Details

  • SHA256: b97c9f62b1cfb8d4ce6e57c187dbad6a6fab69b24e446af31dec8999ce800306
  • Pointer size: 132 Bytes
  • Size of remote file: 5.33 MB
multi_llm_chatbot_backend/app/assets/avatars/advisor5.png ADDED

Git LFS Details

  • SHA256: 769b6a6461cdb2f92e3e71fbbcd2b1361b560fd7c49617f3a476eaf1a6bf9199
  • Pointer size: 132 Bytes
  • Size of remote file: 5.04 MB
multi_llm_chatbot_backend/app/assets/avatars/advisor6.png ADDED

Git LFS Details

  • SHA256: 5d066f008dffcb0332642e35e3f4805ed30598c03055ac149ab9c5c2892f1c8b
  • Pointer size: 132 Bytes
  • Size of remote file: 5.3 MB
multi_llm_chatbot_backend/app/assets/avatars/advisor7.png ADDED

Git LFS Details

  • SHA256: d7ef861863ee750e201e7ee5eb483494b7dbadc975d0f2fb5cb9fdc0f428e0e6
  • Pointer size: 132 Bytes
  • Size of remote file: 4.28 MB
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()