nathycodes commited on
Commit
ec17af3
·
0 Parent(s):

Deploy Tech Presenter Coach

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +12 -0
  2. .env.example +38 -0
  3. .github/ISSUE_TEMPLATE/bug_report.md +28 -0
  4. .github/ISSUE_TEMPLATE/feature_request.md +11 -0
  5. .github/workflows/ci.yml +102 -0
  6. .gitignore +42 -0
  7. CONTRIBUTING.md +26 -0
  8. Dockerfile +40 -0
  9. LICENSE +201 -0
  10. Makefile +85 -0
  11. NOTICE +29 -0
  12. README.md +385 -0
  13. apps/api/Dockerfile +21 -0
  14. apps/api/alembic.ini +38 -0
  15. apps/api/docker-entrypoint.sh +8 -0
  16. apps/api/migrations/env.py +45 -0
  17. apps/api/migrations/script.py.mako +22 -0
  18. apps/api/migrations/versions/20260607_0001_initial_foundation.py +141 -0
  19. apps/api/migrations/versions/20260608_0002_analytics_coaching.py +117 -0
  20. apps/api/migrations/versions/20260904_0003_user_goals.py +41 -0
  21. apps/api/mypy.ini +4 -0
  22. apps/api/requirements-desktop.txt +18 -0
  23. apps/api/requirements-dev.txt +4 -0
  24. apps/api/requirements.txt +21 -0
  25. apps/api/ruff.toml +9 -0
  26. apps/api/src/app/__init__.py +0 -0
  27. apps/api/src/app/auth.py +277 -0
  28. apps/api/src/app/config.py +68 -0
  29. apps/api/src/app/db.py +26 -0
  30. apps/api/src/app/hf_models.py +318 -0
  31. apps/api/src/app/local_inference.py +147 -0
  32. apps/api/src/app/main.py +62 -0
  33. apps/api/src/app/middleware/logging.py +31 -0
  34. apps/api/src/app/middleware/security_headers.py +21 -0
  35. apps/api/src/app/models.py +312 -0
  36. apps/api/src/app/rate_limit.py +27 -0
  37. apps/api/src/app/routes/__init__.py +3 -0
  38. apps/api/src/app/routes/auth.py +63 -0
  39. apps/api/src/app/routes/coaching.py +188 -0
  40. apps/api/src/app/routes/curriculum.py +112 -0
  41. apps/api/src/app/routes/dashboard.py +203 -0
  42. apps/api/src/app/routes/health.py +37 -0
  43. apps/api/src/app/routes/jobs.py +14 -0
  44. apps/api/src/app/routes/news.py +32 -0
  45. apps/api/src/app/routes/sessions.py +987 -0
  46. apps/api/src/app/routes/settings.py +41 -0
  47. apps/api/src/app/routes/setup.py +37 -0
  48. apps/api/src/app/schema_upgrades.py +31 -0
  49. apps/api/src/app/schemas.py +324 -0
  50. apps/api/src/app/scoring.py +216 -0
.dockerignore ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ .git
2
+ .github
3
+ node_modules
4
+ apps/web/.next
5
+ .venv
6
+ data/*
7
+ !data/.gitkeep
8
+ *.db
9
+ __pycache__
10
+ .pytest_cache
11
+ Speech_Machine_Banner.png
12
+ Screenshot_Speech_Machine.png
.env.example ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Web
2
+ NEXT_PUBLIC_API_URL=http://localhost:8000
3
+ NEXT_PUBLIC_LOCAL_ONLY_MODE=true
4
+
5
+ # API
6
+ DATABASE_URL=postgresql+psycopg2://video:video@db:5432/video
7
+ JWT_SECRET=change-me
8
+ JWT_ALGORITHM=HS256
9
+ ACCESS_TOKEN_EXPIRE_MINUTES=15
10
+ REFRESH_TOKEN_EXPIRE_DAYS=7
11
+ ENVIRONMENT=development
12
+ CORS_ORIGINS=http://localhost:3000
13
+ LOCAL_ONLY_MODE=true
14
+ MAX_UPLOAD_MB=500
15
+ REDIS_HOST=redis
16
+ REDIS_PORT=6379
17
+ STORAGE_BACKEND=local
18
+ STORAGE_DIR=/data/storage
19
+ S3_ENDPOINT=
20
+ S3_ACCESS_KEY=
21
+ S3_SECRET_KEY=
22
+ S3_BUCKET=
23
+ S3_REGION=
24
+ TRANSCRIPTION_BACKEND=server
25
+ # Server transcription model: faster-whisper models plus nvidia/parakeet-tdt-0.6b-v3
26
+ WHISPER_MODEL=small
27
+ WHISPER_DEVICE=cpu
28
+ # Desktop-only MLX Audio default model
29
+ MLX_AUDIO_MODEL=mlx-community/parakeet-tdt-0.6b-v3
30
+ OLLAMA_BASE_URL=http://127.0.0.1:11434
31
+ LMSTUDIO_BASE_URL=http://127.0.0.1:1234/v1
32
+ LLAMA_CPP_BASE_URL=http://127.0.0.1:8080
33
+ LOCAL_ENGINE_TIMEOUT_SECONDS=0.75
34
+ COACHING_ENABLED=true
35
+ COACHING_PROVIDER=ollama
36
+ COACHING_MODEL=llama3.2:3b
37
+ COACHING_TIMEOUT_SECONDS=90
38
+ COACHING_MAX_TRANSCRIPT_CHARS=8000
.github/ISSUE_TEMPLATE/bug_report.md ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Bug Report
3
+ about: Report something that is not working
4
+ labels: bug
5
+ ---
6
+
7
+ ## Description
8
+
9
+ ## Steps to reproduce
10
+ 1.
11
+ 2.
12
+ 3.
13
+
14
+ ## Expected behavior
15
+
16
+ ## Actual behavior
17
+
18
+ ## Environment
19
+ - OS:
20
+ - Browser:
21
+ - Docker version:
22
+ - Commit:
23
+
24
+ ## Logs
25
+
26
+ ```text
27
+ Paste relevant logs here and redact secrets.
28
+ ```
.github/ISSUE_TEMPLATE/feature_request.md ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ name: Feature Request
3
+ about: Suggest an improvement
4
+ labels: enhancement
5
+ ---
6
+
7
+ ## Problem
8
+
9
+ ## Proposed solution
10
+
11
+ ## Alternatives considered
.github/workflows/ci.yml ADDED
@@ -0,0 +1,102 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ concurrency:
10
+ group: ci-${{ github.ref }}
11
+ cancel-in-progress: true
12
+
13
+ env:
14
+ NODE_VERSION: "20"
15
+ PYTHON_VERSION: "3.11"
16
+
17
+ jobs:
18
+ web:
19
+ name: Web
20
+ runs-on: ubuntu-latest
21
+ steps:
22
+ - uses: actions/checkout@v4
23
+ - uses: actions/setup-node@v4
24
+ with:
25
+ node-version: ${{ env.NODE_VERSION }}
26
+ cache: npm
27
+ cache-dependency-path: package-lock.json
28
+ - run: npm ci
29
+ - run: npm --workspace apps/web run lint
30
+ - run: npm --workspace apps/web exec tsc -- --noEmit
31
+ - run: npm --workspace apps/web test -- --run
32
+ - run: npm --workspace apps/web run build
33
+ env:
34
+ NEXT_TELEMETRY_DISABLED: 1
35
+
36
+ shared:
37
+ name: Shared
38
+ runs-on: ubuntu-latest
39
+ steps:
40
+ - uses: actions/checkout@v4
41
+ - uses: actions/setup-node@v4
42
+ with:
43
+ node-version: ${{ env.NODE_VERSION }}
44
+ cache: npm
45
+ cache-dependency-path: package-lock.json
46
+ - run: npm ci
47
+ - run: npm --workspace packages/shared exec tsc -- --noEmit
48
+
49
+ api:
50
+ name: API
51
+ runs-on: ubuntu-latest
52
+ services:
53
+ postgres:
54
+ image: postgres:16
55
+ env:
56
+ POSTGRES_USER: video
57
+ POSTGRES_PASSWORD: video
58
+ POSTGRES_DB: video
59
+ ports:
60
+ - 5432:5432
61
+ options: >-
62
+ --health-cmd "pg_isready -U video -d video"
63
+ --health-interval 5s
64
+ --health-timeout 3s
65
+ --health-retries 5
66
+ redis:
67
+ image: redis:7-alpine
68
+ ports:
69
+ - 6379:6379
70
+ options: >-
71
+ --health-cmd "redis-cli ping"
72
+ --health-interval 5s
73
+ --health-timeout 3s
74
+ --health-retries 5
75
+ defaults:
76
+ run:
77
+ working-directory: apps/api
78
+ steps:
79
+ - uses: actions/checkout@v4
80
+ - uses: actions/setup-python@v5
81
+ with:
82
+ python-version: ${{ env.PYTHON_VERSION }}
83
+ cache: pip
84
+ cache-dependency-path: apps/api/requirements-dev.txt
85
+ - run: python -m pip install --upgrade pip
86
+ - run: pip install -r requirements-dev.txt
87
+ - run: ruff check .
88
+ - run: mypy src/app --ignore-missing-imports --no-error-summary
89
+ - run: PYTHONPATH=src pytest -q --cov=app --cov-report=term-missing
90
+ env:
91
+ DATABASE_URL: sqlite:///./ci_test.db
92
+ JWT_SECRET: ci-test-secret
93
+ ENVIRONMENT: test
94
+ CORS_ORIGINS: http://localhost:3000
95
+
96
+ docker:
97
+ name: Docker Build
98
+ runs-on: ubuntu-latest
99
+ needs: [web, shared, api]
100
+ steps:
101
+ - uses: actions/checkout@v4
102
+ - run: docker compose build
.gitignore ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Environment
2
+ .env
3
+ .env.*
4
+ !.env.example
5
+
6
+ # Dependencies
7
+ node_modules/
8
+ apps/api/.venv/
9
+ .venv/
10
+
11
+ # Python
12
+ __pycache__/
13
+ *.py[cod]
14
+ .pytest_cache/
15
+ .mypy_cache/
16
+ .ruff_cache/
17
+ .coverage
18
+ coverage.xml
19
+ htmlcov/
20
+ test_*.db
21
+ local.db
22
+
23
+ # Frontend
24
+ .next/
25
+ apps/web/public/pdf.worker.min.mjs
26
+ out/
27
+ dist/
28
+ *.tsbuildinfo
29
+ coverage/
30
+ playwright-report/
31
+ test-results/
32
+
33
+ # Runtime data
34
+ uploads/
35
+ storage/
36
+ models/
37
+ *.zip
38
+
39
+ # OS / IDE
40
+ .DS_Store
41
+ .idea/
42
+ .vscode/
CONTRIBUTING.md ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Contributing to Speech-Machine
2
+
3
+ ## Development setup
4
+
5
+ 1. Copy `.env.example` to `.env`.
6
+ 2. Set `JWT_SECRET` to a generated secret.
7
+ 3. Run `docker compose up --build`.
8
+ 4. Open `http://localhost:3000` and complete first-run setup.
9
+
10
+ ## Tests
11
+
12
+ ```bash
13
+ npm --workspace apps/web test -- --run
14
+ npm --workspace packages/shared exec tsc -- --noEmit
15
+ cd apps/api && PYTHONPATH=src pytest -q
16
+ ```
17
+
18
+ ## Code style
19
+
20
+ - Frontend: `npm --workspace apps/web run lint`.
21
+ - Backend: `cd apps/api && ruff check .`.
22
+ - Keep API route prefixes stable unless a migration plan is included.
23
+
24
+ ## Pull requests
25
+
26
+ Use a branch from `main`, include tests for behavior changes, and verify Docker still builds.
Dockerfile ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM node:20-bookworm-slim
2
+
3
+ ENV DEBIAN_FRONTEND=noninteractive \
4
+ PYTHONUNBUFFERED=1 \
5
+ HOME=/app/data/home \
6
+ INTERNAL_API_URL="http://127.0.0.1:8000" \
7
+ ALLOW_FRAME_EMBEDDING="true" \
8
+ PORT=7860
9
+
10
+ RUN apt-get update && apt-get install -y --no-install-recommends \
11
+ python3 \
12
+ python3-pip \
13
+ ffmpeg \
14
+ ca-certificates \
15
+ && rm -rf /var/lib/apt/lists/*
16
+
17
+ WORKDIR /app
18
+
19
+ # Install Python backend + cloud DB/S3 dependencies
20
+ RUN pip3 install --no-cache-dir --break-system-packages \
21
+ fastapi "uvicorn[standard]" sqlalchemy psycopg2-binary pydantic pydantic-settings \
22
+ python-multipart "python-jose[cryptography]" "passlib[bcrypt]" "bcrypt<4.1" \
23
+ httpx email-validator faster-whisper arq reportlab boto3
24
+
25
+ # Install Node.js workspace dependencies
26
+ COPY package.json package-lock.json tsconfig.base.json ./
27
+ COPY packages/shared/package.json ./packages/shared/package.json
28
+ COPY apps/web/package.json ./apps/web/package.json
29
+ RUN npm ci --no-audit --no-fund
30
+
31
+ # Copy application source and build Next.js production bundle
32
+ COPY . .
33
+ RUN chmod +x /app/infra/scripts/start_cloud.sh /app/infra/scripts/run_presenter_coach.sh \
34
+ && mkdir -p /app/data/storage /app/data/home \
35
+ && npm --workspace apps/web run build \
36
+ && chmod -R 777 /app/data /app/apps/web/.next
37
+
38
+ EXPOSE 7860
39
+
40
+ CMD ["/app/infra/scripts/start_cloud.sh"]
LICENSE ADDED
@@ -0,0 +1,201 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Apache License
2
+ Version 2.0, January 2004
3
+ http://www.apache.org/licenses/
4
+
5
+ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
6
+
7
+ 1. Definitions.
8
+
9
+ "License" shall mean the terms and conditions for use, reproduction,
10
+ and distribution as defined by Sections 1 through 9 of this document.
11
+
12
+ "Licensor" shall mean the copyright owner or entity authorized by
13
+ the copyright owner that is granting the License.
14
+
15
+ "Legal Entity" shall mean the union of the acting entity and all
16
+ other entities that control, are controlled by, or are under common
17
+ control with that entity. For the purposes of this definition,
18
+ "control" means (i) the power, direct or indirect, to cause the
19
+ direction or management of such entity, whether by contract or
20
+ otherwise, or (ii) ownership of fifty percent (50%) or more of the
21
+ outstanding shares, or (iii) beneficial ownership of such entity.
22
+
23
+ "You" (or "Your") shall mean an individual or Legal Entity
24
+ exercising permissions granted by this License.
25
+
26
+ "Source" form shall mean the preferred form for making modifications,
27
+ including but not limited to software source code, documentation
28
+ source, and configuration files.
29
+
30
+ "Object" form shall mean any form resulting from mechanical
31
+ transformation or translation of a Source form, including but
32
+ not limited to compiled object code, generated documentation,
33
+ and conversions to other media types.
34
+
35
+ "Work" shall mean the work of authorship, whether in Source or
36
+ Object form, made available under the License, as indicated by a
37
+ copyright notice that is included in or attached to the work
38
+ (an example is provided in the Appendix below).
39
+
40
+ "Derivative Works" shall mean any work, whether in Source or Object
41
+ form, that is based on (or derived from) the Work and for which the
42
+ editorial revisions, annotations, elaborations, or other modifications
43
+ represent, as a whole, an original work of authorship. For the purposes
44
+ of this License, Derivative Works shall not include works that remain
45
+ separable from, or merely link (or bind by name) to the interfaces of,
46
+ the Work and Derivative Works thereof.
47
+
48
+ "Contribution" shall mean any work of authorship, including
49
+ the original version of the Work and any modifications or additions
50
+ to that Work or Derivative Works thereof, that is intentionally
51
+ submitted to Licensor for inclusion in the Work by the copyright owner
52
+ or by an individual or Legal Entity authorized to submit on behalf of
53
+ the copyright owner. For the purposes of this definition, "submitted"
54
+ means any form of electronic, verbal, or written communication sent
55
+ to the Licensor or its representatives, including but not limited to
56
+ communication on electronic mailing lists, source code control systems,
57
+ and issue tracking systems that are managed by, or on behalf of, the
58
+ Licensor for the purpose of discussing and improving the Work, but
59
+ excluding communication that is conspicuously marked or otherwise
60
+ designated in writing by the copyright owner as "Not a Contribution."
61
+
62
+ "Contributor" shall mean Licensor and any individual or Legal Entity
63
+ on behalf of whom a Contribution has been received by Licensor and
64
+ subsequently incorporated within the Work.
65
+
66
+ 2. Grant of Copyright License. Subject to the terms and conditions of
67
+ this License, each Contributor hereby grants to You a perpetual,
68
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
69
+ copyright license to reproduce, prepare Derivative Works of,
70
+ publicly display, publicly perform, sublicense, and distribute the
71
+ Work and such Derivative Works in Source or Object form.
72
+
73
+ 3. Grant of Patent License. Subject to the terms and conditions of
74
+ this License, each Contributor hereby grants to You a perpetual,
75
+ worldwide, non-exclusive, no-charge, royalty-free, irrevocable
76
+ (except as stated in this section) patent license to make, have made,
77
+ use, offer to sell, sell, import, and otherwise transfer the Work,
78
+ where such license applies only to those patent claims licensable
79
+ by such Contributor that are necessarily infringed by their
80
+ Contribution(s) alone or by combination of their Contribution(s)
81
+ with the Work to which such Contribution(s) was submitted. If You
82
+ institute patent litigation against any entity (including a
83
+ cross-claim or counterclaim in a lawsuit) alleging that the Work
84
+ or a Contribution incorporated within the Work constitutes direct
85
+ or contributory patent infringement, then any patent licenses
86
+ granted to You under this License for that Work shall terminate
87
+ as of the date such litigation is filed.
88
+
89
+ 4. Redistribution. You may reproduce and distribute copies of the
90
+ Work or Derivative Works thereof in any medium, with or without
91
+ modifications, and in Source or Object form, provided that You
92
+ meet the following conditions:
93
+
94
+ (a) You must give any other recipients of the Work or
95
+ Derivative Works a copy of this License; and
96
+
97
+ (b) You must cause any modified files to carry prominent notices
98
+ stating that You changed the files; and
99
+
100
+ (c) You must retain, in the Source form of any Derivative Works
101
+ that You distribute, all copyright, patent, trademark, and
102
+ attribution notices from the Source form of the Work,
103
+ excluding those notices that do not pertain to any part of
104
+ the Derivative Works; and
105
+
106
+ (d) If the Work includes a "NOTICE" text file as part of its
107
+ distribution, then any Derivative Works that You distribute must
108
+ include a readable copy of the attribution notices contained
109
+ within such NOTICE file, excluding those notices that do not
110
+ pertain to any part of the Derivative Works, in at least one
111
+ of the following places: within a NOTICE text file distributed
112
+ as part of the Derivative Works; within the Source form or
113
+ documentation, if provided along with the Derivative Works; or,
114
+ within a display generated by the Derivative Works, if and
115
+ wherever such third-party notices normally appear. The contents
116
+ of the NOTICE file are for informational purposes only and
117
+ do not modify the License. You may add Your own attribution
118
+ notices within Derivative Works that You distribute, alongside
119
+ or as an addendum to the NOTICE text from the Work, provided
120
+ that such additional attribution notices cannot be construed
121
+ as modifying the License.
122
+
123
+ You may add Your own copyright statement to Your modifications and
124
+ may provide additional or different license terms and conditions
125
+ for use, reproduction, or distribution of Your modifications, or
126
+ for any such Derivative Works as a whole, provided Your use,
127
+ reproduction, and distribution of the Work otherwise complies with
128
+ the conditions stated in this License.
129
+
130
+ 5. Submission of Contributions. Unless You explicitly state otherwise,
131
+ any Contribution intentionally submitted for inclusion in the Work
132
+ by You to the Licensor shall be under the terms and conditions of
133
+ this License, without any additional terms or conditions.
134
+ Notwithstanding the above, nothing herein shall supersede or modify
135
+ the terms of any separate license agreement you may have executed
136
+ with Licensor regarding such Contributions.
137
+
138
+ 6. Trademarks. This License does not grant permission to use the trade
139
+ names, trademarks, service marks, or product names of the Licensor,
140
+ except as required for reasonable and customary use in describing the
141
+ origin of the Work and reproducing the content of the NOTICE file.
142
+
143
+ 7. Disclaimer of Warranty. Unless required by applicable law or
144
+ agreed to in writing, Licensor provides the Work (and each
145
+ Contributor provides its Contributions) on an "AS IS" BASIS,
146
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
147
+ implied, including, without limitation, any warranties or conditions
148
+ of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
149
+ PARTICULAR PURPOSE. You are solely responsible for determining the
150
+ appropriateness of using or redistributing the Work and assume any
151
+ risks associated with Your exercise of permissions under this License.
152
+
153
+ 8. Limitation of Liability. In no event and under no legal theory,
154
+ whether in tort (including negligence), contract, or otherwise,
155
+ unless required by applicable law (such as deliberate and grossly
156
+ negligent acts) or agreed to in writing, shall any Contributor be
157
+ liable to You for damages, including any direct, indirect, special,
158
+ incidental, or consequential damages of any character arising as a
159
+ result of this License or out of the use or inability to use the
160
+ Work (including but not limited to damages for loss of goodwill,
161
+ work stoppage, computer failure or malfunction, or any and all
162
+ other commercial damages or losses), even if such Contributor
163
+ has been advised of the possibility of such damages.
164
+
165
+ 9. Accepting Warranty or Additional Liability. While redistributing
166
+ the Work or Derivative Works thereof, You may choose to offer,
167
+ and charge a fee for, acceptance of support, warranty, indemnity,
168
+ or other liability obligations and/or rights consistent with this
169
+ License. However, in accepting such obligations, You may act only
170
+ on Your own behalf and on Your sole responsibility, not on behalf
171
+ of any other Contributor, and only if You agree to indemnify,
172
+ defend, and hold each Contributor harmless for any liability
173
+ incurred by, or claims asserted against, such Contributor by reason
174
+ of your accepting any such warranty or additional liability.
175
+
176
+ END OF TERMS AND CONDITIONS
177
+
178
+ APPENDIX: How to apply the Apache License to your work.
179
+
180
+ To apply the Apache License to your work, attach the following
181
+ boilerplate notice, with the fields enclosed by brackets "[]"
182
+ replaced with your own identifying information. (Don't include
183
+ the brackets!) The text should be enclosed in the appropriate
184
+ comment syntax for the file format. We also recommend that a
185
+ file or class name and description of purpose be included on the
186
+ same "printed page" as the copyright notice for easier
187
+ identification within third-party archives.
188
+
189
+ Copyright [yyyy] [name of copyright owner]
190
+
191
+ Licensed under the Apache License, Version 2.0 (the "License");
192
+ you may not use this file except in compliance with the License.
193
+ You may obtain a copy of the License at
194
+
195
+ http://www.apache.org/licenses/LICENSE-2.0
196
+
197
+ Unless required by applicable law or agreed to in writing, software
198
+ distributed under the License is distributed on an "AS IS" BASIS,
199
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
200
+ See the License for the specific language governing permissions and
201
+ limitations under the License.
Makefile ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ SHELL := /bin/bash
2
+
3
+ COMPOSE ?= docker compose
4
+ API_VENV ?= $(HOME)/venvs/mlx
5
+ API_PYTHON ?= $(API_VENV)/bin/python
6
+ API_PYTEST ?= PYTHONPATH=src $(API_PYTHON) -m pytest -q
7
+ API_RUFF ?= $(API_VENV)/bin/ruff
8
+ API_MYPY ?= $(API_VENV)/bin/mypy
9
+ ADMIN_EMAIL ?= admin@example.com
10
+ ADMIN_PASSWORD ?= change-me-strong
11
+
12
+ .PHONY: help install install-web install-api dev up local-up local-build down logs reset-local smoke local-smoke test test-web test-api lint lint-web lint-api typecheck typecheck-web typecheck-shared typecheck-api check compose-config format
13
+
14
+ help:
15
+ @awk 'BEGIN {FS = ":.*##"; printf "Speech-Machine local targets:\n"} /^[a-zA-Z0-9_-]+:.*##/ {printf " %-18s %s\n", $$1, $$2}' $(MAKEFILE_LIST)
16
+
17
+ install: install-web install-api ## Install local web and API dependencies.
18
+
19
+ install-web: ## Install root npm workspaces.
20
+ npm ci
21
+
22
+ install-api: ## Install API dependencies into API_VENV, defaulting to ~/venvs/mlx.
23
+ cd apps/api && $(API_PYTHON) -m pip install -r requirements-dev.txt
24
+
25
+ dev: local-up ## Start the local Docker stack.
26
+
27
+ up: local-up ## Start the local Docker stack.
28
+
29
+ local-up: .env ## Start the local Docker stack with rebuilds when needed.
30
+ $(COMPOSE) up --build
31
+
32
+ local-build: ## Build local Docker images.
33
+ $(COMPOSE) build api worker web
34
+
35
+ down: ## Stop local Docker services.
36
+ $(COMPOSE) down
37
+
38
+ logs: ## Follow local Docker logs.
39
+ $(COMPOSE) logs -f api worker web
40
+
41
+ reset-local: ## Stop services and delete local Docker volumes.
42
+ $(COMPOSE) down -v
43
+
44
+ smoke: local-smoke ## Run local endpoint smoke checks.
45
+
46
+ local-smoke: ## Check local API, setup, login, and web endpoints.
47
+ ADMIN_EMAIL="$(ADMIN_EMAIL)" ADMIN_PASSWORD="$(ADMIN_PASSWORD)" ./scripts/local-smoke.sh
48
+
49
+ test: test-web test-api ## Run frontend and backend tests.
50
+
51
+ test-web: ## Run frontend unit tests once.
52
+ npm --workspace apps/web test -- --run
53
+
54
+ test-api: ## Run API tests through API_VENV.
55
+ cd apps/api && $(API_PYTEST)
56
+
57
+ lint: lint-web lint-api ## Run web and API lint checks.
58
+
59
+ lint-web: ## Run Next.js lint.
60
+ npm --workspace apps/web run lint
61
+
62
+ lint-api: ## Run Ruff against the API.
63
+ cd apps/api && $(API_RUFF) check .
64
+
65
+ typecheck: typecheck-shared typecheck-web typecheck-api ## Run TypeScript and Python type checks.
66
+
67
+ typecheck-web: ## Run web TypeScript check.
68
+ npm --workspace apps/web exec tsc -- --noEmit
69
+
70
+ typecheck-shared: ## Run shared package TypeScript check.
71
+ npm --workspace packages/shared exec tsc -- --noEmit
72
+
73
+ typecheck-api: ## Run API mypy check.
74
+ cd apps/api && $(API_MYPY) src/app --ignore-missing-imports --no-error-summary
75
+
76
+ check: test-web typecheck-shared lint-web typecheck-web test-api lint-api typecheck-api compose-config ## Run the local pre-commit check suite.
77
+
78
+ compose-config: ## Validate Docker Compose config.
79
+ $(COMPOSE) config >/dev/null
80
+
81
+ format: ## Format frontend files.
82
+ npm --workspace apps/web run format
83
+
84
+ .env:
85
+ cp .env.example .env
NOTICE ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ Speech-Machine — Personalized Tech Camera-Presenting Coach Edition
2
+ Copyright 2026 Susant Achary (Original Speech-Machine Author)
3
+
4
+ Licensed under the Apache License, Version 2.0 (the "License");
5
+ you may not use this file except in compliance with the License.
6
+ You may obtain a copy of the License at:
7
+
8
+ http://www.apache.org/licenses/LICENSE-2.0
9
+
10
+ Summary of Modifications (Apache License 2.0, Section 4(b)):
11
+ - Preserved Speech-Machine's core monorepo architecture (Next.js 14 frontend,
12
+ FastAPI + SQLAlchemy backend, @video/shared scoring package) and existing
13
+ capabilities (MediaRecorder video capture, MediaPipe eye-contact/smile/head
14
+ tracking, Web Audio loudness/pitch analysis, Web Speech & faster-whisper
15
+ transcription, IndexedDB draft recovery, PDF/TXT document reading, and exports).
16
+ - Added a personalized 30-day Tech Presenter Training Curriculum with adaptive
17
+ presenter profiles, progress persistence, and one-click studio launch.
18
+ - Added Daily Speaking Warm-Ups, Camera-Presenting Challenges, and dynamic
19
+ weakness-targeted improvement drills.
20
+ - Upgraded the Camera Recording Studio with an on-lens built-in teleprompter
21
+ supporting auto-scroll, voice-synced highlighting, presenter bullet cues,
22
+ mirror mode, countdown timer, audio-only fallback, and editable transcripts.
23
+ - Added a Technology News & Storytelling Practice desk with live RSS/HN feeds,
24
+ curated tech briefings, custom topic/URL parsing, and 4 presenter storyboards.
25
+ - Added a built-in analytical presenter coaching engine fallback alongside
26
+ existing local LLM providers (Ollama, LM Studio, llama.cpp), plus local
27
+ background worker fallback when Redis/arq is not running.
28
+ - Added Weekly Review analytics, reflection notes, enhanced Dual-Video
29
+ Side-by-Side Recording Comparison, and a responsive Mac + mobile UI shell.
README.md ADDED
@@ -0,0 +1,385 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: Tech Presenter Coach
3
+ emoji: 🎙️
4
+ colorFrom: red
5
+ colorTo: yellow
6
+ sdk: docker
7
+ app_port: 7860
8
+ ---
9
+
10
+ ![Speech-Machine Banner](./Speech_Machine_Banner.png)
11
+
12
+ # Speech-Machine · Personal Tech Presenter Coach Edition
13
+
14
+ Speech-Machine is an AI-assisted speech practice and review platform extended into a daily, responsive **Personal Technology Presenter Coach** for Mac and mobile phone browsers.
15
+
16
+ ## Personal Tech Presenter Coach Upgrades
17
+
18
+ Built directly on [`SSusantAchary/speech-machine`](https://github.com/SSusantAchary/speech-machine) (Apache License 2.0 — see `LICENSE` and `NOTICE`), this edition preserves all core recording, transcription, MediaPipe delivery tracking, document reading, draft recovery, and analytics capabilities while adding:
19
+
20
+ - **Personalized 30-Day Tech Presenter Curriculum (`/curriculum`)**: Customizable Presenter Profile (role, speaking track, target WPM zone, habits to fix) + 4 progressive training weeks (30 daily missions with warm-ups, camera challenges, teleprompter scripts, bullet cues, reflection notes, and auto-completion).
21
+ - **Camera Studio with Built-In On-Lens Teleprompter (`/record`)**: Top-of-frame eye-line teleprompter supporting **Auto-Scroll** (WPM-calibrated), **Voice-Sync** word highlighting, and **Bullet Cue Cards**, plus mirror mode, font/opacity controls, 3–2–1 countdown, audio-only fallback, and unblocked browser session saving without requiring a local Whisper download first.
22
+ - **Technology News & Storytelling Lab (`/tech-news`)**: Live RSS tech news feed (Ars Technica, Hacker News, The Verge Tech + curated briefings) and custom topic/URL storyboard builder across 4 presenter frameworks (*60s Breaking Tech Briefing*, *2-Min Deep-Dive Explainer*, *90s Contrarian Take*, *2-Min Builder Story*).
23
+ - **Daily Warm-Ups, Targeted Weakness Drills & Camera Challenges (`/exercises`)**: Interactive 60s vocal/presence countdown warm-ups, weakness-matched drills driven by your recent WPM/filler/eye-contact telemetry, and on-camera presenter challenges.
24
+ - **Built-In Smart Presenter Coach (`/session/[id]`)**: Works out-of-the-box with zero external LLM setup (and seamlessly upgrades to local Ollama / LM Studio / llama.cpp when running), plus inline transcript editing (`PATCH /sessions/{id}/transcript`) that recomputes scores and coaching on demand.
25
+ - **Streaks, Weekly Reviews & Side-by-Side Video Comparison (`/progress`)**: Synchronized dual-video playback comparing Take A vs Take B, metric deltas, 365-day activity heatmap, and weekly presenter reflection journal.
26
+ - **Mac + Mobile Phone Responsive Architecture**: Desktop top navigation + mobile safe-area bottom tab bar, with Next.js same-origin API proxying (`/api/backend/*`) so a single port (`:3000`) works cleanly across Mac and mobile browsers over LAN, Tailscale, or HTTPS tunnels.
27
+
28
+ ### Quick Start (Mac + Phone Browser)
29
+
30
+ ```bash
31
+ ./infra/scripts/run_presenter_coach.sh
32
+ ```
33
+
34
+ - **On your Mac**: Open `http://localhost:3000`
35
+ - **On your Phone (same Wi-Fi / Tailscale)**: Open `https://<your-mac-tailscale-domain>:3000` (note: mobile browsers require `https://` or `localhost` to grant camera/microphone `getUserMedia` access; `tailscale serve --https=443 http://localhost:3000` or Cloudflare Tunnel provides instant HTTPS on mobile).
36
+
37
+ ## Sample UI
38
+
39
+ Below is a sample Speech-Machine screen showing the product layout and session statistics view.
40
+
41
+ ![Speech-Machine Sample UI](./Screenshot_Speech_Machine.png)
42
+
43
+ ## What It Does
44
+
45
+ - Records practice videos in the browser with camera and microphone selection.
46
+ - Uploads existing videos for asynchronous transcription and review.
47
+ - Downloads transcript output as `.txt` and `.pdf`.
48
+ - Saves full practice sessions with score, transcript, metrics, and review data.
49
+ - Restores unsaved recording drafts from browser storage after refresh.
50
+ - Tracks realtime speaking cues such as words per minute, filler words, pauses, and rolling captions.
51
+ - Tracks delivery signals such as eye contact, smile, loudness, and head movement.
52
+ - Supports a scrollable reading document panel during recording for `.pdf` and `.txt` files.
53
+ - Highlights the current reading block during recording using live browser speech cues when supported.
54
+ - Exports complete session bundles and supports video-only delete or full session delete.
55
+ - Supports first-run local admin setup and email/password authentication.
56
+
57
+ ## Core Capabilities
58
+
59
+ ### Recording and review
60
+
61
+ - Browser-based recording with MediaRecorder
62
+ - Realtime preview and review playback
63
+ - Draft recovery for unsaved recordings
64
+ - Session comparison and score breakdown
65
+ - Timeline markers for speaking issues and improvement points
66
+
67
+ ### Speech analysis
68
+
69
+ - Final server-side transcription
70
+ - Realtime browser speech cues where the Web Speech API is available
71
+ - Average and realtime WPM
72
+ - Filler-word detection
73
+ - Long-pause and silence detection
74
+ - Transcript search and filler highlighting
75
+
76
+ ### Delivery analysis
77
+
78
+ - Eye-contact estimation using MediaPipe face landmarks
79
+ - Smile and head stability metrics
80
+ - Loudness tracking from the audio stream
81
+ - Composite scoring across delivery, speech, and content
82
+
83
+ ### Guided reading
84
+
85
+ - Upload a reading document during recording
86
+ - Supported input: `.pdf`, `.txt`
87
+ - Vertical teleprompter-style reading panel below the recorder
88
+ - Live paragraph highlighting based on current spoken content when browser speech recognition is available
89
+
90
+ ## Platform Compatibility
91
+
92
+ ### Primary supported runtime
93
+
94
+ The primary supported runtime is the Docker stack.
95
+
96
+ | Platform | Status | Notes |
97
+ | --- | --- | --- |
98
+ | macOS | Supported | Best overall local development path with Docker Desktop |
99
+ | Linux | Supported | Good fit for local and server deployment |
100
+ | Windows | Supported | Use Docker Desktop; WSL2-backed setups are recommended |
101
+
102
+ ### Browser compatibility
103
+
104
+ | Browser | Recording | Realtime speech cues | Notes |
105
+ | --- | --- | --- | --- |
106
+ | Chrome | Supported | Best support | Recommended browser |
107
+ | Edge | Supported | Good support | Recommended browser |
108
+ | Safari | Supported | Limited / browser-dependent | Recording path includes Safari-specific handling; Web Speech API support may be unavailable |
109
+ | Firefox | Partial | Limited | Core UI works, but speech-recognition behavior depends on browser capabilities |
110
+
111
+ ### Desktop runtime note
112
+
113
+ The repo also contains a desktop-oriented runtime foundation, but the default and recommended deployment path is still Docker. Optional `MLX Audio` support is macOS Apple Silicon oriented.
114
+
115
+ ## Minimum System Requirements
116
+
117
+ These are practical minimums for local Docker usage, not hard-enforced limits.
118
+
119
+ | Requirement | Minimum | Recommended |
120
+ | --- | --- | --- |
121
+ | CPU | 4 logical cores | 8 logical cores |
122
+ | Memory | 8 GB RAM | 16 GB RAM |
123
+ | Free disk | 10 GB | 20+ GB, especially when downloading transcription models |
124
+ | Browser | Modern Chromium / Safari | Latest Chrome or Edge |
125
+ | Camera / Mic | Required for recording mode | External mic recommended for better transcription quality |
126
+ | Docker | Docker Desktop or Docker Engine + Compose | Latest stable |
127
+
128
+ Notes:
129
+ - Larger Whisper models need more RAM, disk, and transcription time.
130
+ - GPU is optional. CPU-only operation is supported.
131
+ - If you plan to keep multiple downloaded models locally, reserve additional disk space.
132
+
133
+ ## Supported Transcription Models
134
+
135
+ ### Docker / server backend
136
+
137
+ The default backend is the built-in server backend using `faster-whisper==1.1.0`. Models are downloaded only when the user explicitly chooses `Download` in the UI.
138
+
139
+ - `openai/whisper-tiny`
140
+ - `openai/whisper-base`
141
+ - `openai/whisper-small`
142
+ - `openai/whisper-medium`
143
+ - `openai/whisper-large-v3`
144
+ - `openai/whisper-large-v3-turbo`
145
+
146
+ ### Optional MLX Audio backend
147
+
148
+ Available only in the optional desktop-oriented path when `mlx-audio` is installed and the runtime is compatible.
149
+
150
+ - `mlx-community/whisper-large-v3-turbo-asr-fp16`
151
+ - `distil-whisper/distil-large-v3`
152
+ - `mlx-community/Qwen3-ASR-1.7B-8bit`
153
+ - `mlx-community/Qwen3-ForcedAligner-0.6B-8bit`
154
+ - `mlx-community/parakeet-tdt-0.6b-v3`
155
+ - `mlx-community/Voxtral-Mini-3B-2507-bf16`
156
+ - `mlx-community/Voxtral-Mini-4B-Realtime-2602-4bit`
157
+ - `mlx-community/Voxtral-Mini-4B-Realtime-2602-fp16`
158
+ - `mlx-community/VibeVoice-ASR-bf16`
159
+
160
+ ## Latency Characteristics
161
+
162
+ Speech-Machine does not ship a formal benchmark suite yet, so latency depends on browser, host CPU/GPU, selected model, and recording length.
163
+
164
+ | Workflow | Expected behavior |
165
+ | --- | --- |
166
+ | Live recorder metrics | Near realtime in the browser |
167
+ | Live face / delivery metrics | Near realtime on modern hardware |
168
+ | Save session | Usually seconds, depending on file size and storage |
169
+ | Final transcription | Seconds to minutes depending on video duration and model size |
170
+ | Model download | One-time per model; depends on model size and network speed |
171
+
172
+ Practical guidance:
173
+ - `Whisper Tiny` and `Whisper Base` are the fastest options.
174
+ - `Whisper Small` is a good default balance for local CPU usage.
175
+ - `Whisper Medium`, `Large v3`, and `Large v3 Turbo` need more memory and longer completion time.
176
+
177
+ ## Tech Stack
178
+
179
+ ### Frontend
180
+
181
+ - Next.js 14 App Router
182
+ - React 18
183
+ - TypeScript
184
+ - Tailwind CSS
185
+ - Zustand
186
+ - Recharts
187
+ - `idb` for IndexedDB draft persistence
188
+ - `pdfjs-dist` for PDF text extraction
189
+ - MediaPipe Tasks Vision
190
+ - Web Audio API
191
+ - Web Speech API
192
+
193
+ ### Backend
194
+
195
+ - FastAPI
196
+ - SQLAlchemy
197
+ - PostgreSQL in Docker
198
+ - SQLite in desktop-oriented local runtime mode
199
+ - JWT authentication
200
+ - Local file storage with optional S3-compatible storage
201
+ - `ffmpeg` for audio extraction
202
+ - `faster-whisper==1.1.0`
203
+ - Optional `mlx-audio`
204
+ - `huggingface_hub` for on-demand local model downloads
205
+
206
+ ### Tooling
207
+
208
+ - Docker Compose
209
+ - Vitest
210
+ - Playwright
211
+ - Pytest
212
+ - Make
213
+
214
+ ## Architecture
215
+
216
+ ```mermaid
217
+ flowchart LR
218
+ User([Speaker]) --> Browser
219
+
220
+ subgraph Client[Browser / Next.js]
221
+ Browser[Next.js web app]
222
+ Capture[MediaRecorder<br/>camera and microphone]
223
+ Live[MediaPipe + Web Audio<br/>live delivery metrics]
224
+ Drafts[(IndexedDB<br/>draft recovery)]
225
+ Browser --> Capture
226
+ Browser --> Live
227
+ Browser <--> Drafts
228
+ end
229
+
230
+ Browser <-->|HTTPS / JSON<br/>job polling| API
231
+
232
+ subgraph Services[Docker application services]
233
+ API[FastAPI API<br/>authentication and sessions]
234
+ Queue[(Redis<br/>arq job queue)]
235
+ Worker[Background worker]
236
+ Database[(PostgreSQL<br/>sessions and analytics)]
237
+ Storage[(Local or S3 storage<br/>video and documents)]
238
+
239
+ API <--> Database
240
+ API <--> Storage
241
+ API -->|enqueue transcription| Queue
242
+ Queue --> Worker
243
+ Worker <--> Storage
244
+ Worker --> Database
245
+ end
246
+
247
+ subgraph Intelligence[Local intelligence]
248
+ Media[ffmpeg]
249
+ ASR[faster-whisper<br/>or optional MLX Audio]
250
+ Coach[Ollama, LM Studio,<br/>or llama.cpp]
251
+ Media --> ASR
252
+ end
253
+
254
+ Worker --> Media
255
+ ASR -->|transcript and metrics| Worker
256
+ Worker -->|session context| Coach
257
+ Coach -->|structured coaching| Worker
258
+ ```
259
+
260
+ Recordings and uploaded videos are validated by the API, stored locally or in S3-compatible storage, and queued through Redis. The worker extracts audio, runs transcription, computes analytics, and optionally requests structured coaching from a local LLM. The web app polls for job completion and renders the saved session, dashboard trends, and coaching feedback.
261
+
262
+ See [the architecture guide](./docs/ARCHITECTURE.md) for the request lifecycle and security boundaries.
263
+
264
+ ## Repository Layout
265
+
266
+ - `apps/web`: Next.js frontend
267
+ - `apps/api`: FastAPI backend
268
+ - `packages/shared`: shared types and scoring logic
269
+ - `infra`: desktop runtime helpers
270
+ - `docs`: packaging and supporting documentation
271
+
272
+ ## Quick Start
273
+
274
+ ### Recommended: Docker
275
+
276
+ ```bash
277
+ cp .env.example .env
278
+ make local-up
279
+ ```
280
+
281
+ Open:
282
+
283
+ - App: `http://localhost:3000`
284
+ - API docs: `http://localhost:8000/docs`
285
+
286
+ On first launch, open the app and create the local admin account in the setup screen.
287
+
288
+ You can also create the first local admin from the terminal:
289
+
290
+ ```bash
291
+ curl -X POST http://localhost:8000/setup \
292
+ -H "Content-Type: application/json" \
293
+ -d '{"email":"admin@example.com","password":"change-me-strong"}'
294
+ ```
295
+
296
+ Then run a local smoke check:
297
+
298
+ ```bash
299
+ make local-smoke
300
+ ```
301
+
302
+ See [docs/local-machine.md](./docs/local-machine.md) for the full local workflow.
303
+
304
+ ### Local development without Docker
305
+
306
+ #### API
307
+
308
+ ```bash
309
+ cd apps/api
310
+ python3 -m venv .venv
311
+ source .venv/bin/activate
312
+ pip install -r requirements.txt
313
+ uvicorn app.main:app --reload --host 0.0.0.0 --port 8000
314
+ ```
315
+
316
+ #### Web
317
+
318
+ ```bash
319
+ cd apps/web
320
+ npm install
321
+ npm run dev
322
+ ```
323
+
324
+ ## Build and Test
325
+
326
+ ```bash
327
+ make local-build
328
+ ```
329
+
330
+ ```bash
331
+ make check
332
+ ```
333
+
334
+ ## Lines of Code
335
+
336
+ Source snapshot as of March 22, 2026, excluding `node_modules`, `.next`, caches, and lockfiles:
337
+
338
+ | Area | Files | Approx. LOC |
339
+ | --- | --- | --- |
340
+ | API | 17 | 2,370 |
341
+ | Web | 36 | 4,444 |
342
+ | Shared | 4 | 290 |
343
+ | Infra | 4 | 263 |
344
+ | Total app source | 61 | 7,367 |
345
+
346
+
347
+
348
+ ## Current Feature Set
349
+
350
+ - Browser recording and uploaded-video transcription
351
+ - Transcript download in `.txt` and `.pdf`
352
+ - Session export bundle
353
+ - Session comparison
354
+ - Full session delete and video-only delete
355
+ - Reading-document upload and guided reading panel
356
+ - Draft recovery
357
+ - Model download-on-demand from the UI
358
+ - User-defined practice goals with live progress and goal-aware AI coaching
359
+ - Docker-first local deployment
360
+
361
+ ## Planned / Upcoming Upgrades
362
+
363
+ - Better progress reporting for long model downloads
364
+ - Broader browser support around live speech cues
365
+ - Stronger progress reporting for queued transcription workloads
366
+ - More production deployment presets
367
+ - Native desktop shell packaging
368
+ - Stronger benchmarking and latency reporting
369
+ - Richer document guidance and reading analytics
370
+
371
+ ## License
372
+
373
+ This project is licensed under the Apache License 2.0.
374
+
375
+ High-level summary:
376
+ - You may use, modify, and distribute the software.
377
+ - You must preserve copyright and license notices.
378
+ - Changes should be documented when redistributing modified versions.
379
+ - The software is provided on an “AS IS” basis without warranties.
380
+
381
+ See [LICENSE](./LICENSE) for the full license text.
382
+
383
+ ## Developer
384
+
385
+ Developed by **Susant Achary**.
apps/api/Dockerfile ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ RUN apt-get update \
4
+ && apt-get install -y --no-install-recommends ffmpeg build-essential libsndfile1 \
5
+ && rm -rf /var/lib/apt/lists/*
6
+
7
+ WORKDIR /app
8
+
9
+ COPY requirements.txt ./
10
+ RUN pip install --no-cache-dir -r requirements.txt
11
+
12
+ COPY src ./src
13
+ COPY alembic.ini ./alembic.ini
14
+ COPY migrations ./migrations
15
+ COPY docker-entrypoint.sh ./docker-entrypoint.sh
16
+ RUN chmod +x ./docker-entrypoint.sh
17
+
18
+ ENV PYTHONPATH=/app/src
19
+
20
+ ENTRYPOINT ["./docker-entrypoint.sh"]
21
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
apps/api/alembic.ini ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [alembic]
2
+ script_location = migrations
3
+ prepend_sys_path = .
4
+ sqlalchemy.url =
5
+
6
+ [loggers]
7
+ keys = root,sqlalchemy,alembic
8
+
9
+ [handlers]
10
+ keys = console
11
+
12
+ [formatters]
13
+ keys = generic
14
+
15
+ [logger_root]
16
+ level = WARN
17
+ handlers = console
18
+ qualname =
19
+
20
+ [logger_sqlalchemy]
21
+ level = WARN
22
+ handlers =
23
+ qualname = sqlalchemy.engine
24
+
25
+ [logger_alembic]
26
+ level = INFO
27
+ handlers =
28
+ qualname = alembic
29
+
30
+ [handler_console]
31
+ class = StreamHandler
32
+ args = (sys.stderr,)
33
+ level = NOTSET
34
+ formatter = generic
35
+
36
+ [formatter_generic]
37
+ format = %(levelname)-5.5s [%(name)s] %(message)s
38
+ datefmt = %H:%M:%S
apps/api/docker-entrypoint.sh ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ #!/bin/sh
2
+ set -e
3
+
4
+ if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then
5
+ alembic upgrade head
6
+ fi
7
+
8
+ exec "$@"
apps/api/migrations/env.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from logging.config import fileConfig
2
+
3
+ from alembic import context
4
+ from sqlalchemy import engine_from_config, pool
5
+
6
+ from app.config import settings
7
+ from app.db import Base
8
+ from app import models # noqa: F401
9
+
10
+ config = context.config
11
+ if config.config_file_name is not None:
12
+ fileConfig(config.config_file_name)
13
+
14
+ target_metadata = Base.metadata
15
+
16
+
17
+ def run_migrations_offline() -> None:
18
+ context.configure(
19
+ url=settings.database_url,
20
+ target_metadata=target_metadata,
21
+ literal_binds=True,
22
+ dialect_opts={"paramstyle": "named"},
23
+ )
24
+ with context.begin_transaction():
25
+ context.run_migrations()
26
+
27
+
28
+ def run_migrations_online() -> None:
29
+ configuration = config.get_section(config.config_ini_section, {})
30
+ configuration["sqlalchemy.url"] = settings.database_url
31
+ connectable = engine_from_config(
32
+ configuration,
33
+ prefix="sqlalchemy.",
34
+ poolclass=pool.NullPool,
35
+ )
36
+ with connectable.connect() as connection:
37
+ context.configure(connection=connection, target_metadata=target_metadata)
38
+ with context.begin_transaction():
39
+ context.run_migrations()
40
+
41
+
42
+ if context.is_offline_mode():
43
+ run_migrations_offline()
44
+ else:
45
+ run_migrations_online()
apps/api/migrations/script.py.mako ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """${message}
2
+
3
+ Revision ID: ${up_revision}
4
+ Revises: ${down_revision | comma,n}
5
+ Create Date: ${create_date}
6
+ """
7
+ from alembic import op
8
+ import sqlalchemy as sa
9
+ ${imports if imports else ""}
10
+
11
+ revision = ${repr(up_revision)}
12
+ down_revision = ${repr(down_revision)}
13
+ branch_labels = ${repr(branch_labels)}
14
+ depends_on = ${repr(depends_on)}
15
+
16
+
17
+ def upgrade() -> None:
18
+ ${upgrades if upgrades else "pass"}
19
+
20
+
21
+ def downgrade() -> None:
22
+ ${downgrades if downgrades else "pass"}
apps/api/migrations/versions/20260607_0001_initial_foundation.py ADDED
@@ -0,0 +1,141 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """initial foundation schema
2
+
3
+ Revision ID: 20260607_0001
4
+ Revises:
5
+ Create Date: 2026-06-07
6
+ """
7
+ from alembic import op
8
+ import sqlalchemy as sa
9
+
10
+ revision = "20260607_0001"
11
+ down_revision = None
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+
16
+ def upgrade() -> None:
17
+ op.create_table(
18
+ "users",
19
+ sa.Column("id", sa.Integer(), nullable=False),
20
+ sa.Column("email", sa.String(length=255), nullable=False),
21
+ sa.Column("hashed_password", sa.String(length=255), nullable=False),
22
+ sa.Column("is_admin", sa.Boolean(), nullable=False, server_default=sa.false()),
23
+ sa.Column("created_at", sa.DateTime(), nullable=True),
24
+ sa.PrimaryKeyConstraint("id"),
25
+ )
26
+ op.create_index(op.f("ix_users_id"), "users", ["id"], unique=False)
27
+ op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True)
28
+
29
+ op.create_table(
30
+ "refresh_tokens",
31
+ sa.Column("id", sa.Integer(), nullable=False),
32
+ sa.Column("user_id", sa.Integer(), nullable=False),
33
+ sa.Column("jti_hash", sa.String(length=64), nullable=False),
34
+ sa.Column("expires_at", sa.DateTime(), nullable=False),
35
+ sa.Column("revoked_at", sa.DateTime(), nullable=True),
36
+ sa.Column("replaced_by_jti_hash", sa.String(length=64), nullable=True),
37
+ sa.Column("created_at", sa.DateTime(), nullable=True),
38
+ sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
39
+ sa.PrimaryKeyConstraint("id"),
40
+ )
41
+ op.create_index(op.f("ix_refresh_tokens_id"), "refresh_tokens", ["id"], unique=False)
42
+ op.create_index(op.f("ix_refresh_tokens_user_id"), "refresh_tokens", ["user_id"], unique=False)
43
+ op.create_index(op.f("ix_refresh_tokens_jti_hash"), "refresh_tokens", ["jti_hash"], unique=True)
44
+
45
+ op.create_table(
46
+ "sessions",
47
+ sa.Column("id", sa.Integer(), nullable=False),
48
+ sa.Column("user_id", sa.Integer(), nullable=False),
49
+ sa.Column("title", sa.String(length=255), nullable=True),
50
+ sa.Column("mode", sa.String(length=80), nullable=True),
51
+ sa.Column("prompt", sa.Text(), nullable=True),
52
+ sa.Column("goal", sa.String(length=255), nullable=True),
53
+ sa.Column("started_at", sa.DateTime(), nullable=True),
54
+ sa.Column("ended_at", sa.DateTime(), nullable=True),
55
+ sa.Column("duration_ms", sa.Integer(), nullable=True),
56
+ sa.Column("wpm_avg", sa.Float(), nullable=True),
57
+ sa.Column("filler_count", sa.Integer(), nullable=True),
58
+ sa.Column("eye_contact_pct", sa.Float(), nullable=True),
59
+ sa.Column("video_path", sa.String(length=1024), nullable=True),
60
+ sa.Column("document_path", sa.String(length=1024), nullable=True),
61
+ sa.Column("document_name", sa.String(length=255), nullable=True),
62
+ sa.Column("document_mime_type", sa.String(length=255), nullable=True),
63
+ sa.Column("document_blocks_json", sa.Text(), nullable=True),
64
+ sa.Column("transcription_status", sa.String(length=32), nullable=True),
65
+ sa.Column("created_at", sa.DateTime(), nullable=True),
66
+ sa.ForeignKeyConstraint(["user_id"], ["users.id"]),
67
+ sa.PrimaryKeyConstraint("id"),
68
+ )
69
+ op.create_index(op.f("ix_sessions_id"), "sessions", ["id"], unique=False)
70
+
71
+ op.create_table(
72
+ "metrics_timeseries",
73
+ sa.Column("id", sa.Integer(), nullable=False),
74
+ sa.Column("session_id", sa.Integer(), nullable=False),
75
+ sa.Column("t", sa.Integer(), nullable=False),
76
+ sa.Column("wpm", sa.Float(), nullable=True),
77
+ sa.Column("rms", sa.Float(), nullable=True),
78
+ sa.Column("eye_contact", sa.Float(), nullable=True),
79
+ sa.Column("smile", sa.Float(), nullable=True),
80
+ sa.Column("yaw", sa.Float(), nullable=True),
81
+ sa.Column("pitch", sa.Float(), nullable=True),
82
+ sa.Column("roll", sa.Float(), nullable=True),
83
+ sa.Column("filler_count", sa.Integer(), nullable=True),
84
+ sa.Column("pause_ms", sa.Integer(), nullable=True),
85
+ sa.ForeignKeyConstraint(["session_id"], ["sessions.id"]),
86
+ sa.PrimaryKeyConstraint("id"),
87
+ )
88
+ op.create_index(op.f("ix_metrics_timeseries_id"), "metrics_timeseries", ["id"], unique=False)
89
+
90
+ op.create_table(
91
+ "scores",
92
+ sa.Column("id", sa.Integer(), nullable=False),
93
+ sa.Column("session_id", sa.Integer(), nullable=False),
94
+ sa.Column("data", sa.JSON(), nullable=False),
95
+ sa.ForeignKeyConstraint(["session_id"], ["sessions.id"]),
96
+ sa.PrimaryKeyConstraint("id"),
97
+ sa.UniqueConstraint("session_id"),
98
+ )
99
+ op.create_index(op.f("ix_scores_id"), "scores", ["id"], unique=False)
100
+
101
+ op.create_table(
102
+ "tags",
103
+ sa.Column("id", sa.Integer(), nullable=False),
104
+ sa.Column("session_id", sa.Integer(), nullable=False),
105
+ sa.Column("name", sa.String(length=80), nullable=False),
106
+ sa.ForeignKeyConstraint(["session_id"], ["sessions.id"]),
107
+ sa.PrimaryKeyConstraint("id"),
108
+ )
109
+ op.create_index(op.f("ix_tags_id"), "tags", ["id"], unique=False)
110
+
111
+ op.create_table(
112
+ "transcript_segments",
113
+ sa.Column("id", sa.Integer(), nullable=False),
114
+ sa.Column("session_id", sa.Integer(), nullable=False),
115
+ sa.Column("start_ms", sa.Integer(), nullable=False),
116
+ sa.Column("end_ms", sa.Integer(), nullable=False),
117
+ sa.Column("text", sa.Text(), nullable=False),
118
+ sa.ForeignKeyConstraint(["session_id"], ["sessions.id"]),
119
+ sa.PrimaryKeyConstraint("id"),
120
+ )
121
+ op.create_index(op.f("ix_transcript_segments_id"), "transcript_segments", ["id"], unique=False)
122
+
123
+
124
+ def downgrade() -> None:
125
+ op.drop_index(op.f("ix_transcript_segments_id"), table_name="transcript_segments")
126
+ op.drop_table("transcript_segments")
127
+ op.drop_index(op.f("ix_tags_id"), table_name="tags")
128
+ op.drop_table("tags")
129
+ op.drop_index(op.f("ix_scores_id"), table_name="scores")
130
+ op.drop_table("scores")
131
+ op.drop_index(op.f("ix_metrics_timeseries_id"), table_name="metrics_timeseries")
132
+ op.drop_table("metrics_timeseries")
133
+ op.drop_index(op.f("ix_sessions_id"), table_name="sessions")
134
+ op.drop_table("sessions")
135
+ op.drop_index(op.f("ix_refresh_tokens_jti_hash"), table_name="refresh_tokens")
136
+ op.drop_index(op.f("ix_refresh_tokens_user_id"), table_name="refresh_tokens")
137
+ op.drop_index(op.f("ix_refresh_tokens_id"), table_name="refresh_tokens")
138
+ op.drop_table("refresh_tokens")
139
+ op.drop_index(op.f("ix_users_email"), table_name="users")
140
+ op.drop_index(op.f("ix_users_id"), table_name="users")
141
+ op.drop_table("users")
apps/api/migrations/versions/20260608_0002_analytics_coaching.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """add analytics and coaching tables
2
+
3
+ Revision ID: 20260608_0002
4
+ Revises: 20260607_0001
5
+ Create Date: 2026-06-08
6
+ """
7
+ from alembic import op
8
+ import sqlalchemy as sa
9
+
10
+ revision = "20260608_0002"
11
+ down_revision = "20260607_0001"
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+
16
+ def upgrade() -> None:
17
+ op.create_table(
18
+ "session_metrics",
19
+ sa.Column("id", sa.Integer(), nullable=False),
20
+ sa.Column("session_id", sa.Integer(), nullable=False),
21
+ sa.Column("user_id", sa.Integer(), nullable=False),
22
+ sa.Column("recorded_at", sa.DateTime(), nullable=False),
23
+ sa.Column("duration_seconds", sa.Float(), nullable=False, server_default="0"),
24
+ sa.Column("word_count", sa.Integer(), nullable=False, server_default="0"),
25
+ sa.Column("wpm", sa.Float(), nullable=False, server_default="0"),
26
+ sa.Column("filler_count", sa.Integer(), nullable=False, server_default="0"),
27
+ sa.Column("filler_rate", sa.Float(), nullable=False, server_default="0"),
28
+ sa.Column("pause_count", sa.Integer(), nullable=False, server_default="0"),
29
+ sa.Column("unique_word_count", sa.Integer(), nullable=False, server_default="0"),
30
+ sa.Column("vocabulary_density", sa.Float(), nullable=False, server_default="0"),
31
+ sa.Column("eye_contact_score", sa.Float(), nullable=True),
32
+ sa.Column("smile_score", sa.Float(), nullable=True),
33
+ sa.Column("head_stability_score", sa.Float(), nullable=True),
34
+ sa.Column("loudness_score", sa.Float(), nullable=True),
35
+ sa.Column("speech_score", sa.Float(), nullable=False, server_default="0"),
36
+ sa.Column("delivery_score", sa.Float(), nullable=False, server_default="0"),
37
+ sa.Column("overall_score", sa.Float(), nullable=False, server_default="0"),
38
+ sa.Column("filler_breakdown", sa.JSON(), nullable=True),
39
+ sa.Column("wpm_timeline", sa.JSON(), nullable=True),
40
+ sa.Column("created_at", sa.DateTime(), nullable=True),
41
+ sa.ForeignKeyConstraint(["session_id"], ["sessions.id"], ondelete="CASCADE"),
42
+ sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
43
+ sa.PrimaryKeyConstraint("id"),
44
+ sa.UniqueConstraint("session_id"),
45
+ )
46
+ op.create_index(op.f("ix_session_metrics_id"), "session_metrics", ["id"], unique=False)
47
+ op.create_index(op.f("ix_session_metrics_session_id"), "session_metrics", ["session_id"], unique=False)
48
+ op.create_index(op.f("ix_session_metrics_user_id"), "session_metrics", ["user_id"], unique=False)
49
+ op.create_index("ix_session_metrics_user_date", "session_metrics", ["user_id", "recorded_at"], unique=False)
50
+
51
+ op.create_table(
52
+ "daily_aggregates",
53
+ sa.Column("id", sa.Integer(), nullable=False),
54
+ sa.Column("user_id", sa.Integer(), nullable=False),
55
+ sa.Column("practice_date", sa.Date(), nullable=False),
56
+ sa.Column("session_count", sa.Integer(), nullable=False, server_default="0"),
57
+ sa.Column("total_duration_seconds", sa.Float(), nullable=False, server_default="0"),
58
+ sa.Column("avg_wpm", sa.Float(), nullable=False, server_default="0"),
59
+ sa.Column("avg_filler_rate", sa.Float(), nullable=False, server_default="0"),
60
+ sa.Column("avg_overall_score", sa.Float(), nullable=False, server_default="0"),
61
+ sa.Column("avg_speech_score", sa.Float(), nullable=False, server_default="0"),
62
+ sa.Column("avg_delivery_score", sa.Float(), nullable=False, server_default="0"),
63
+ sa.Column("best_overall_score", sa.Float(), nullable=False, server_default="0"),
64
+ sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
65
+ sa.PrimaryKeyConstraint("id"),
66
+ sa.UniqueConstraint("user_id", "practice_date", name="uq_daily_aggregates_user_date"),
67
+ )
68
+ op.create_index(op.f("ix_daily_aggregates_id"), "daily_aggregates", ["id"], unique=False)
69
+ op.create_index(op.f("ix_daily_aggregates_user_id"), "daily_aggregates", ["user_id"], unique=False)
70
+ op.create_index("ix_daily_aggregates_user_date", "daily_aggregates", ["user_id", "practice_date"], unique=False)
71
+
72
+ op.create_table(
73
+ "coaching_feedback",
74
+ sa.Column("id", sa.Integer(), nullable=False),
75
+ sa.Column("session_id", sa.Integer(), nullable=False),
76
+ sa.Column("user_id", sa.Integer(), nullable=False),
77
+ sa.Column("status", sa.String(length=20), nullable=False, server_default="pending"),
78
+ sa.Column("model_used", sa.String(length=100), nullable=True),
79
+ sa.Column("generation_time_ms", sa.Integer(), nullable=True),
80
+ sa.Column("summary", sa.Text(), nullable=True),
81
+ sa.Column("strengths", sa.JSON(), nullable=True),
82
+ sa.Column("improvements", sa.JSON(), nullable=True),
83
+ sa.Column("drills", sa.JSON(), nullable=True),
84
+ sa.Column("practice_plan", sa.JSON(), nullable=True),
85
+ sa.Column("raw_response", sa.Text(), nullable=True),
86
+ sa.Column("compared_to_session_id", sa.Integer(), nullable=True),
87
+ sa.Column("trend_context", sa.JSON(), nullable=True),
88
+ sa.Column("created_at", sa.DateTime(), nullable=True),
89
+ sa.Column("error_message", sa.Text(), nullable=True),
90
+ sa.ForeignKeyConstraint(["session_id"], ["sessions.id"], ondelete="CASCADE"),
91
+ sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
92
+ sa.PrimaryKeyConstraint("id"),
93
+ sa.UniqueConstraint("session_id"),
94
+ )
95
+ op.create_index(op.f("ix_coaching_feedback_id"), "coaching_feedback", ["id"], unique=False)
96
+ op.create_index(op.f("ix_coaching_feedback_session_id"), "coaching_feedback", ["session_id"], unique=False)
97
+ op.create_index(op.f("ix_coaching_feedback_user_id"), "coaching_feedback", ["user_id"], unique=False)
98
+ op.create_index("ix_coaching_feedback_user_created", "coaching_feedback", ["user_id", "created_at"], unique=False)
99
+
100
+
101
+ def downgrade() -> None:
102
+ op.drop_index("ix_coaching_feedback_user_created", table_name="coaching_feedback")
103
+ op.drop_index(op.f("ix_coaching_feedback_user_id"), table_name="coaching_feedback")
104
+ op.drop_index(op.f("ix_coaching_feedback_session_id"), table_name="coaching_feedback")
105
+ op.drop_index(op.f("ix_coaching_feedback_id"), table_name="coaching_feedback")
106
+ op.drop_table("coaching_feedback")
107
+
108
+ op.drop_index("ix_daily_aggregates_user_date", table_name="daily_aggregates")
109
+ op.drop_index(op.f("ix_daily_aggregates_user_id"), table_name="daily_aggregates")
110
+ op.drop_index(op.f("ix_daily_aggregates_id"), table_name="daily_aggregates")
111
+ op.drop_table("daily_aggregates")
112
+
113
+ op.drop_index("ix_session_metrics_user_date", table_name="session_metrics")
114
+ op.drop_index(op.f("ix_session_metrics_user_id"), table_name="session_metrics")
115
+ op.drop_index(op.f("ix_session_metrics_session_id"), table_name="session_metrics")
116
+ op.drop_index(op.f("ix_session_metrics_id"), table_name="session_metrics")
117
+ op.drop_table("session_metrics")
apps/api/migrations/versions/20260904_0003_user_goals.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """add user goals
2
+
3
+ Revision ID: 20260904_0003
4
+ Revises: 20260608_0002
5
+ Create Date: 2026-09-04
6
+ """
7
+ from alembic import op
8
+ import sqlalchemy as sa
9
+
10
+ revision = "20260904_0003"
11
+ down_revision = "20260608_0002"
12
+ branch_labels = None
13
+ depends_on = None
14
+
15
+
16
+ def upgrade() -> None:
17
+ op.create_table(
18
+ "user_goals",
19
+ sa.Column("id", sa.Integer(), nullable=False),
20
+ sa.Column("user_id", sa.Integer(), nullable=False),
21
+ sa.Column("metric_name", sa.String(length=50), nullable=False),
22
+ sa.Column("comparison", sa.String(length=10), nullable=False),
23
+ sa.Column("target_value", sa.Float(), nullable=False),
24
+ sa.Column("target_upper", sa.Float(), nullable=True),
25
+ sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
26
+ sa.Column("created_at", sa.DateTime(), nullable=False),
27
+ sa.Column("updated_at", sa.DateTime(), nullable=False),
28
+ sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"),
29
+ sa.PrimaryKeyConstraint("id"),
30
+ sa.UniqueConstraint("user_id", "metric_name", name="uq_user_goals_user_metric"),
31
+ )
32
+ op.create_index(op.f("ix_user_goals_id"), "user_goals", ["id"], unique=False)
33
+ op.create_index(op.f("ix_user_goals_user_id"), "user_goals", ["user_id"], unique=False)
34
+ op.create_index("ix_user_goals_user_active", "user_goals", ["user_id", "is_active"], unique=False)
35
+
36
+
37
+ def downgrade() -> None:
38
+ op.drop_index("ix_user_goals_user_active", table_name="user_goals")
39
+ op.drop_index(op.f("ix_user_goals_user_id"), table_name="user_goals")
40
+ op.drop_index(op.f("ix_user_goals_id"), table_name="user_goals")
41
+ op.drop_table("user_goals")
apps/api/mypy.ini ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ [mypy]
2
+ python_version = 3.11
3
+ ignore_missing_imports = True
4
+ ignore_errors = True
apps/api/requirements-desktop.txt ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.111.0
2
+ uvicorn[standard]==0.30.0
3
+ sqlalchemy==2.0.30
4
+ pydantic==2.7.2
5
+ pydantic-settings==2.2.1
6
+ email-validator==2.2.0
7
+ python-jose==3.3.0
8
+ passlib[bcrypt]==1.7.4
9
+ python-multipart==0.0.9
10
+ aiofiles==23.2.1
11
+ faster-whisper==1.1.0
12
+ httpx==0.27.0
13
+ requests==2.32.3
14
+ Cython
15
+ packaging
16
+ huggingface_hub==0.24.7
17
+ nemo_toolkit[asr]
18
+ mlx-audio
apps/api/requirements-dev.txt ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ -r requirements.txt
2
+ ruff==0.5.7
3
+ mypy==1.11.1
4
+ pytest-cov==5.0.0
apps/api/requirements.txt ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ fastapi==0.111.0
2
+ uvicorn[standard]==0.30.0
3
+ sqlalchemy==2.0.30
4
+ psycopg2-binary==2.9.9
5
+ pydantic==2.7.2
6
+ pydantic-settings==2.2.1
7
+ email-validator==2.2.0
8
+ python-jose==3.3.0
9
+ passlib[bcrypt]==1.7.4
10
+ python-multipart==0.0.9
11
+ aiofiles==23.2.1
12
+ boto3==1.34.107
13
+ alembic==1.13.2
14
+ arq==0.26.1
15
+ redis[hiredis]==5.0.8
16
+ faster-whisper==1.1.0
17
+ pytest==8.2.1
18
+ httpx==0.27.0
19
+ requests==2.32.3
20
+ Cython
21
+ packaging
apps/api/ruff.toml ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ target-version = "py311"
2
+ line-length = 110
3
+
4
+ [lint]
5
+ select = ["E", "F"]
6
+ ignore = ["E402", "E501"]
7
+
8
+ [lint.per-file-ignores]
9
+ "tests/**/*.py" = ["S101"]
apps/api/src/app/__init__.py ADDED
File without changes
apps/api/src/app/auth.py ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import hashlib
2
+ import uuid
3
+ from datetime import datetime, timedelta
4
+
5
+ import bcrypt
6
+ from fastapi import Body, Cookie, Depends, Header, HTTPException, Query, Response, status
7
+ from fastapi.security import OAuth2PasswordBearer
8
+ from jose import JWTError, jwt
9
+ from pydantic import BaseModel
10
+ from sqlalchemy.orm import Session
11
+
12
+ from app import models
13
+ from app.config import settings
14
+ from app.db import get_db
15
+
16
+ oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False)
17
+ REFRESH_COOKIE_NAME = "refresh_token"
18
+ ACCESS_HEADER_NAME = "X-Presenter-Token"
19
+ REFRESH_HEADER_NAME = "X-Refresh-Token"
20
+
21
+
22
+ class TokenPayload(BaseModel):
23
+ sub: str
24
+ email: str | None = None
25
+ role: str = "user"
26
+ jti: str
27
+ type: str
28
+
29
+
30
+ def hash_password(password: str) -> str:
31
+ return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt(rounds=12)).decode("utf-8")
32
+
33
+
34
+ def verify_password(password: str, hashed_password: str) -> bool:
35
+ return bcrypt.checkpw(password.encode("utf-8"), hashed_password.encode("utf-8"))
36
+
37
+
38
+ def _utcnow() -> datetime:
39
+ return datetime.utcnow()
40
+
41
+
42
+ def _hash_jti(jti: str) -> str:
43
+ return hashlib.sha256(jti.encode("utf-8")).hexdigest()
44
+
45
+
46
+ def _token_expire_delta(token_type: str) -> timedelta:
47
+ if token_type == "refresh":
48
+ return timedelta(days=settings.refresh_token_expire_days)
49
+ return timedelta(minutes=settings.access_token_expire_minutes)
50
+
51
+
52
+ def _create_token(user: models.User, token_type: str, jti: str | None = None) -> tuple[str, str, datetime]:
53
+ token_jti = jti or str(uuid.uuid4())
54
+ expires_at = _utcnow() + _token_expire_delta(token_type)
55
+ payload = {
56
+ "sub": str(user.id),
57
+ "email": user.email,
58
+ "role": "admin" if user.is_admin else "user",
59
+ "iat": _utcnow(),
60
+ "exp": expires_at,
61
+ "jti": token_jti,
62
+ "type": token_type,
63
+ }
64
+ token = jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm)
65
+ return token, token_jti, expires_at
66
+
67
+
68
+ def create_access_token(user: models.User) -> str:
69
+ token, _jti, _expires_at = _create_token(user, "access")
70
+ return token
71
+
72
+
73
+ def create_refresh_token(db: Session, user: models.User) -> str:
74
+ token, jti, expires_at = _create_token(user, "refresh")
75
+ db.add(
76
+ models.RefreshToken(
77
+ user_id=user.id,
78
+ jti_hash=_hash_jti(jti),
79
+ expires_at=expires_at,
80
+ )
81
+ )
82
+ db.commit()
83
+ return token
84
+
85
+
86
+ def set_refresh_cookie(response: Response, token: str) -> None:
87
+ response.set_cookie(
88
+ key=REFRESH_COOKIE_NAME,
89
+ value=token,
90
+ httponly=True,
91
+ secure=settings.cookie_secure,
92
+ samesite="lax",
93
+ max_age=settings.refresh_token_expire_days * 24 * 60 * 60,
94
+ path=settings.cookie_path,
95
+ )
96
+
97
+
98
+ def clear_refresh_cookie(response: Response) -> None:
99
+ response.delete_cookie(
100
+ key=REFRESH_COOKIE_NAME,
101
+ httponly=True,
102
+ secure=settings.cookie_secure,
103
+ samesite="lax",
104
+ path=settings.cookie_path,
105
+ )
106
+
107
+
108
+ def decode_token(token: str, expected_type: str = "access") -> TokenPayload:
109
+ credentials_exception = HTTPException(
110
+ status_code=status.HTTP_401_UNAUTHORIZED,
111
+ detail="Invalid or expired token",
112
+ headers={"WWW-Authenticate": "Bearer"},
113
+ )
114
+ try:
115
+ payload = jwt.decode(
116
+ token,
117
+ settings.jwt_secret,
118
+ algorithms=[settings.jwt_algorithm],
119
+ options={"require_exp": True, "require_iat": True},
120
+ )
121
+ except JWTError as exc:
122
+ raise credentials_exception from exc
123
+
124
+ if payload.get("type") != expected_type:
125
+ raise credentials_exception
126
+
127
+ try:
128
+ return TokenPayload(
129
+ sub=str(payload["sub"]),
130
+ email=payload.get("email"),
131
+ role=payload.get("role", "user"),
132
+ jti=str(payload["jti"]),
133
+ type=str(payload["type"]),
134
+ )
135
+ except (KeyError, ValueError) as exc:
136
+ raise credentials_exception from exc
137
+
138
+
139
+ def authenticate_user(db: Session, email: str, password: str) -> models.User | None:
140
+ user = db.query(models.User).filter(models.User.email == email).first()
141
+ if not user or not verify_password(password, user.hashed_password):
142
+ return None
143
+ return user
144
+
145
+
146
+ def issue_token_pair(db: Session, response: Response, user: models.User) -> dict:
147
+ access_token = create_access_token(user)
148
+ refresh_token = create_refresh_token(db, user)
149
+ set_refresh_cookie(response, refresh_token)
150
+ return {
151
+ "access_token": access_token,
152
+ "refresh_token": refresh_token,
153
+ "token_type": "bearer",
154
+ "expires_in": settings.access_token_expire_minutes * 60,
155
+ "user": {"id": user.id, "email": user.email, "is_admin": user.is_admin},
156
+ }
157
+
158
+
159
+ def rotate_refresh_token(
160
+ db: Session,
161
+ response: Response,
162
+ refresh_token: str | None,
163
+ ) -> dict:
164
+ if not refresh_token:
165
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing refresh token")
166
+
167
+ payload = decode_token(refresh_token, expected_type="refresh")
168
+ token_row = (
169
+ db.query(models.RefreshToken)
170
+ .filter(models.RefreshToken.jti_hash == _hash_jti(payload.jti))
171
+ .first()
172
+ )
173
+ if token_row is None or token_row.revoked_at is not None or token_row.expires_at <= _utcnow():
174
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
175
+
176
+ try:
177
+ user_id = int(payload.sub)
178
+ except ValueError as exc:
179
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token") from exc
180
+
181
+ user = db.query(models.User).filter(models.User.id == user_id).first()
182
+ if user is None:
183
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token")
184
+
185
+ access_token = create_access_token(user)
186
+ new_refresh_token, new_jti, new_expires_at = _create_token(user, "refresh")
187
+ token_row.revoked_at = _utcnow()
188
+ token_row.replaced_by_jti_hash = _hash_jti(new_jti)
189
+ db.add(
190
+ models.RefreshToken(
191
+ user_id=user.id,
192
+ jti_hash=_hash_jti(new_jti),
193
+ expires_at=new_expires_at,
194
+ )
195
+ )
196
+ db.commit()
197
+ set_refresh_cookie(response, new_refresh_token)
198
+ return {
199
+ "access_token": access_token,
200
+ "refresh_token": new_refresh_token,
201
+ "token_type": "bearer",
202
+ "expires_in": settings.access_token_expire_minutes * 60,
203
+ "user": {"id": user.id, "email": user.email, "is_admin": user.is_admin},
204
+ }
205
+
206
+
207
+ def revoke_refresh_token(db: Session, response: Response, refresh_token: str | None) -> None:
208
+ if refresh_token:
209
+ try:
210
+ payload = decode_token(refresh_token, expected_type="refresh")
211
+ except HTTPException:
212
+ payload = None
213
+ if payload is not None:
214
+ token_row = (
215
+ db.query(models.RefreshToken)
216
+ .filter(models.RefreshToken.jti_hash == _hash_jti(payload.jti))
217
+ .first()
218
+ )
219
+ if token_row is not None and token_row.revoked_at is None:
220
+ token_row.revoked_at = _utcnow()
221
+ db.commit()
222
+ clear_refresh_cookie(response)
223
+
224
+
225
+ def get_current_user(
226
+ db: Session = Depends(get_db),
227
+ bearer_token: str | None = Depends(oauth2_scheme),
228
+ header_token: str | None = Header(default=None, alias=ACCESS_HEADER_NAME),
229
+ query_token: str | None = Query(default=None, alias="_pt"),
230
+ ) -> models.User:
231
+ credentials_exception = HTTPException(
232
+ status_code=status.HTTP_401_UNAUTHORIZED,
233
+ detail="Could not validate credentials",
234
+ headers={"WWW-Authenticate": "Bearer"},
235
+ )
236
+ candidates = [tok for tok in (header_token, query_token, bearer_token) if tok]
237
+ if not candidates:
238
+ raise HTTPException(
239
+ status_code=status.HTTP_401_UNAUTHORIZED,
240
+ detail="Not authenticated",
241
+ headers={"WWW-Authenticate": "Bearer"},
242
+ )
243
+
244
+ payload: TokenPayload | None = None
245
+ last_error: HTTPException = credentials_exception
246
+ for candidate in candidates:
247
+ try:
248
+ payload = decode_token(candidate, expected_type="access")
249
+ break
250
+ except HTTPException as exc:
251
+ last_error = exc
252
+
253
+ if payload is None:
254
+ raise last_error
255
+
256
+ try:
257
+ user_id = int(payload.sub)
258
+ except ValueError as exc:
259
+ raise credentials_exception from exc
260
+
261
+ user = db.query(models.User).filter(models.User.id == user_id).first()
262
+ if user is None:
263
+ raise credentials_exception
264
+ return user
265
+
266
+
267
+ def get_refresh_cookie(
268
+ refresh_token: str | None = Cookie(default=None, alias=REFRESH_COOKIE_NAME),
269
+ header_refresh_token: str | None = Header(default=None, alias=REFRESH_HEADER_NAME),
270
+ body_payload: dict | None = Body(default=None),
271
+ ) -> str | None:
272
+ body_token = None
273
+ if isinstance(body_payload, dict):
274
+ raw = body_payload.get("refresh_token")
275
+ if isinstance(raw, str) and raw.strip():
276
+ body_token = raw.strip()
277
+ return refresh_token or header_refresh_token or body_token
apps/api/src/app/config.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+
3
+ from pydantic import ConfigDict
4
+ from pydantic_settings import BaseSettings
5
+
6
+
7
+ class Settings(BaseSettings):
8
+ model_config = ConfigDict(env_prefix="", case_sensitive=False)
9
+
10
+ database_url: str = "sqlite:///./local.db"
11
+ jwt_secret: str = "change-me"
12
+ jwt_algorithm: str = "HS256"
13
+ access_token_expire_minutes: int = 15
14
+ refresh_token_expire_days: int = 7
15
+ cookie_path: str = "/auth"
16
+ environment: str = "development"
17
+ cors_origins: str = "http://localhost:3000"
18
+ max_upload_mb: int = 500
19
+ redis_host: str = "localhost"
20
+ redis_port: int = 6379
21
+ storage_backend: str = "local"
22
+ local_only_mode: bool = False
23
+ local_admin_email: str = "admin@example.local"
24
+ local_admin_password: str = ""
25
+ storage_dir: str = "./data/storage"
26
+ s3_endpoint: str | None = None
27
+ s3_access_key: str | None = None
28
+ s3_secret_key: str | None = None
29
+ s3_bucket: str | None = None
30
+ s3_region: str | None = None
31
+ transcription_backend: str = "server"
32
+ whisper_model: str = "small"
33
+ whisper_device: str = "cpu"
34
+ mlx_audio_model: str = "mlx-community/parakeet-tdt-0.6b-v3"
35
+ ollama_base_url: str = "http://127.0.0.1:11434"
36
+ lmstudio_base_url: str = "http://127.0.0.1:1234/v1"
37
+ llama_cpp_base_url: str = "http://127.0.0.1:8080"
38
+ local_engine_timeout_seconds: float = 0.75
39
+ coaching_enabled: bool = True
40
+ coaching_provider: str = "auto"
41
+ coaching_fallback_builtin: bool = True
42
+ coaching_model: str = "llama3.2:3b"
43
+ coaching_timeout_seconds: float = 90
44
+ coaching_max_transcript_chars: int = 8000
45
+ use_local_worker_fallback: bool = True
46
+
47
+ @property
48
+ def cors_origin_list(self) -> list[str]:
49
+ value = self.cors_origins.strip()
50
+ if value.startswith("["):
51
+ try:
52
+ parsed = json.loads(value)
53
+ except json.JSONDecodeError:
54
+ parsed = None
55
+ if isinstance(parsed, list):
56
+ return [str(origin).strip() for origin in parsed if str(origin).strip()]
57
+ return [origin.strip() for origin in value.split(",") if origin.strip()]
58
+
59
+ @property
60
+ def max_upload_bytes(self) -> int:
61
+ return self.max_upload_mb * 1024 * 1024
62
+
63
+ @property
64
+ def cookie_secure(self) -> bool:
65
+ return self.environment.lower() == "production"
66
+
67
+
68
+ settings = Settings()
apps/api/src/app/db.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import create_engine
2
+ from sqlalchemy.engine.url import make_url
3
+ from sqlalchemy.orm import declarative_base, sessionmaker
4
+
5
+ from app.config import settings
6
+
7
+ normalized_db_url = settings.database_url
8
+ if normalized_db_url.startswith("postgres://"):
9
+ normalized_db_url = normalized_db_url.replace("postgres://", "postgresql://", 1)
10
+
11
+ url = make_url(normalized_db_url)
12
+ connect_args = {}
13
+ if url.drivername.startswith("sqlite"):
14
+ connect_args = {"check_same_thread": False}
15
+
16
+ engine = create_engine(normalized_db_url, pool_pre_ping=True, connect_args=connect_args)
17
+ SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
18
+ Base = declarative_base()
19
+
20
+
21
+ def get_db():
22
+ db = SessionLocal()
23
+ try:
24
+ yield db
25
+ finally:
26
+ db.close()
apps/api/src/app/hf_models.py ADDED
@@ -0,0 +1,318 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ import inspect
4
+ import json
5
+ import re
6
+ import shutil
7
+ from pathlib import Path
8
+ from typing import Any, Callable
9
+
10
+ from app.config import settings
11
+
12
+ SERVER_BACKEND = "server"
13
+ MLX_AUDIO_BACKEND = "mlx_audio"
14
+ DOWNLOAD_MARKER = ".sparks-model-download.json"
15
+
16
+ SERVER_WHISPER_MODELS: tuple[dict[str, str], ...] = (
17
+ {
18
+ "id": "openai/whisper-tiny",
19
+ "name": "Whisper Tiny",
20
+ "repo_id": "openai/whisper-tiny",
21
+ "runtime_model_id": "tiny",
22
+ "description": "OpenAI Whisper Tiny served through the faster-whisper runtime.",
23
+ "languages": "99+ languages",
24
+ "backend": SERVER_BACKEND,
25
+ },
26
+ {
27
+ "id": "openai/whisper-base",
28
+ "name": "Whisper Base",
29
+ "repo_id": "openai/whisper-base",
30
+ "runtime_model_id": "base",
31
+ "description": "OpenAI Whisper Base served through the faster-whisper runtime.",
32
+ "languages": "99+ languages",
33
+ "backend": SERVER_BACKEND,
34
+ },
35
+ {
36
+ "id": "openai/whisper-small",
37
+ "name": "Whisper Small",
38
+ "repo_id": "openai/whisper-small",
39
+ "runtime_model_id": "small",
40
+ "description": "OpenAI Whisper Small served through the faster-whisper runtime.",
41
+ "languages": "99+ languages",
42
+ "backend": SERVER_BACKEND,
43
+ },
44
+ {
45
+ "id": "openai/whisper-medium",
46
+ "name": "Whisper Medium",
47
+ "repo_id": "openai/whisper-medium",
48
+ "runtime_model_id": "medium",
49
+ "description": "OpenAI Whisper Medium served through the faster-whisper runtime.",
50
+ "languages": "99+ languages",
51
+ "backend": SERVER_BACKEND,
52
+ },
53
+ {
54
+ "id": "openai/whisper-large-v3",
55
+ "name": "Whisper Large v3",
56
+ "repo_id": "openai/whisper-large-v3",
57
+ "runtime_model_id": "large-v3",
58
+ "description": "OpenAI Whisper Large v3 served through the faster-whisper runtime.",
59
+ "languages": "99+ languages",
60
+ "backend": SERVER_BACKEND,
61
+ },
62
+ {
63
+ "id": "openai/whisper-large-v3-turbo",
64
+ "name": "Whisper Large v3 Turbo",
65
+ "repo_id": "openai/whisper-large-v3-turbo",
66
+ "runtime_model_id": "turbo",
67
+ "description": "OpenAI Whisper Large v3 Turbo served through the faster-whisper runtime.",
68
+ "languages": "99+ languages",
69
+ "backend": SERVER_BACKEND,
70
+ },
71
+ )
72
+
73
+ MLX_AUDIO_STT_MODELS: tuple[dict[str, str], ...] = (
74
+ {
75
+ "id": "mlx-community/whisper-large-v3-turbo-asr-fp16",
76
+ "name": "Whisper",
77
+ "repo_id": "mlx-community/whisper-large-v3-turbo-asr-fp16",
78
+ "description": "OpenAI's robust speech-to-text model for multilingual transcription.",
79
+ "languages": "99+ languages",
80
+ "backend": MLX_AUDIO_BACKEND,
81
+ },
82
+ {
83
+ "id": "distil-whisper/distil-large-v3",
84
+ "name": "Distil-Whisper",
85
+ "repo_id": "distil-whisper/distil-large-v3",
86
+ "description": "Distilled Whisper variant optimized for faster English transcription.",
87
+ "languages": "English",
88
+ "backend": MLX_AUDIO_BACKEND,
89
+ },
90
+ {
91
+ "id": "mlx-community/Qwen3-ASR-1.7B-8bit",
92
+ "name": "Qwen3-ASR",
93
+ "repo_id": "mlx-community/Qwen3-ASR-1.7B-8bit",
94
+ "description": "Alibaba's multilingual ASR model for speech recognition across major languages.",
95
+ "languages": "ZH, EN, JA, KO, and more",
96
+ "backend": MLX_AUDIO_BACKEND,
97
+ },
98
+ {
99
+ "id": "mlx-community/Qwen3-ForcedAligner-0.6B-8bit",
100
+ "name": "Qwen3-ForcedAligner",
101
+ "repo_id": "mlx-community/Qwen3-ForcedAligner-0.6B-8bit",
102
+ "description": "Word-level audio alignment model for timestamp refinement and transcript sync.",
103
+ "languages": "ZH, EN, JA, KO, and more",
104
+ "backend": MLX_AUDIO_BACKEND,
105
+ },
106
+ {
107
+ "id": "mlx-community/parakeet-tdt-0.6b-v3",
108
+ "name": "Parakeet",
109
+ "repo_id": "mlx-community/parakeet-tdt-0.6b-v3",
110
+ "description": "NVIDIA's accurate speech recognizer with strong multilingual coverage.",
111
+ "languages": "25 European languages",
112
+ "backend": MLX_AUDIO_BACKEND,
113
+ },
114
+ {
115
+ "id": "mlx-community/Voxtral-Mini-3B-2507-bf16",
116
+ "name": "Voxtral",
117
+ "repo_id": "mlx-community/Voxtral-Mini-3B-2507-bf16",
118
+ "description": "Mistral's compact speech model for general-purpose transcription.",
119
+ "languages": "Multiple languages",
120
+ "backend": MLX_AUDIO_BACKEND,
121
+ },
122
+ {
123
+ "id": "mlx-community/Voxtral-Mini-4B-Realtime-2602-4bit",
124
+ "name": "Voxtral Realtime 4bit",
125
+ "repo_id": "mlx-community/Voxtral-Mini-4B-Realtime-2602-4bit",
126
+ "description": "Streaming-friendly Voxtral realtime model quantized for lighter local use.",
127
+ "languages": "Multiple languages",
128
+ "backend": MLX_AUDIO_BACKEND,
129
+ },
130
+ {
131
+ "id": "mlx-community/Voxtral-Mini-4B-Realtime-2602-fp16",
132
+ "name": "Voxtral Realtime fp16",
133
+ "repo_id": "mlx-community/Voxtral-Mini-4B-Realtime-2602-fp16",
134
+ "description": "Higher precision realtime Voxtral transcription model for local speech capture.",
135
+ "languages": "Multiple languages",
136
+ "backend": MLX_AUDIO_BACKEND,
137
+ },
138
+ {
139
+ "id": "mlx-community/VibeVoice-ASR-bf16",
140
+ "name": "VibeVoice-ASR",
141
+ "repo_id": "mlx-community/VibeVoice-ASR-bf16",
142
+ "description": "Microsoft's ASR model with diarization and timestamp support.",
143
+ "languages": "Multiple languages",
144
+ "backend": MLX_AUDIO_BACKEND,
145
+ },
146
+ )
147
+
148
+
149
+ def get_models_root() -> Path:
150
+ storage_dir = Path(settings.storage_dir).expanduser()
151
+ return storage_dir.parent / "models" / "hf"
152
+
153
+
154
+ def _sanitize_repo_id(repo_id: str) -> str:
155
+ return re.sub(r"[^A-Za-z0-9._-]+", "-", repo_id).strip("-")
156
+
157
+
158
+ def _model_dir(repo_id: str) -> Path:
159
+ return get_models_root() / _sanitize_repo_id(repo_id)
160
+
161
+
162
+ def _download_marker_path(repo_id: str) -> Path:
163
+ return _model_dir(repo_id) / DOWNLOAD_MARKER
164
+
165
+
166
+ def _is_model_downloaded(repo_id: str) -> bool:
167
+ model_dir = _model_dir(repo_id)
168
+ return model_dir.exists() and _download_marker_path(repo_id).exists()
169
+
170
+
171
+ def _build_model_status(model: dict[str, str]) -> dict[str, Any]:
172
+ local_path = str(_model_dir(model["repo_id"])) if _is_model_downloaded(model["repo_id"]) else None
173
+ return {
174
+ "id": model["id"],
175
+ "name": model["name"],
176
+ "repo_id": model["repo_id"],
177
+ "description": model["description"],
178
+ "languages": model["languages"],
179
+ "available": local_path is not None,
180
+ "local_path": local_path,
181
+ }
182
+
183
+
184
+ def _prepare_model_dir(repo_id: str) -> Path:
185
+ target_dir = _model_dir(repo_id)
186
+ target_dir.parent.mkdir(parents=True, exist_ok=True)
187
+ if target_dir.exists() and not _download_marker_path(repo_id).exists():
188
+ shutil.rmtree(target_dir, ignore_errors=True)
189
+ return target_dir
190
+
191
+
192
+ def _finalize_download(repo_id: str) -> None:
193
+ _download_marker_path(repo_id).write_text(
194
+ json.dumps({"repo_id": repo_id}, indent=2),
195
+ encoding="utf-8",
196
+ )
197
+
198
+
199
+ def _download_with_marker(model: dict[str, Any], downloader: Callable[[Path], None]) -> dict[str, Any]:
200
+ if _is_model_downloaded(model["repo_id"]):
201
+ return _build_model_status(model)
202
+
203
+ target_dir = _prepare_model_dir(model["repo_id"])
204
+
205
+ try:
206
+ downloader(target_dir)
207
+ except Exception as exc: # pragma: no cover - defensive wrapper around third-party download errors
208
+ if target_dir.exists() and not _download_marker_path(model["repo_id"]).exists():
209
+ shutil.rmtree(target_dir, ignore_errors=True)
210
+ raise RuntimeError(f"Failed to download model '{model['repo_id']}': {exc}") from exc
211
+
212
+ _finalize_download(model["repo_id"])
213
+ return _build_model_status(model)
214
+
215
+
216
+ def get_server_model_ids() -> tuple[str, ...]:
217
+ return tuple(model["id"] for model in SERVER_WHISPER_MODELS)
218
+
219
+
220
+ def get_server_model_keys() -> set[str]:
221
+ keys: set[str] = set()
222
+ for model in SERVER_WHISPER_MODELS:
223
+ keys.add(model["id"])
224
+ keys.add(model["repo_id"])
225
+ keys.add(model["runtime_model_id"])
226
+ return keys
227
+
228
+
229
+ def get_server_model_catalog() -> list[dict[str, Any]]:
230
+ return [_build_model_status(model) for model in SERVER_WHISPER_MODELS]
231
+
232
+
233
+ def resolve_server_model_entry(model_name: str | None = None) -> dict[str, Any]:
234
+ selected_model = model_name or settings.whisper_model
235
+ for model in SERVER_WHISPER_MODELS:
236
+ if selected_model in {model["id"], model["repo_id"], model["runtime_model_id"]}:
237
+ return {**_build_model_status(model), "runtime_model_id": model["runtime_model_id"]}
238
+ raise ValueError(
239
+ f"Unsupported server transcription model '{selected_model}'. "
240
+ f"Choose one of: {', '.join(get_server_model_ids())}"
241
+ )
242
+
243
+
244
+ def download_server_model(model_name: str) -> dict[str, Any]:
245
+ model = resolve_server_model_entry(model_name)
246
+
247
+ try:
248
+ import faster_whisper
249
+ from faster_whisper import utils as faster_whisper_utils
250
+ from faster_whisper.utils import download_model
251
+ except ImportError as exc:
252
+ raise RuntimeError(
253
+ "faster-whisper is not installed in this runtime. "
254
+ "Install it before downloading Whisper models."
255
+ ) from exc
256
+
257
+ supported_models = getattr(faster_whisper_utils, "_MODELS", {})
258
+ if isinstance(supported_models, dict) and model["runtime_model_id"] not in supported_models:
259
+ runtime_version = getattr(faster_whisper, "__version__", "installed")
260
+ raise ValueError(
261
+ f"Whisper model '{model['name']}' is not supported by faster-whisper {runtime_version}. "
262
+ f"Upgrade faster-whisper to a version that supports '{model['runtime_model_id']}' "
263
+ "before downloading this model."
264
+ )
265
+
266
+ parameters = inspect.signature(download_model).parameters
267
+
268
+ def _download(target_dir: Path) -> None:
269
+ if "output_dir" in parameters:
270
+ download_model(model["runtime_model_id"], output_dir=str(target_dir))
271
+ return
272
+ download_model(model["runtime_model_id"], str(target_dir))
273
+
274
+ return _download_with_marker(model, _download)
275
+
276
+
277
+ def get_mlx_audio_model_ids() -> tuple[str, ...]:
278
+ return tuple(model["id"] for model in MLX_AUDIO_STT_MODELS)
279
+
280
+
281
+ def get_mlx_audio_model_keys() -> set[str]:
282
+ keys: set[str] = set()
283
+ for model in MLX_AUDIO_STT_MODELS:
284
+ keys.add(model["id"])
285
+ keys.add(model["repo_id"])
286
+ return keys
287
+
288
+
289
+ def get_mlx_audio_model_catalog() -> list[dict[str, Any]]:
290
+ return [_build_model_status(model) for model in MLX_AUDIO_STT_MODELS]
291
+
292
+
293
+ def resolve_mlx_audio_model_entry(model_name: str | None = None) -> dict[str, Any]:
294
+ selected_model = model_name or settings.mlx_audio_model
295
+ for model in MLX_AUDIO_STT_MODELS:
296
+ if selected_model in {model["id"], model["repo_id"]}:
297
+ return _build_model_status(model)
298
+ raise ValueError(
299
+ f"Unsupported MLX Audio model '{selected_model}'. "
300
+ f"Choose one of: {', '.join(get_mlx_audio_model_ids())}"
301
+ )
302
+
303
+
304
+ def download_mlx_audio_model(model_name: str) -> dict[str, Any]:
305
+ model = resolve_mlx_audio_model_entry(model_name)
306
+
307
+ try:
308
+ from huggingface_hub import snapshot_download
309
+ except ImportError as exc:
310
+ raise RuntimeError(
311
+ "huggingface_hub is not installed in this desktop runtime. "
312
+ "Install it in the desktop environment to download MLX models."
313
+ ) from exc
314
+
315
+ def _download(target_dir: Path) -> None:
316
+ snapshot_download(repo_id=model["repo_id"], local_dir=str(target_dir))
317
+
318
+ return _download_with_marker(model, _download)
apps/api/src/app/local_inference.py ADDED
@@ -0,0 +1,147 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from __future__ import annotations
2
+
3
+ from typing import Callable
4
+
5
+ import requests
6
+
7
+ from app.config import settings
8
+
9
+ RECOMMENDED_LOCAL_MODELS = (
10
+ "nvidia/parakeet-tdt-0.6b-v3",
11
+ "nvidia/parakeet-ctc-1.1b",
12
+ "mistralai/Voxtral-Mini-4B-Realtime-2602",
13
+ "Qwen/Qwen3-ASR-1.7B",
14
+ )
15
+
16
+ ENGINE_LABELS = {
17
+ "ollama": "Ollama",
18
+ "lmstudio": "LM Studio",
19
+ "llamacpp": "llama.cpp",
20
+ }
21
+
22
+
23
+ def _default_engine_url(engine_id: str) -> str:
24
+ if engine_id == "ollama":
25
+ return settings.ollama_base_url
26
+ if engine_id == "lmstudio":
27
+ return settings.lmstudio_base_url
28
+ if engine_id == "llamacpp":
29
+ return settings.llama_cpp_base_url
30
+ raise ValueError(f"Unsupported local inference engine '{engine_id}'")
31
+
32
+
33
+ def _join_url(base_url: str, suffix: str) -> str:
34
+ return f"{base_url.rstrip('/')}/{suffix.lstrip('/')}"
35
+
36
+
37
+ def _normalize_engine_url(engine_id: str, base_url: str | None = None) -> str:
38
+ selected = (base_url or _default_engine_url(engine_id)).strip()
39
+ return selected.rstrip("/")
40
+
41
+
42
+ def _request_json(url: str) -> dict:
43
+ response = requests.get(url, timeout=settings.local_engine_timeout_seconds)
44
+ response.raise_for_status()
45
+ return response.json()
46
+
47
+
48
+ def _request_ok(url: str) -> bool:
49
+ response = requests.get(url, timeout=settings.local_engine_timeout_seconds)
50
+ response.raise_for_status()
51
+ return True
52
+
53
+
54
+ def _check_with_candidates(
55
+ engine_id: str,
56
+ base_url: str,
57
+ candidates: list[tuple[str, Callable[[dict], list[str]]]],
58
+ probe: Callable[[str], dict],
59
+ ) -> dict:
60
+ last_error = "Connection failed"
61
+ for suffix, parser in candidates:
62
+ url = _join_url(base_url, suffix)
63
+ try:
64
+ payload = probe(url)
65
+ return {
66
+ "id": engine_id,
67
+ "label": ENGINE_LABELS[engine_id],
68
+ "base_url": base_url,
69
+ "live": True,
70
+ "detail": "Connected",
71
+ "available_models": parser(payload),
72
+ }
73
+ except requests.RequestException as exc:
74
+ last_error = str(exc)
75
+ return {
76
+ "id": engine_id,
77
+ "label": ENGINE_LABELS[engine_id],
78
+ "base_url": base_url,
79
+ "live": False,
80
+ "detail": last_error,
81
+ "available_models": [],
82
+ }
83
+
84
+
85
+ def _parse_ollama_models(payload: dict) -> list[str]:
86
+ return [item["name"] for item in payload.get("models", []) if item.get("name")]
87
+
88
+
89
+ def _parse_openai_models(payload: dict) -> list[str]:
90
+ return [item["id"] for item in payload.get("data", []) if item.get("id")]
91
+
92
+
93
+ def _parse_empty(_payload: dict) -> list[str]:
94
+ return []
95
+
96
+
97
+ def check_engine(engine_id: str, base_url: str | None = None) -> dict:
98
+ normalized_url = _normalize_engine_url(engine_id, base_url)
99
+
100
+ if engine_id == "ollama":
101
+ return _check_with_candidates(
102
+ engine_id,
103
+ normalized_url,
104
+ [
105
+ ("api/tags", _parse_ollama_models),
106
+ ("v1/models", _parse_openai_models),
107
+ ],
108
+ _request_json,
109
+ )
110
+
111
+ if engine_id == "lmstudio":
112
+ return _check_with_candidates(
113
+ engine_id,
114
+ normalized_url,
115
+ [("models", _parse_openai_models), ("v1/models", _parse_openai_models)],
116
+ _request_json,
117
+ )
118
+
119
+ if engine_id == "llamacpp":
120
+ status = _check_with_candidates(
121
+ engine_id,
122
+ normalized_url,
123
+ [("v1/models", _parse_openai_models)],
124
+ _request_json,
125
+ )
126
+ if status["live"]:
127
+ return status
128
+
129
+ try:
130
+ _request_ok(_join_url(normalized_url, "health"))
131
+ return {
132
+ "id": engine_id,
133
+ "label": ENGINE_LABELS[engine_id],
134
+ "base_url": normalized_url,
135
+ "live": True,
136
+ "detail": "Connected",
137
+ "available_models": [],
138
+ }
139
+ except requests.RequestException as exc:
140
+ status["detail"] = str(exc)
141
+ return status
142
+
143
+ raise ValueError(f"Unsupported local inference engine '{engine_id}'")
144
+
145
+
146
+ def get_all_engine_statuses() -> list[dict]:
147
+ return [check_engine(engine_id) for engine_id in ("ollama", "lmstudio", "llamacpp")]
apps/api/src/app/main.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from contextlib import asynccontextmanager
2
+ import logging
3
+ import sys
4
+
5
+ from fastapi import FastAPI
6
+ from fastapi.middleware.cors import CORSMiddleware
7
+
8
+ from app.config import settings
9
+ from app.db import Base, engine
10
+ from app.middleware.logging import StructuredLoggingMiddleware
11
+ from app.middleware.security_headers import SecurityHeadersMiddleware
12
+ from app.routes import (
13
+ auth,
14
+ coaching,
15
+ curriculum,
16
+ dashboard,
17
+ health,
18
+ jobs,
19
+ news,
20
+ sessions,
21
+ settings as settings_routes,
22
+ setup,
23
+ )
24
+ from app.schema_upgrades import apply_startup_schema_upgrades
25
+
26
+ logging.basicConfig(
27
+ level=logging.INFO,
28
+ format="%(message)s",
29
+ handlers=[logging.StreamHandler(sys.stdout)],
30
+ )
31
+ logging.getLogger("uvicorn.access").setLevel(logging.WARNING)
32
+
33
+
34
+ @asynccontextmanager
35
+ async def lifespan(_app: FastAPI):
36
+ Base.metadata.create_all(bind=engine)
37
+ apply_startup_schema_upgrades()
38
+ yield
39
+
40
+
41
+ app = FastAPI(title="Speech-Machine Tech Presenter Coach API", lifespan=lifespan)
42
+
43
+ app.add_middleware(SecurityHeadersMiddleware)
44
+ app.add_middleware(StructuredLoggingMiddleware)
45
+ app.add_middleware(
46
+ CORSMiddleware,
47
+ allow_origins=settings.cors_origin_list,
48
+ allow_credentials=True,
49
+ allow_methods=["*"],
50
+ allow_headers=["*"],
51
+ )
52
+
53
+ app.include_router(setup.router)
54
+ app.include_router(auth.router)
55
+ app.include_router(dashboard.router)
56
+ app.include_router(curriculum.router)
57
+ app.include_router(news.router)
58
+ app.include_router(coaching.router)
59
+ app.include_router(sessions.router)
60
+ app.include_router(settings_routes.router)
61
+ app.include_router(jobs.router)
62
+ app.include_router(health.router)
apps/api/src/app/middleware/logging.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import logging
3
+ import time
4
+ from collections.abc import Callable
5
+
6
+ from starlette.middleware.base import BaseHTTPMiddleware
7
+ from starlette.requests import Request
8
+ from starlette.responses import Response
9
+
10
+ logger = logging.getLogger("speech_machine.access")
11
+
12
+
13
+ class StructuredLoggingMiddleware(BaseHTTPMiddleware):
14
+ async def dispatch(self, request: Request, call_next: Callable) -> Response:
15
+ start = time.perf_counter()
16
+ response = await call_next(request)
17
+ duration_ms = round((time.perf_counter() - start) * 1000, 2)
18
+ payload = {
19
+ "method": request.method,
20
+ "path": request.url.path,
21
+ "status": response.status_code,
22
+ "duration_ms": duration_ms,
23
+ "client_ip": request.client.host if request.client else None,
24
+ }
25
+ if response.status_code >= 500:
26
+ logger.error(json.dumps(payload))
27
+ elif response.status_code >= 400:
28
+ logger.warning(json.dumps(payload))
29
+ else:
30
+ logger.info(json.dumps(payload))
31
+ return response
apps/api/src/app/middleware/security_headers.py ADDED
@@ -0,0 +1,21 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from collections.abc import Callable
2
+
3
+ from starlette.middleware.base import BaseHTTPMiddleware
4
+ from starlette.requests import Request
5
+ from starlette.responses import Response
6
+
7
+ from app.config import settings
8
+
9
+
10
+ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
11
+ async def dispatch(self, request: Request, call_next: Callable) -> Response:
12
+ response = await call_next(request)
13
+ response.headers["X-Content-Type-Options"] = "nosniff"
14
+ response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
15
+ if settings.environment.lower() == "production":
16
+ response.headers["X-Frame-Options"] = "SAMEORIGIN"
17
+ response.headers["Permissions-Policy"] = "camera=(self), microphone=(self), geolocation=()"
18
+ response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
19
+ else:
20
+ response.headers["Permissions-Policy"] = "camera=*, microphone=*, geolocation=()"
21
+ return response
apps/api/src/app/models.py ADDED
@@ -0,0 +1,312 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import date, datetime
2
+ from sqlalchemy import Boolean, Column, Date, DateTime, Float, ForeignKey, Index, Integer, String, Text, JSON, UniqueConstraint
3
+ from sqlalchemy.orm import relationship
4
+
5
+ from app.db import Base
6
+
7
+
8
+ class User(Base):
9
+ __tablename__ = "users"
10
+
11
+ id = Column(Integer, primary_key=True, index=True)
12
+ email = Column(String(255), unique=True, index=True, nullable=False)
13
+ hashed_password = Column(String(255), nullable=False)
14
+ is_admin = Column(Boolean, default=False, nullable=False)
15
+ created_at = Column(DateTime, default=datetime.utcnow)
16
+
17
+ sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan")
18
+ refresh_tokens = relationship(
19
+ "RefreshToken", back_populates="user", cascade="all, delete-orphan"
20
+ )
21
+ session_metrics = relationship("SessionMetrics", back_populates="user", cascade="all, delete-orphan")
22
+ daily_aggregates = relationship("DailyAggregate", back_populates="user", cascade="all, delete-orphan")
23
+ coaching_feedback = relationship("CoachingFeedback", back_populates="user", cascade="all, delete-orphan")
24
+ goals = relationship("UserGoal", back_populates="user", cascade="all, delete-orphan")
25
+ presenter_profile = relationship(
26
+ "PresenterProfile", back_populates="user", uselist=False, cascade="all, delete-orphan"
27
+ )
28
+ curriculum_completions = relationship(
29
+ "CurriculumDayCompletion", back_populates="user", cascade="all, delete-orphan"
30
+ )
31
+ weekly_reflections = relationship(
32
+ "WeeklyReflection", back_populates="user", cascade="all, delete-orphan"
33
+ )
34
+
35
+
36
+ class RefreshToken(Base):
37
+ __tablename__ = "refresh_tokens"
38
+
39
+ id = Column(Integer, primary_key=True, index=True)
40
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True)
41
+ jti_hash = Column(String(64), unique=True, index=True, nullable=False)
42
+ expires_at = Column(DateTime, nullable=False)
43
+ revoked_at = Column(DateTime, nullable=True)
44
+ replaced_by_jti_hash = Column(String(64), nullable=True)
45
+ created_at = Column(DateTime, default=datetime.utcnow)
46
+
47
+ user = relationship("User", back_populates="refresh_tokens")
48
+
49
+
50
+ class Session(Base):
51
+ __tablename__ = "sessions"
52
+
53
+ id = Column(Integer, primary_key=True, index=True)
54
+ user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
55
+ title = Column(String(255), nullable=True)
56
+ mode = Column(String(80), nullable=True)
57
+ prompt = Column(Text, nullable=True)
58
+ goal = Column(String(255), nullable=True)
59
+ curriculum_day = Column(Integer, nullable=True)
60
+ exercise_id = Column(String(100), nullable=True)
61
+ story_framework = Column(String(80), nullable=True)
62
+ target_keywords_json = Column(Text, nullable=True)
63
+ teleprompter_script = Column(Text, nullable=True)
64
+ started_at = Column(DateTime, nullable=True)
65
+ ended_at = Column(DateTime, nullable=True)
66
+ duration_ms = Column(Integer, default=0)
67
+ wpm_avg = Column(Float, default=0)
68
+ filler_count = Column(Integer, default=0)
69
+ eye_contact_pct = Column(Float, default=0)
70
+ video_path = Column(String(1024), nullable=True)
71
+ document_path = Column(String(1024), nullable=True)
72
+ document_name = Column(String(255), nullable=True)
73
+ document_mime_type = Column(String(255), nullable=True)
74
+ document_blocks_json = Column(Text, nullable=True)
75
+ transcription_status = Column(String(32), default="idle")
76
+ created_at = Column(DateTime, default=datetime.utcnow)
77
+
78
+ user = relationship("User", back_populates="sessions")
79
+ transcript_segments = relationship(
80
+ "TranscriptSegment", back_populates="session", cascade="all, delete-orphan"
81
+ )
82
+ metrics = relationship(
83
+ "MetricsTimeseries", back_populates="session", cascade="all, delete-orphan"
84
+ )
85
+ score = relationship("Score", back_populates="session", uselist=False, cascade="all, delete-orphan")
86
+ tags = relationship("Tag", back_populates="session", cascade="all, delete-orphan")
87
+ metrics_snapshot = relationship(
88
+ "SessionMetrics", back_populates="session", uselist=False, cascade="all, delete-orphan"
89
+ )
90
+ coaching_feedback = relationship(
91
+ "CoachingFeedback", back_populates="session", uselist=False, cascade="all, delete-orphan"
92
+ )
93
+
94
+
95
+ class PresenterProfile(Base):
96
+ __tablename__ = "presenter_profiles"
97
+
98
+ id = Column(Integer, primary_key=True, index=True)
99
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, index=True)
100
+ display_name = Column(String(120), default="Tech Presenter", nullable=False)
101
+ primary_role = Column(String(120), default="Software Engineer & Tech Presenter", nullable=False)
102
+ focus_track = Column(String(80), default="tech_storytelling", nullable=False)
103
+ experience_level = Column(String(40), default="intermediate", nullable=False)
104
+ target_wpm_min = Column(Integer, default=125, nullable=False)
105
+ target_wpm_max = Column(Integer, default=155, nullable=False)
106
+ max_filler_rate = Column(Float, default=2.0, nullable=False)
107
+ min_eye_contact = Column(Float, default=0.7, nullable=False)
108
+ daily_minutes_goal = Column(Integer, default=15, nullable=False)
109
+ weaknesses = Column(JSON, nullable=True)
110
+ speaking_goal_statement = Column(
111
+ Text,
112
+ default="Become a confident, articulate, engaging technology presenter on camera.",
113
+ nullable=True,
114
+ )
115
+ curriculum_start_date = Column(Date, default=date.today, nullable=False)
116
+ created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
117
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
118
+
119
+ user = relationship("User", back_populates="presenter_profile")
120
+
121
+
122
+ class CurriculumDayCompletion(Base):
123
+ __tablename__ = "curriculum_day_completions"
124
+ __table_args__ = (
125
+ UniqueConstraint("user_id", "day_number", name="uq_curriculum_user_day"),
126
+ Index("ix_curriculum_user_day", "user_id", "day_number"),
127
+ )
128
+
129
+ id = Column(Integer, primary_key=True, index=True)
130
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
131
+ day_number = Column(Integer, nullable=False)
132
+ completed_session_id = Column(Integer, ForeignKey("sessions.id", ondelete="SET NULL"), nullable=True)
133
+ best_score = Column(Float, default=0, nullable=False)
134
+ attempts_count = Column(Integer, default=1, nullable=False)
135
+ reflection_notes = Column(Text, nullable=True)
136
+ completed_at = Column(DateTime, default=datetime.utcnow, nullable=False)
137
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
138
+
139
+ user = relationship("User", back_populates="curriculum_completions")
140
+
141
+
142
+ class WeeklyReflection(Base):
143
+ __tablename__ = "weekly_reflections"
144
+ __table_args__ = (
145
+ UniqueConstraint("user_id", "week_start_date", name="uq_weekly_reflections_user_week"),
146
+ Index("ix_weekly_reflections_user_week", "user_id", "week_start_date"),
147
+ )
148
+
149
+ id = Column(Integer, primary_key=True, index=True)
150
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
151
+ week_start_date = Column(Date, nullable=False)
152
+ wins_note = Column(Text, nullable=True)
153
+ focus_next_week = Column(Text, nullable=True)
154
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
155
+
156
+ user = relationship("User", back_populates="weekly_reflections")
157
+
158
+
159
+ class SessionMetrics(Base):
160
+ __tablename__ = "session_metrics"
161
+ __table_args__ = (
162
+ Index("ix_session_metrics_user_date", "user_id", "recorded_at"),
163
+ )
164
+
165
+ id = Column(Integer, primary_key=True, index=True)
166
+ session_id = Column(Integer, ForeignKey("sessions.id", ondelete="CASCADE"), nullable=False, unique=True, index=True)
167
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
168
+ recorded_at = Column(DateTime, nullable=False, default=datetime.utcnow)
169
+
170
+ duration_seconds = Column(Float, default=0, nullable=False)
171
+ word_count = Column(Integer, default=0, nullable=False)
172
+ wpm = Column(Float, default=0, nullable=False)
173
+ filler_count = Column(Integer, default=0, nullable=False)
174
+ filler_rate = Column(Float, default=0, nullable=False)
175
+ pause_count = Column(Integer, default=0, nullable=False)
176
+ unique_word_count = Column(Integer, default=0, nullable=False)
177
+ vocabulary_density = Column(Float, default=0, nullable=False)
178
+
179
+ eye_contact_score = Column(Float, nullable=True)
180
+ smile_score = Column(Float, nullable=True)
181
+ head_stability_score = Column(Float, nullable=True)
182
+ loudness_score = Column(Float, nullable=True)
183
+
184
+ speech_score = Column(Float, default=0, nullable=False)
185
+ delivery_score = Column(Float, default=0, nullable=False)
186
+ overall_score = Column(Float, default=0, nullable=False)
187
+ filler_breakdown = Column(JSON, nullable=True)
188
+ wpm_timeline = Column(JSON, nullable=True)
189
+ created_at = Column(DateTime, default=datetime.utcnow)
190
+
191
+ session = relationship("Session", back_populates="metrics_snapshot")
192
+ user = relationship("User", back_populates="session_metrics")
193
+
194
+
195
+ class DailyAggregate(Base):
196
+ __tablename__ = "daily_aggregates"
197
+ __table_args__ = (
198
+ UniqueConstraint("user_id", "practice_date", name="uq_daily_aggregates_user_date"),
199
+ Index("ix_daily_aggregates_user_date", "user_id", "practice_date"),
200
+ )
201
+
202
+ id = Column(Integer, primary_key=True, index=True)
203
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
204
+ practice_date = Column(Date, nullable=False)
205
+ session_count = Column(Integer, default=0, nullable=False)
206
+ total_duration_seconds = Column(Float, default=0, nullable=False)
207
+ avg_wpm = Column(Float, default=0, nullable=False)
208
+ avg_filler_rate = Column(Float, default=0, nullable=False)
209
+ avg_overall_score = Column(Float, default=0, nullable=False)
210
+ avg_speech_score = Column(Float, default=0, nullable=False)
211
+ avg_delivery_score = Column(Float, default=0, nullable=False)
212
+ best_overall_score = Column(Float, default=0, nullable=False)
213
+
214
+ user = relationship("User", back_populates="daily_aggregates")
215
+
216
+
217
+ class CoachingFeedback(Base):
218
+ __tablename__ = "coaching_feedback"
219
+ __table_args__ = (
220
+ Index("ix_coaching_feedback_user_created", "user_id", "created_at"),
221
+ )
222
+
223
+ id = Column(Integer, primary_key=True, index=True)
224
+ session_id = Column(Integer, ForeignKey("sessions.id", ondelete="CASCADE"), nullable=False, unique=True, index=True)
225
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
226
+ status = Column(String(20), default="pending", nullable=False)
227
+ model_used = Column(String(100), nullable=True)
228
+ generation_time_ms = Column(Integer, nullable=True)
229
+ summary = Column(Text, nullable=True)
230
+ strengths = Column(JSON, nullable=True)
231
+ improvements = Column(JSON, nullable=True)
232
+ drills = Column(JSON, nullable=True)
233
+ practice_plan = Column(JSON, nullable=True)
234
+ raw_response = Column(Text, nullable=True)
235
+ compared_to_session_id = Column(Integer, nullable=True)
236
+ trend_context = Column(JSON, nullable=True)
237
+ created_at = Column(DateTime, default=datetime.utcnow)
238
+ error_message = Column(Text, nullable=True)
239
+
240
+ session = relationship("Session", back_populates="coaching_feedback")
241
+ user = relationship("User", back_populates="coaching_feedback")
242
+
243
+
244
+ class UserGoal(Base):
245
+ __tablename__ = "user_goals"
246
+ __table_args__ = (
247
+ UniqueConstraint("user_id", "metric_name", name="uq_user_goals_user_metric"),
248
+ Index("ix_user_goals_user_active", "user_id", "is_active"),
249
+ )
250
+
251
+ id = Column(Integer, primary_key=True, index=True)
252
+ user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
253
+ metric_name = Column(String(50), nullable=False)
254
+ comparison = Column(String(10), nullable=False)
255
+ target_value = Column(Float, nullable=False)
256
+ target_upper = Column(Float, nullable=True)
257
+ is_active = Column(Boolean, default=True, nullable=False)
258
+ created_at = Column(DateTime, default=datetime.utcnow, nullable=False)
259
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
260
+
261
+ user = relationship("User", back_populates="goals")
262
+
263
+
264
+ class TranscriptSegment(Base):
265
+ __tablename__ = "transcript_segments"
266
+
267
+ id = Column(Integer, primary_key=True, index=True)
268
+ session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
269
+ start_ms = Column(Integer, nullable=False)
270
+ end_ms = Column(Integer, nullable=False)
271
+ text = Column(Text, nullable=False)
272
+
273
+ session = relationship("Session", back_populates="transcript_segments")
274
+
275
+
276
+ class MetricsTimeseries(Base):
277
+ __tablename__ = "metrics_timeseries"
278
+
279
+ id = Column(Integer, primary_key=True, index=True)
280
+ session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
281
+ t = Column(Integer, nullable=False)
282
+ wpm = Column(Float, default=0)
283
+ rms = Column(Float, default=0)
284
+ eye_contact = Column(Float, default=0)
285
+ smile = Column(Float, default=0)
286
+ yaw = Column(Float, default=0)
287
+ pitch = Column(Float, default=0)
288
+ roll = Column(Float, default=0)
289
+ filler_count = Column(Integer, default=0)
290
+ pause_ms = Column(Integer, default=0)
291
+
292
+ session = relationship("Session", back_populates="metrics")
293
+
294
+
295
+ class Score(Base):
296
+ __tablename__ = "scores"
297
+
298
+ id = Column(Integer, primary_key=True, index=True)
299
+ session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False, unique=True)
300
+ data = Column(JSON, nullable=False)
301
+
302
+ session = relationship("Session", back_populates="score")
303
+
304
+
305
+ class Tag(Base):
306
+ __tablename__ = "tags"
307
+
308
+ id = Column(Integer, primary_key=True, index=True)
309
+ session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False)
310
+ name = Column(String(80), nullable=False)
311
+
312
+ session = relationship("Session", back_populates="tags")
apps/api/src/app/rate_limit.py ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import time
2
+ from collections import defaultdict, deque
3
+ from collections.abc import Callable
4
+
5
+ from fastapi import HTTPException, Request, status
6
+
7
+ WINDOW_SECONDS = 60
8
+ _hits: dict[str, deque[float]] = defaultdict(deque)
9
+
10
+
11
+ def rate_limit(limit: int) -> Callable:
12
+ def dependency(request: Request) -> None:
13
+ client = request.client.host if request.client else "unknown"
14
+ key = f"{client}:{request.url.path}"
15
+ now = time.time()
16
+ window_start = now - WINDOW_SECONDS
17
+ bucket = _hits[key]
18
+ while bucket and bucket[0] < window_start:
19
+ bucket.popleft()
20
+ if len(bucket) >= limit:
21
+ raise HTTPException(
22
+ status_code=status.HTTP_429_TOO_MANY_REQUESTS,
23
+ detail="Rate limit exceeded. Try again shortly.",
24
+ )
25
+ bucket.append(now)
26
+
27
+ return dependency
apps/api/src/app/routes/__init__.py ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ from app.routes import auth, sessions, settings
2
+
3
+ __all__ = ["auth", "sessions", "settings"]
apps/api/src/app/routes/auth.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, Response, status
2
+ from sqlalchemy.orm import Session
3
+
4
+ from app import models, schemas
5
+ from app.auth import (
6
+ authenticate_user,
7
+ get_current_user,
8
+ get_refresh_cookie,
9
+ hash_password,
10
+ issue_token_pair,
11
+ revoke_refresh_token,
12
+ rotate_refresh_token,
13
+ )
14
+ from app.db import get_db
15
+ from app.rate_limit import rate_limit
16
+
17
+ router = APIRouter(prefix="/auth", tags=["auth"])
18
+
19
+
20
+ @router.post("/signup", response_model=schemas.Token)
21
+ def signup(payload: schemas.UserCreate, response: Response, db: Session = Depends(get_db)):
22
+ if db.query(models.User.id).first() is None:
23
+ raise HTTPException(status_code=409, detail="Initial setup required")
24
+ existing = db.query(models.User).filter(models.User.email == payload.email).first()
25
+ if existing:
26
+ raise HTTPException(status_code=400, detail="Email already registered")
27
+ user = models.User(email=payload.email, hashed_password=hash_password(payload.password), is_admin=False)
28
+ db.add(user)
29
+ db.commit()
30
+ db.refresh(user)
31
+ return issue_token_pair(db, response, user)
32
+
33
+
34
+ @router.post("/login", response_model=schemas.Token, dependencies=[Depends(rate_limit(10))])
35
+ def login(payload: schemas.UserLogin, response: Response, db: Session = Depends(get_db)):
36
+ user = authenticate_user(db, payload.email, payload.password)
37
+ if not user:
38
+ raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password")
39
+ return issue_token_pair(db, response, user)
40
+
41
+
42
+ @router.post("/refresh", response_model=schemas.Token)
43
+ def refresh(
44
+ response: Response,
45
+ refresh_token: str | None = Depends(get_refresh_cookie),
46
+ db: Session = Depends(get_db),
47
+ ):
48
+ return rotate_refresh_token(db, response, refresh_token)
49
+
50
+
51
+ @router.get("/me", response_model=schemas.UserOut)
52
+ def me(current_user: models.User = Depends(get_current_user)):
53
+ return current_user
54
+
55
+
56
+ @router.post("/logout")
57
+ def logout(
58
+ response: Response,
59
+ refresh_token: str | None = Depends(get_refresh_cookie),
60
+ db: Session = Depends(get_db),
61
+ ):
62
+ revoke_refresh_token(db, response, refresh_token)
63
+ return {"status": "ok"}
apps/api/src/app/routes/coaching.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, Query
2
+ from sqlalchemy.orm import Session
3
+
4
+ from app import models
5
+ from app.auth import get_current_user
6
+ from app.db import get_db
7
+ from app.services.analytics import compute_session_metrics, get_active_goal_context, get_session_history
8
+ from app.services.coaching import generate_coaching_feedback
9
+ from app.worker.enqueue import enqueue_coaching
10
+ from app.worker.jobs import _snapshot_to_dict, _transcript_text
11
+
12
+ router = APIRouter(prefix="/coaching", tags=["coaching"])
13
+
14
+
15
+ def _generate_inline_coaching(db: Session, session: models.Session, user_id: int) -> models.CoachingFeedback:
16
+ feedback = (
17
+ db.query(models.CoachingFeedback)
18
+ .filter(models.CoachingFeedback.session_id == session.id)
19
+ .first()
20
+ )
21
+ if feedback is None:
22
+ feedback = models.CoachingFeedback(session_id=session.id, user_id=user_id, status="generating")
23
+ db.add(feedback)
24
+ db.commit()
25
+ db.refresh(feedback)
26
+
27
+ snapshot = compute_session_metrics(db, session.id)
28
+ metrics = _snapshot_to_dict(snapshot, session)
29
+ transcript = _transcript_text(session)
30
+ history = get_session_history(db, user_id, limit=10)
31
+ active_goals = get_active_goal_context(db, user_id)
32
+
33
+ try:
34
+ result = generate_coaching_feedback(transcript, metrics, history, None, active_goals)
35
+ feedback.status = "completed"
36
+ feedback.summary = result["summary"]
37
+ feedback.strengths = result["strengths"]
38
+ feedback.improvements = result["improvements"]
39
+ feedback.drills = result["drills"]
40
+ feedback.practice_plan = result["practice_plan"]
41
+ feedback.raw_response = result["raw_response"]
42
+ feedback.model_used = result["model_used"]
43
+ feedback.generation_time_ms = result["generation_time_ms"]
44
+ feedback.error_message = None
45
+ feedback.trend_context = {
46
+ "sessions_analyzed": len(history),
47
+ "progress_note": result.get("progress_note", ""),
48
+ }
49
+ except Exception as exc:
50
+ feedback.status = "failed"
51
+ feedback.error_message = str(exc)[:1000]
52
+
53
+ db.commit()
54
+ db.refresh(feedback)
55
+ return feedback
56
+
57
+
58
+ @router.get("/session/{session_id}")
59
+ def get_session_coaching(
60
+ session_id: int,
61
+ auto: bool = Query(default=False),
62
+ db: Session = Depends(get_db),
63
+ current_user: models.User = Depends(get_current_user),
64
+ ):
65
+ feedback = (
66
+ db.query(models.CoachingFeedback)
67
+ .filter(
68
+ models.CoachingFeedback.session_id == session_id,
69
+ models.CoachingFeedback.user_id == current_user.id,
70
+ )
71
+ .first()
72
+ )
73
+ if feedback is None and auto:
74
+ session = (
75
+ db.query(models.Session)
76
+ .filter(models.Session.id == session_id, models.Session.user_id == current_user.id)
77
+ .first()
78
+ )
79
+ if session is not None and (session.transcript_segments or session.duration_ms > 0):
80
+ feedback = _generate_inline_coaching(db, session, current_user.id)
81
+
82
+ if feedback is None:
83
+ return {"status": "not_available"}
84
+ return _serialize_feedback(feedback)
85
+
86
+
87
+ @router.get("/latest")
88
+ def latest_coaching(
89
+ db: Session = Depends(get_db),
90
+ current_user: models.User = Depends(get_current_user),
91
+ ):
92
+ feedback = (
93
+ db.query(models.CoachingFeedback)
94
+ .filter(
95
+ models.CoachingFeedback.user_id == current_user.id,
96
+ models.CoachingFeedback.status == "completed",
97
+ )
98
+ .order_by(models.CoachingFeedback.created_at.desc())
99
+ .first()
100
+ )
101
+ if feedback is None:
102
+ return {"status": "not_available"}
103
+ return _serialize_feedback(feedback)
104
+
105
+
106
+ @router.get("/history")
107
+ def coaching_history(
108
+ limit: int = Query(default=10, ge=1, le=50),
109
+ db: Session = Depends(get_db),
110
+ current_user: models.User = Depends(get_current_user),
111
+ ):
112
+ rows = (
113
+ db.query(models.CoachingFeedback)
114
+ .filter(
115
+ models.CoachingFeedback.user_id == current_user.id,
116
+ models.CoachingFeedback.status == "completed",
117
+ )
118
+ .order_by(models.CoachingFeedback.created_at.desc())
119
+ .limit(limit)
120
+ .all()
121
+ )
122
+ return [_serialize_feedback(row, include_raw=False) for row in rows]
123
+
124
+
125
+ @router.post("/session/{session_id}/retry")
126
+ async def retry_session_coaching(
127
+ session_id: int,
128
+ db: Session = Depends(get_db),
129
+ current_user: models.User = Depends(get_current_user),
130
+ ):
131
+ session = (
132
+ db.query(models.Session)
133
+ .filter(models.Session.id == session_id, models.Session.user_id == current_user.id)
134
+ .first()
135
+ )
136
+ if session is None:
137
+ raise HTTPException(status_code=404, detail="Session not found")
138
+
139
+ feedback = (
140
+ db.query(models.CoachingFeedback)
141
+ .filter(models.CoachingFeedback.session_id == session_id)
142
+ .first()
143
+ )
144
+ if feedback is None:
145
+ feedback = models.CoachingFeedback(
146
+ session_id=session_id,
147
+ user_id=current_user.id,
148
+ status="pending",
149
+ )
150
+ db.add(feedback)
151
+ feedback.user_id = current_user.id
152
+ feedback.status = "pending"
153
+ feedback.error_message = None
154
+ feedback.trend_context = None
155
+ db.commit()
156
+
157
+ try:
158
+ job_id = await enqueue_coaching(session_id, current_user.id)
159
+ except Exception as exc:
160
+ feedback.status = "failed"
161
+ feedback.error_message = "Job backend unavailable"
162
+ db.commit()
163
+ raise HTTPException(status_code=503, detail="Job backend unavailable") from exc
164
+
165
+ feedback.trend_context = {"job_id": job_id}
166
+ db.commit()
167
+ return {"status": "queued", "job_id": job_id}
168
+
169
+
170
+ def _serialize_feedback(feedback: models.CoachingFeedback, include_raw: bool = False) -> dict:
171
+ payload = {
172
+ "id": feedback.id,
173
+ "session_id": feedback.session_id,
174
+ "status": feedback.status,
175
+ "summary": feedback.summary,
176
+ "strengths": feedback.strengths or [],
177
+ "improvements": feedback.improvements or [],
178
+ "drills": feedback.drills or [],
179
+ "practice_plan": feedback.practice_plan or {},
180
+ "trend_context": feedback.trend_context or {},
181
+ "model_used": feedback.model_used,
182
+ "generation_time_ms": feedback.generation_time_ms,
183
+ "created_at": feedback.created_at.isoformat() if feedback.created_at else None,
184
+ "error_message": feedback.error_message if feedback.status == "failed" else None,
185
+ }
186
+ if include_raw:
187
+ payload["raw_response"] = feedback.raw_response
188
+ return payload
apps/api/src/app/routes/curriculum.py ADDED
@@ -0,0 +1,112 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import date
2
+
3
+ from fastapi import APIRouter, Depends, HTTPException
4
+ from sqlalchemy.orm import Session
5
+
6
+ from app import models, schemas
7
+ from app.auth import get_current_user
8
+ from app.db import get_db
9
+ from app.services.analytics import prepare_user_analytics
10
+ from app.services.curriculum import (
11
+ get_curriculum_plan,
12
+ get_or_create_profile,
13
+ get_personalized_exercises,
14
+ record_curriculum_completion,
15
+ serialize_profile,
16
+ )
17
+
18
+ router = APIRouter(prefix="/curriculum", tags=["curriculum"])
19
+
20
+
21
+ @router.get("/profile")
22
+ def read_presenter_profile(
23
+ db: Session = Depends(get_db),
24
+ current_user: models.User = Depends(get_current_user),
25
+ ):
26
+ profile = get_or_create_profile(db, current_user.id)
27
+ return serialize_profile(profile)
28
+
29
+
30
+ @router.put("/profile")
31
+ def update_presenter_profile(
32
+ payload: schemas.PresenterProfileUpdate,
33
+ db: Session = Depends(get_db),
34
+ current_user: models.User = Depends(get_current_user),
35
+ ):
36
+ profile = get_or_create_profile(db, current_user.id)
37
+ updates = payload.model_dump(exclude_unset=True)
38
+ reset_start = updates.pop("reset_start_date", False)
39
+
40
+ wpm_min = updates.get("target_wpm_min", profile.target_wpm_min)
41
+ wpm_max = updates.get("target_wpm_max", profile.target_wpm_max)
42
+ if wpm_max <= wpm_min:
43
+ raise HTTPException(status_code=422, detail="target_wpm_max must be greater than target_wpm_min")
44
+
45
+ for key, value in updates.items():
46
+ if value is not None:
47
+ setattr(profile, key, value)
48
+ if reset_start:
49
+ profile.curriculum_start_date = date.today()
50
+
51
+ db.commit()
52
+ db.refresh(profile)
53
+ return serialize_profile(profile)
54
+
55
+
56
+ @router.get("/plan")
57
+ def read_curriculum_plan(
58
+ db: Session = Depends(get_db),
59
+ current_user: models.User = Depends(get_current_user),
60
+ ):
61
+ prepare_user_analytics(db, current_user.id)
62
+ return get_curriculum_plan(db, current_user.id)
63
+
64
+
65
+ @router.post("/days/{day_number}/complete")
66
+ def complete_curriculum_day(
67
+ day_number: int,
68
+ payload: schemas.CurriculumCompleteRequest,
69
+ db: Session = Depends(get_db),
70
+ current_user: models.User = Depends(get_current_user),
71
+ ):
72
+ if day_number < 1 or day_number > 30:
73
+ raise HTTPException(status_code=422, detail="Day number must be between 1 and 30")
74
+
75
+ score_value = payload.score
76
+ if payload.session_id is not None:
77
+ linked_session = (
78
+ db.query(models.Session)
79
+ .filter(models.Session.id == payload.session_id, models.Session.user_id == current_user.id)
80
+ .first()
81
+ )
82
+ if linked_session is None:
83
+ raise HTTPException(status_code=404, detail="Linked session not found")
84
+ if score_value is None and linked_session.score and isinstance(linked_session.score.data, dict):
85
+ score_value = float(linked_session.score.data.get("total", 0) or 0)
86
+
87
+ row = record_curriculum_completion(
88
+ db=db,
89
+ user_id=current_user.id,
90
+ day_number=day_number,
91
+ session_id=payload.session_id,
92
+ score=score_value,
93
+ reflection_notes=payload.reflection_notes,
94
+ )
95
+ return {
96
+ "day_number": row.day_number,
97
+ "completed": True,
98
+ "completed_session_id": row.completed_session_id,
99
+ "best_score": row.best_score,
100
+ "attempts_count": row.attempts_count,
101
+ "reflection_notes": row.reflection_notes,
102
+ "completed_at": row.completed_at.isoformat() if row.completed_at else None,
103
+ }
104
+
105
+
106
+ @router.get("/exercises")
107
+ def read_exercises(
108
+ db: Session = Depends(get_db),
109
+ current_user: models.User = Depends(get_current_user),
110
+ ):
111
+ prepare_user_analytics(db, current_user.id)
112
+ return get_personalized_exercises(db, current_user.id)
apps/api/src/app/routes/dashboard.py ADDED
@@ -0,0 +1,203 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, Query
2
+ from sqlalchemy.orm import Session
3
+
4
+ from app import models, schemas
5
+ from app.auth import get_current_user
6
+ from app.db import get_db
7
+ from app.services.analytics import (
8
+ compare_sessions,
9
+ get_activity_heatmap,
10
+ get_filler_breakdown,
11
+ get_goal_current_values,
12
+ get_overview_stats,
13
+ get_session_history,
14
+ get_trend_data,
15
+ get_user_goals,
16
+ get_weekly_review,
17
+ prepare_user_analytics,
18
+ save_weekly_reflection,
19
+ serialize_goal,
20
+ )
21
+
22
+ router = APIRouter(prefix="/dashboard", tags=["dashboard"])
23
+
24
+
25
+ def _validate_goal_values(comparison: str, target_value: float, target_upper: float | None) -> None:
26
+ if comparison == "between" and (target_upper is None or target_upper <= target_value):
27
+ raise HTTPException(status_code=422, detail="between goals require target_upper greater than target_value")
28
+ if comparison != "between" and target_upper is not None:
29
+ raise HTTPException(status_code=422, detail="target_upper is only valid for between goals")
30
+
31
+
32
+ @router.get("/overview")
33
+ def overview(
34
+ db: Session = Depends(get_db),
35
+ current_user: models.User = Depends(get_current_user),
36
+ ):
37
+ prepare_user_analytics(db, current_user.id)
38
+ return get_overview_stats(db, current_user.id)
39
+
40
+
41
+ @router.get("/weekly-review")
42
+ def weekly_review(
43
+ weeks_ago: int = Query(default=0, ge=0, le=12),
44
+ db: Session = Depends(get_db),
45
+ current_user: models.User = Depends(get_current_user),
46
+ ):
47
+ prepare_user_analytics(db, current_user.id)
48
+ return get_weekly_review(db, current_user.id, weeks_ago=weeks_ago)
49
+
50
+
51
+ @router.put("/weekly-review/note")
52
+ def update_weekly_note(
53
+ payload: schemas.WeeklyNoteUpdate,
54
+ db: Session = Depends(get_db),
55
+ current_user: models.User = Depends(get_current_user),
56
+ ):
57
+ return save_weekly_reflection(
58
+ db,
59
+ current_user.id,
60
+ payload.week_start_date,
61
+ payload.wins_note,
62
+ payload.focus_next_week,
63
+ )
64
+
65
+
66
+ @router.get("/goals", response_model=list[schemas.UserGoalOut])
67
+ def goals(
68
+ db: Session = Depends(get_db),
69
+ current_user: models.User = Depends(get_current_user),
70
+ ):
71
+ prepare_user_analytics(db, current_user.id)
72
+ return get_user_goals(db, current_user.id)
73
+
74
+
75
+ @router.post("/goals", response_model=schemas.UserGoalOut, status_code=201)
76
+ def create_goal(
77
+ payload: schemas.UserGoalCreate,
78
+ db: Session = Depends(get_db),
79
+ current_user: models.User = Depends(get_current_user),
80
+ ):
81
+ existing = (
82
+ db.query(models.UserGoal)
83
+ .filter(
84
+ models.UserGoal.user_id == current_user.id,
85
+ models.UserGoal.metric_name == payload.metric_name,
86
+ )
87
+ .first()
88
+ )
89
+ if existing is not None:
90
+ raise HTTPException(status_code=409, detail="A goal for this metric already exists")
91
+ goal = models.UserGoal(user_id=current_user.id, **payload.model_dump())
92
+ db.add(goal)
93
+ db.commit()
94
+ db.refresh(goal)
95
+ current = get_goal_current_values(db, current_user.id).get(goal.metric_name)
96
+ return serialize_goal(goal, current)
97
+
98
+
99
+ @router.patch("/goals/{goal_id}", response_model=schemas.UserGoalOut)
100
+ def update_goal(
101
+ goal_id: int,
102
+ payload: schemas.UserGoalUpdate,
103
+ db: Session = Depends(get_db),
104
+ current_user: models.User = Depends(get_current_user),
105
+ ):
106
+ goal = (
107
+ db.query(models.UserGoal)
108
+ .filter(models.UserGoal.id == goal_id, models.UserGoal.user_id == current_user.id)
109
+ .first()
110
+ )
111
+ if goal is None:
112
+ raise HTTPException(status_code=404, detail="Goal not found")
113
+ updates = payload.model_dump(exclude_unset=True)
114
+ comparison = updates.get("comparison", goal.comparison)
115
+ target_value = updates.get("target_value", goal.target_value)
116
+ target_upper = updates.get("target_upper", goal.target_upper)
117
+ if comparison != "between" and "comparison" in updates and "target_upper" not in updates:
118
+ target_upper = None
119
+ updates["target_upper"] = None
120
+ _validate_goal_values(comparison, target_value, target_upper)
121
+ for key, value in updates.items():
122
+ setattr(goal, key, value)
123
+ db.commit()
124
+ db.refresh(goal)
125
+ current = get_goal_current_values(db, current_user.id).get(goal.metric_name)
126
+ return serialize_goal(goal, current)
127
+
128
+
129
+ @router.delete("/goals/{goal_id}")
130
+ def delete_goal(
131
+ goal_id: int,
132
+ db: Session = Depends(get_db),
133
+ current_user: models.User = Depends(get_current_user),
134
+ ):
135
+ goal = (
136
+ db.query(models.UserGoal)
137
+ .filter(models.UserGoal.id == goal_id, models.UserGoal.user_id == current_user.id)
138
+ .first()
139
+ )
140
+ if goal is None:
141
+ raise HTTPException(status_code=404, detail="Goal not found")
142
+ db.delete(goal)
143
+ db.commit()
144
+ return {"status": "deleted"}
145
+
146
+
147
+ @router.get("/trends/{metric}")
148
+ def trends(
149
+ metric: str,
150
+ days: int = Query(default=90, ge=7, le=365),
151
+ db: Session = Depends(get_db),
152
+ current_user: models.User = Depends(get_current_user),
153
+ ):
154
+ prepare_user_analytics(db, current_user.id)
155
+ try:
156
+ return get_trend_data(db, current_user.id, metric, days)
157
+ except ValueError as exc:
158
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
159
+
160
+
161
+ @router.get("/sessions")
162
+ def sessions(
163
+ limit: int = Query(default=20, ge=1, le=100),
164
+ offset: int = Query(default=0, ge=0),
165
+ db: Session = Depends(get_db),
166
+ current_user: models.User = Depends(get_current_user),
167
+ ):
168
+ prepare_user_analytics(db, current_user.id)
169
+ return get_session_history(db, current_user.id, limit, offset)
170
+
171
+
172
+ @router.get("/heatmap")
173
+ def heatmap(
174
+ days: int = Query(default=365, ge=30, le=365),
175
+ db: Session = Depends(get_db),
176
+ current_user: models.User = Depends(get_current_user),
177
+ ):
178
+ prepare_user_analytics(db, current_user.id)
179
+ return get_activity_heatmap(db, current_user.id, days)
180
+
181
+
182
+ @router.get("/fillers")
183
+ def fillers(
184
+ last_n: int = Query(default=20, ge=5, le=100),
185
+ db: Session = Depends(get_db),
186
+ current_user: models.User = Depends(get_current_user),
187
+ ):
188
+ prepare_user_analytics(db, current_user.id)
189
+ return get_filler_breakdown(db, current_user.id, last_n)
190
+
191
+
192
+ @router.get("/compare")
193
+ def compare(
194
+ session_a: int = Query(...),
195
+ session_b: int = Query(...),
196
+ db: Session = Depends(get_db),
197
+ current_user: models.User = Depends(get_current_user),
198
+ ):
199
+ prepare_user_analytics(db, current_user.id)
200
+ try:
201
+ return compare_sessions(db, current_user.id, session_a, session_b)
202
+ except ValueError as exc:
203
+ raise HTTPException(status_code=404, detail=str(exc)) from exc
apps/api/src/app/routes/health.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends
2
+ from sqlalchemy import text
3
+ from sqlalchemy.orm import Session
4
+
5
+ from app.config import settings
6
+ from app.db import get_db
7
+
8
+ router = APIRouter(tags=["health"])
9
+
10
+
11
+ @router.get("/health")
12
+ def health():
13
+ return {"status": "ok"}
14
+
15
+
16
+ @router.get("/health/ready")
17
+ def readiness(db: Session = Depends(get_db)):
18
+ checks: dict[str, str] = {}
19
+ try:
20
+ db.execute(text("SELECT 1"))
21
+ checks["database"] = "ok"
22
+ except Exception as exc:
23
+ checks["database"] = f"error: {type(exc).__name__}"
24
+
25
+ try:
26
+ import redis
27
+
28
+ client = redis.Redis(host=settings.redis_host, port=settings.redis_port, socket_connect_timeout=1)
29
+ client.ping()
30
+ checks["redis"] = "ok"
31
+ except Exception as exc:
32
+ checks["redis"] = f"error: {type(exc).__name__}"
33
+
34
+ return {
35
+ "status": "ok" if all(value == "ok" for value in checks.values()) else "degraded",
36
+ "checks": checks,
37
+ }
apps/api/src/app/routes/jobs.py ADDED
@@ -0,0 +1,14 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException
2
+
3
+ from app.auth import get_current_user
4
+ from app.worker.enqueue import get_job_status
5
+
6
+ router = APIRouter(prefix="/jobs", tags=["jobs"])
7
+
8
+
9
+ @router.get("/{job_id}")
10
+ async def job_status(job_id: str, _user=Depends(get_current_user)):
11
+ try:
12
+ return await get_job_status(job_id)
13
+ except Exception as exc:
14
+ raise HTTPException(status_code=503, detail="Job backend unavailable") from exc
apps/api/src/app/routes/news.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, Query
2
+
3
+ from app import models, schemas
4
+ from app.auth import get_current_user
5
+ from app.services.tech_news import build_storyboard, fetch_tech_news_feed
6
+
7
+ router = APIRouter(prefix="/news", tags=["news"])
8
+
9
+
10
+ @router.get("/feed")
11
+ def get_news_feed(
12
+ category: str | None = Query(default=None),
13
+ refresh: bool = Query(default=False),
14
+ _current_user: models.User = Depends(get_current_user),
15
+ ):
16
+ return fetch_tech_news_feed(category=category, force_refresh=refresh)
17
+
18
+
19
+ @router.post("/storyboard")
20
+ def create_storyboard(
21
+ payload: schemas.StoryboardRequest,
22
+ _current_user: models.User = Depends(get_current_user),
23
+ ):
24
+ return build_storyboard(
25
+ headline=payload.headline,
26
+ summary=payload.summary,
27
+ source=payload.source,
28
+ url=payload.url,
29
+ category=payload.category,
30
+ framework=payload.framework,
31
+ personal_angle=payload.personal_angle,
32
+ )
apps/api/src/app/routes/sessions.py ADDED
@@ -0,0 +1,987 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import os
3
+ import re
4
+ import shutil
5
+ import tempfile
6
+ import textwrap
7
+ import zipfile
8
+ from pathlib import Path
9
+ from typing import Optional
10
+
11
+ from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile
12
+ from fastapi.responses import FileResponse, Response
13
+ from sqlalchemy.orm import Session
14
+
15
+ from app import models, schemas, transcription
16
+ from app.auth import get_current_user
17
+ from app.config import settings
18
+ from app.db import SessionLocal, get_db
19
+ from app.scoring import compute_score, compute_wpm, count_fillers, count_words
20
+ from app.services.analytics import compute_session_metrics
21
+ from app.services.curriculum import record_curriculum_completion
22
+ from app.storage import storage_client
23
+ from app.worker.enqueue import enqueue_transcription
24
+
25
+ router = APIRouter(prefix="/sessions", tags=["sessions"])
26
+
27
+
28
+ def _resolve_video_format(value: Optional[str]) -> tuple[str, str]:
29
+ if not value:
30
+ return "webm", "video/webm"
31
+ lower = value.lower()
32
+ if "audio/" in lower:
33
+ if "wav" in lower:
34
+ return "wav", "audio/wav"
35
+ if "mp4" in lower or "m4a" in lower or "aac" in lower:
36
+ return "mp4", "audio/mp4"
37
+ return "webm", "audio/webm"
38
+ if "video/" in lower:
39
+ if "mp4" in lower:
40
+ return "mp4", "video/mp4"
41
+ if "quicktime" in lower:
42
+ return "mp4", "video/quicktime"
43
+ if "webm" in lower:
44
+ return "webm", "video/webm"
45
+ return "webm", "video/webm"
46
+ suffix = Path(lower).suffix
47
+ if suffix == ".mp4":
48
+ return "mp4", "video/mp4"
49
+ if suffix == ".m4a":
50
+ return "mp4", "audio/mp4"
51
+ if suffix == ".wav":
52
+ return "wav", "audio/wav"
53
+ if suffix == ".mov":
54
+ return "mp4", "video/quicktime"
55
+ if suffix == ".webm":
56
+ return "webm", "video/webm"
57
+ return "webm", "video/webm"
58
+
59
+
60
+ def _detect_media_type(video_path: str, fallback: str) -> str:
61
+ try:
62
+ with open(video_path, "rb") as video_file:
63
+ header = video_file.read(16)
64
+ if len(header) >= 8 and header[4:8] == b"ftyp":
65
+ return "audio/mp4" if fallback.startswith("audio/") else "video/mp4"
66
+ if header.startswith(b"\x1a\x45\xdf\xa3"):
67
+ return "audio/webm" if fallback.startswith("audio/") else "video/webm"
68
+ if len(header) >= 12 and header[:4] == b"RIFF" and header[8:12] == b"WAVE":
69
+ return "audio/wav"
70
+ except OSError:
71
+ return fallback
72
+ return fallback
73
+
74
+
75
+ def _file_size_and_header(file_obj, header_size: int = 16) -> tuple[int, bytes]:
76
+ current = file_obj.tell()
77
+ file_obj.seek(0, os.SEEK_END)
78
+ size = file_obj.tell()
79
+ file_obj.seek(0)
80
+ header = file_obj.read(header_size)
81
+ file_obj.seek(current)
82
+ return size, header
83
+
84
+
85
+ def _validate_size(size: int, label: str) -> None:
86
+ if size > settings.max_upload_bytes:
87
+ raise HTTPException(
88
+ status_code=413,
89
+ detail=f"{label} exceeds the {settings.max_upload_mb} MB upload limit.",
90
+ )
91
+
92
+
93
+ def _validate_video_header(header: bytes, media_type: str) -> None:
94
+ if media_type in {"video/mp4", "video/quicktime", "audio/mp4"}:
95
+ if len(header) >= 8 and header[4:8] == b"ftyp":
96
+ return
97
+ if media_type in {"video/webm", "audio/webm"} and header.startswith(b"\x1a\x45\xdf\xa3"):
98
+ return
99
+ if media_type == "audio/wav" and len(header) >= 12 and header[:4] == b"RIFF" and header[8:12] == b"WAVE":
100
+ return
101
+ raise HTTPException(status_code=415, detail="Unsupported or invalid video file.")
102
+
103
+
104
+ def _validate_video_upload(file: UploadFile, media_type: str) -> None:
105
+ size, header = _file_size_and_header(file.file)
106
+ _validate_size(size, "Video")
107
+ _validate_video_header(header, media_type)
108
+ file.file.seek(0)
109
+
110
+
111
+ def _validate_video_path(path: Path, media_type: str) -> None:
112
+ size = path.stat().st_size
113
+ _validate_size(size, "Video")
114
+ with open(path, "rb") as merged:
115
+ header = merged.read(16)
116
+ _validate_video_header(header, media_type)
117
+
118
+
119
+ def _validate_document_upload(file: UploadFile, extension: str, mime_type: str) -> None:
120
+ size, header = _file_size_and_header(file.file)
121
+ _validate_size(size, "Document")
122
+ if extension == ".pdf" and not header.startswith(b"%PDF-"):
123
+ raise HTTPException(status_code=415, detail="Invalid PDF file.")
124
+ if mime_type == "text/plain" and b"\x00" in header:
125
+ raise HTTPException(status_code=415, detail="Invalid text file.")
126
+ file.file.seek(0)
127
+
128
+
129
+ def _serialize_transcript_segments(
130
+ transcript_segments: list[models.TranscriptSegment],
131
+ ) -> list[schemas.TranscriptSegmentOut]:
132
+ return [
133
+ schemas.TranscriptSegmentOut(
134
+ id=segment.id,
135
+ start_ms=segment.start_ms,
136
+ end_ms=segment.end_ms,
137
+ text=segment.text,
138
+ )
139
+ for segment in transcript_segments
140
+ ]
141
+
142
+
143
+ def _deserialize_document_blocks(raw_value: str | None) -> list[schemas.DocumentBlockOut]:
144
+ if not raw_value:
145
+ return []
146
+ try:
147
+ payload = json.loads(raw_value)
148
+ except json.JSONDecodeError:
149
+ return []
150
+
151
+ if not isinstance(payload, list):
152
+ return []
153
+
154
+ blocks: list[schemas.DocumentBlockOut] = []
155
+ for index, item in enumerate(payload):
156
+ text = item.get("text") if isinstance(item, dict) else item if isinstance(item, str) else ""
157
+ if not isinstance(text, str) or not text.strip():
158
+ continue
159
+ blocks.append(schemas.DocumentBlockOut(index=index, text=text.strip()))
160
+ return blocks
161
+
162
+
163
+ def _serialize_document(session: models.Session) -> schemas.SessionDocumentOut | None:
164
+ blocks = _deserialize_document_blocks(session.document_blocks_json)
165
+ if not session.document_name or not session.document_mime_type or not blocks:
166
+ return None
167
+ return schemas.SessionDocumentOut(
168
+ name=session.document_name,
169
+ mime_type=session.document_mime_type,
170
+ blocks=blocks,
171
+ )
172
+
173
+
174
+ def _serialize_metrics(metrics: list[models.MetricsTimeseries]) -> list[schemas.MetricsPointCreate]:
175
+ return [
176
+ schemas.MetricsPointCreate(
177
+ t=point.t,
178
+ wpm=point.wpm,
179
+ rms=point.rms,
180
+ eye_contact=point.eye_contact,
181
+ smile=point.smile,
182
+ yaw=point.yaw,
183
+ pitch=point.pitch,
184
+ roll=point.roll,
185
+ filler_count=point.filler_count,
186
+ pause_ms=point.pause_ms,
187
+ )
188
+ for point in metrics
189
+ ]
190
+
191
+
192
+ def _parse_keywords(raw: str | None) -> list[str]:
193
+ if not raw:
194
+ return []
195
+ try:
196
+ parsed = json.loads(raw)
197
+ if isinstance(parsed, list):
198
+ return [str(k) for k in parsed if str(k).strip()]
199
+ except json.JSONDecodeError:
200
+ pass
201
+ return []
202
+
203
+
204
+ def _safe_filename(value: str) -> str:
205
+ cleaned = re.sub(r"[^a-zA-Z0-9_-]+", "_", value).strip("_")
206
+ return cleaned or "session"
207
+
208
+
209
+ def _session_transcript_filename(session: models.Session, extension: str) -> str:
210
+ title = session.title or session.mode or f"session_{session.id}"
211
+ return f"{_safe_filename(title)}_transcript.{extension}"
212
+
213
+
214
+ def _format_timestamp(ms: int) -> str:
215
+ total_seconds = max(ms, 0) // 1000
216
+ minutes, seconds = divmod(total_seconds, 60)
217
+ hours, minutes = divmod(minutes, 60)
218
+ if hours:
219
+ return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
220
+ return f"{minutes:02d}:{seconds:02d}"
221
+
222
+
223
+ def _build_transcript_text(session: models.Session) -> str:
224
+ lines = [
225
+ session.title or session.mode or f"Session {session.id}",
226
+ f"Duration: {_format_timestamp(session.duration_ms)}",
227
+ "",
228
+ ]
229
+ for segment in session.transcript_segments:
230
+ lines.append(f"[{_format_timestamp(segment.start_ms)}] {segment.text}")
231
+ return "\n".join(lines).strip() + "\n"
232
+
233
+
234
+ def _escape_pdf_text(text: str) -> str:
235
+ return text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)")
236
+
237
+
238
+ def _pdf_stream(lines: list[str]) -> bytes:
239
+ commands = ["BT", "/F1 11 Tf", "14 TL", "72 770 Td"]
240
+ first_line = True
241
+ for line in lines:
242
+ wrapped_lines = textwrap.wrap(
243
+ line,
244
+ width=88,
245
+ replace_whitespace=False,
246
+ drop_whitespace=False,
247
+ ) or [""]
248
+ for wrapped in wrapped_lines:
249
+ if first_line:
250
+ commands.append(f"({_escape_pdf_text(wrapped)}) Tj")
251
+ first_line = False
252
+ else:
253
+ commands.append("T*")
254
+ commands.append(f"({_escape_pdf_text(wrapped)}) Tj")
255
+ commands.append("ET")
256
+ return "\n".join(commands).encode("latin-1", errors="replace")
257
+
258
+
259
+ def _build_transcript_pdf(session: models.Session) -> bytes:
260
+ transcript_lines = _build_transcript_text(session).splitlines()
261
+ lines_per_page = 48
262
+ pages = [
263
+ transcript_lines[index:index + lines_per_page]
264
+ for index in range(0, len(transcript_lines), lines_per_page)
265
+ ] or [["Transcript unavailable."]]
266
+
267
+ objects: list[bytes] = []
268
+ page_object_numbers: list[int] = []
269
+ current_object = 4
270
+
271
+ for page_lines in pages:
272
+ page_object_numbers.append(current_object)
273
+ current_object += 2
274
+
275
+ objects.append(b"<< /Type /Catalog /Pages 2 0 R >>")
276
+ kids = " ".join(f"{number} 0 R" for number in page_object_numbers)
277
+ objects.append(f"<< /Type /Pages /Kids [{kids}] /Count {len(page_object_numbers)} >>".encode("latin-1"))
278
+ objects.append(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>")
279
+
280
+ for index, page_lines in enumerate(pages):
281
+ page_object_number = page_object_numbers[index]
282
+ content_object_number = page_object_number + 1
283
+ objects.append(
284
+ (
285
+ f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] "
286
+ f"/Resources << /Font << /F1 3 0 R >> >> /Contents {content_object_number} 0 R >>"
287
+ ).encode("latin-1")
288
+ )
289
+ content_bytes = _pdf_stream(page_lines)
290
+ objects.append(
291
+ b"<< /Length "
292
+ + str(len(content_bytes)).encode("latin-1")
293
+ + b" >>\nstream\n"
294
+ + content_bytes
295
+ + b"\nendstream"
296
+ )
297
+
298
+ pdf = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n")
299
+ offsets = [0]
300
+ for object_number, body in enumerate(objects, start=1):
301
+ offsets.append(len(pdf))
302
+ pdf.extend(f"{object_number} 0 obj\n".encode("latin-1"))
303
+ pdf.extend(body)
304
+ pdf.extend(b"\nendobj\n")
305
+
306
+ xref_offset = len(pdf)
307
+ pdf.extend(f"xref\n0 {len(objects) + 1}\n".encode("latin-1"))
308
+ pdf.extend(b"0000000000 65535 f \n")
309
+ for offset in offsets[1:]:
310
+ pdf.extend(f"{offset:010d} 00000 n \n".encode("latin-1"))
311
+ pdf.extend(
312
+ (
313
+ f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\n"
314
+ f"startxref\n{xref_offset}\n%%EOF\n"
315
+ ).encode("latin-1")
316
+ )
317
+ return bytes(pdf)
318
+
319
+
320
+ def _raise_if_transcript_missing(session: models.Session) -> None:
321
+ if not session.transcript_segments:
322
+ raise HTTPException(status_code=400, detail="Transcript not available yet")
323
+
324
+
325
+ def _status_for_missing_video(session: models.Session) -> str:
326
+ if session.transcript_segments:
327
+ return "complete"
328
+ return "idle"
329
+
330
+
331
+ def _resolve_document_format(filename: str | None, content_type: str | None) -> tuple[str, str]:
332
+ suffix = Path(filename or "").suffix.lower()
333
+ normalized_content_type = (content_type or "").split(";")[0].strip().lower()
334
+ if suffix == ".pdf" or normalized_content_type == "application/pdf":
335
+ return ".pdf", "application/pdf"
336
+ if suffix == ".txt" or normalized_content_type == "text/plain":
337
+ return ".txt", "text/plain"
338
+ raise HTTPException(status_code=400, detail="Unsupported document type. Upload a PDF or TXT file.")
339
+
340
+
341
+ def _normalize_document_blocks_payload(raw_value: str) -> str:
342
+ try:
343
+ payload = json.loads(raw_value)
344
+ except json.JSONDecodeError as exc:
345
+ raise HTTPException(status_code=400, detail="Document blocks payload must be valid JSON.") from exc
346
+
347
+ if not isinstance(payload, list):
348
+ raise HTTPException(status_code=400, detail="Document blocks payload must be a list.")
349
+
350
+ blocks = []
351
+ for item in payload:
352
+ text = item.get("text") if isinstance(item, dict) else item if isinstance(item, str) else ""
353
+ if not isinstance(text, str) or not text.strip():
354
+ continue
355
+ blocks.append({"index": len(blocks), "text": text.strip()})
356
+
357
+ if not blocks:
358
+ raise HTTPException(status_code=400, detail="Document blocks payload is empty.")
359
+
360
+ return json.dumps(blocks, ensure_ascii=False)
361
+
362
+
363
+ def _delete_session_assets(session: models.Session) -> None:
364
+ if session.video_path:
365
+ storage_client.delete_file(session.video_path)
366
+ if session.document_path:
367
+ storage_client.delete_file(session.document_path)
368
+
369
+
370
+ @router.post("", response_model=schemas.SessionOut)
371
+ def create_session(
372
+ payload: schemas.SessionCreate,
373
+ db: Session = Depends(get_db),
374
+ current_user: models.User = Depends(get_current_user),
375
+ ):
376
+ clean_keywords = [str(k).strip() for k in payload.target_keywords if str(k).strip()]
377
+ seg_dicts = [seg.model_dump() for seg in payload.transcript_segments]
378
+ metric_dicts = [point.model_dump() for point in payload.metrics]
379
+
380
+ computed_wpm = payload.wpm_avg
381
+ computed_fillers = payload.filler_count
382
+ if seg_dicts:
383
+ words = count_words(seg_dicts)
384
+ if not computed_wpm and payload.duration_ms > 0:
385
+ computed_wpm = compute_wpm(words, payload.duration_ms)
386
+ if not computed_fillers:
387
+ computed_fillers = count_fillers(seg_dicts)
388
+
389
+ session = models.Session(
390
+ user_id=current_user.id,
391
+ title=payload.title,
392
+ mode=payload.mode,
393
+ prompt=payload.prompt,
394
+ goal=payload.goal,
395
+ curriculum_day=payload.curriculum_day,
396
+ exercise_id=payload.exercise_id,
397
+ story_framework=payload.story_framework,
398
+ target_keywords_json=json.dumps(clean_keywords, ensure_ascii=False) if clean_keywords else None,
399
+ teleprompter_script=payload.teleprompter_script,
400
+ started_at=payload.started_at,
401
+ ended_at=payload.ended_at,
402
+ duration_ms=payload.duration_ms,
403
+ wpm_avg=computed_wpm,
404
+ filler_count=computed_fillers,
405
+ eye_contact_pct=payload.eye_contact_pct,
406
+ transcription_status="complete" if seg_dicts else "idle",
407
+ )
408
+ db.add(session)
409
+ db.flush()
410
+
411
+ for tag in payload.tags:
412
+ db.add(models.Tag(session_id=session.id, name=tag))
413
+
414
+ for seg in payload.transcript_segments:
415
+ db.add(
416
+ models.TranscriptSegment(
417
+ session_id=session.id,
418
+ start_ms=seg.start_ms,
419
+ end_ms=seg.end_ms,
420
+ text=seg.text,
421
+ )
422
+ )
423
+
424
+ for point in payload.metrics:
425
+ db.add(
426
+ models.MetricsTimeseries(
427
+ session_id=session.id,
428
+ t=point.t,
429
+ wpm=point.wpm,
430
+ rms=point.rms,
431
+ eye_contact=point.eye_contact,
432
+ smile=point.smile,
433
+ yaw=point.yaw,
434
+ pitch=point.pitch,
435
+ roll=point.roll,
436
+ filler_count=point.filler_count,
437
+ pause_ms=point.pause_ms,
438
+ )
439
+ )
440
+
441
+ score_payload = payload.score
442
+ if score_payload is None:
443
+ score_payload = compute_score(
444
+ seg_dicts,
445
+ metric_dicts,
446
+ payload.duration_ms,
447
+ clean_keywords,
448
+ )
449
+ db.add(models.Score(session_id=session.id, data=score_payload))
450
+
451
+ db.commit()
452
+ db.refresh(session)
453
+ snapshot = compute_session_metrics(db, session.id)
454
+
455
+ if payload.curriculum_day and 1 <= payload.curriculum_day <= 30:
456
+ record_curriculum_completion(
457
+ db=db,
458
+ user_id=current_user.id,
459
+ day_number=payload.curriculum_day,
460
+ session_id=session.id,
461
+ score=snapshot.overall_score,
462
+ )
463
+
464
+ db.refresh(session)
465
+ return _session_to_out(session)
466
+
467
+
468
+ @router.patch("/{session_id}/transcript", response_model=schemas.SessionOut)
469
+ def update_session_transcript(
470
+ session_id: int,
471
+ payload: schemas.TranscriptUpdateRequest,
472
+ db: Session = Depends(get_db),
473
+ current_user: models.User = Depends(get_current_user),
474
+ ):
475
+ session = _get_session_or_404(db, current_user.id, session_id)
476
+ session.transcript_segments.clear()
477
+ db.flush()
478
+
479
+ seg_dicts = []
480
+ for seg in payload.transcript_segments:
481
+ text = seg.text.strip()
482
+ if not text:
483
+ continue
484
+ db.add(
485
+ models.TranscriptSegment(
486
+ session_id=session.id,
487
+ start_ms=max(0, seg.start_ms),
488
+ end_ms=max(seg.start_ms, seg.end_ms),
489
+ text=text,
490
+ )
491
+ )
492
+ seg_dicts.append({"start_ms": max(0, seg.start_ms), "end_ms": max(seg.start_ms, seg.end_ms), "text": text})
493
+ db.flush()
494
+
495
+ metric_dicts = [
496
+ {
497
+ "t": point.t,
498
+ "wpm": point.wpm,
499
+ "rms": point.rms,
500
+ "eye_contact": point.eye_contact,
501
+ "smile": point.smile,
502
+ "yaw": point.yaw,
503
+ "pitch": point.pitch,
504
+ "roll": point.roll,
505
+ "filler_count": point.filler_count,
506
+ "pause_ms": point.pause_ms,
507
+ }
508
+ for point in session.metrics
509
+ ]
510
+ words = count_words(seg_dicts)
511
+ if session.duration_ms > 0:
512
+ session.wpm_avg = compute_wpm(words, session.duration_ms)
513
+ session.filler_count = count_fillers(seg_dicts)
514
+ session.transcription_status = "complete" if seg_dicts else _status_for_missing_video(session)
515
+
516
+ score_payload = compute_score(
517
+ seg_dicts,
518
+ metric_dicts,
519
+ session.duration_ms,
520
+ _parse_keywords(session.target_keywords_json),
521
+ )
522
+ if session.score:
523
+ session.score.data = score_payload
524
+ else:
525
+ db.add(models.Score(session_id=session.id, data=score_payload))
526
+
527
+ # Invalidate stale coaching so next fetch regenerates with updated transcript
528
+ existing_coaching = (
529
+ db.query(models.CoachingFeedback)
530
+ .filter(models.CoachingFeedback.session_id == session.id)
531
+ .first()
532
+ )
533
+ if existing_coaching:
534
+ db.delete(existing_coaching)
535
+
536
+ db.commit()
537
+ snapshot = compute_session_metrics(db, session.id)
538
+ if session.curriculum_day and 1 <= session.curriculum_day <= 30:
539
+ record_curriculum_completion(
540
+ db=db,
541
+ user_id=current_user.id,
542
+ day_number=session.curriculum_day,
543
+ session_id=session.id,
544
+ score=snapshot.overall_score,
545
+ )
546
+ db.refresh(session)
547
+ return _session_to_out(session)
548
+
549
+
550
+ @router.get("", response_model=list[schemas.SessionListItem])
551
+ def list_sessions(
552
+ db: Session = Depends(get_db),
553
+ current_user: models.User = Depends(get_current_user),
554
+ ):
555
+ sessions = (
556
+ db.query(models.Session)
557
+ .filter(models.Session.user_id == current_user.id)
558
+ .order_by(models.Session.created_at.desc())
559
+ .all()
560
+ )
561
+ items = []
562
+ for sess in sessions:
563
+ score_data = sess.score.data if sess.score else None
564
+ items.append(
565
+ schemas.SessionListItem(
566
+ id=sess.id,
567
+ title=sess.title,
568
+ mode=sess.mode,
569
+ curriculum_day=sess.curriculum_day,
570
+ duration_ms=sess.duration_ms,
571
+ wpm_avg=sess.wpm_avg,
572
+ filler_count=sess.filler_count,
573
+ eye_contact_pct=sess.eye_contact_pct,
574
+ created_at=sess.created_at,
575
+ transcription_status=sess.transcription_status,
576
+ video_available=bool(sess.video_path),
577
+ score=score_data,
578
+ )
579
+ )
580
+ return items
581
+
582
+
583
+ @router.get("/{session_id}", response_model=schemas.SessionOut)
584
+ def get_session(
585
+ session_id: int,
586
+ db: Session = Depends(get_db),
587
+ current_user: models.User = Depends(get_current_user),
588
+ ):
589
+ session = _get_session_or_404(db, current_user.id, session_id)
590
+ return _session_to_out(session)
591
+
592
+
593
+ @router.post("/{session_id}/upload", response_model=schemas.UploadResponse)
594
+ def upload_video(
595
+ session_id: int,
596
+ file: UploadFile = File(...),
597
+ chunk_index: Optional[int] = None,
598
+ total_chunks: Optional[int] = None,
599
+ upload_id: Optional[str] = None,
600
+ mime_type: Optional[str] = None,
601
+ db: Session = Depends(get_db),
602
+ current_user: models.User = Depends(get_current_user),
603
+ ):
604
+ session = _get_session_or_404(db, current_user.id, session_id)
605
+ extension, resolved_mime = _resolve_video_format(mime_type or file.content_type or file.filename)
606
+
607
+ if chunk_index is not None and total_chunks is not None:
608
+ if not upload_id:
609
+ raise HTTPException(status_code=400, detail="upload_id required")
610
+ chunk_size, _header = _file_size_and_header(file.file)
611
+ _validate_size(chunk_size, "Video chunk")
612
+ file.file.seek(0)
613
+ tmp_dir = Path(settings.storage_dir) / "tmp" / upload_id
614
+ tmp_dir.mkdir(parents=True, exist_ok=True)
615
+ chunk_path = tmp_dir / f"{chunk_index}.part"
616
+ with open(chunk_path, "wb") as out:
617
+ shutil.copyfileobj(file.file, out)
618
+
619
+ if chunk_index == total_chunks - 1:
620
+ merged_path = tmp_dir / f"merged.{extension}"
621
+ with open(merged_path, "wb") as merged:
622
+ for idx in range(total_chunks):
623
+ part_path = tmp_dir / f"{idx}.part"
624
+ if not part_path.exists():
625
+ raise HTTPException(status_code=400, detail="missing chunk")
626
+ with open(part_path, "rb") as part:
627
+ shutil.copyfileobj(part, merged)
628
+ _validate_video_path(merged_path, resolved_mime)
629
+ key = f"sessions/{session_id}/recording.{extension}"
630
+ with open(merged_path, "rb") as merged_file:
631
+ stored_path = storage_client.save_file(key, merged_file)
632
+ session.video_path = stored_path
633
+ db.commit()
634
+ shutil.rmtree(tmp_dir)
635
+ return {"session_id": session_id, "status": "complete", "video_path": stored_path}
636
+
637
+ return {"session_id": session_id, "status": "partial"}
638
+
639
+ _validate_video_upload(file, resolved_mime)
640
+ key = f"sessions/{session_id}/recording.{extension}"
641
+ stored_path = storage_client.save_file(key, file.file)
642
+ session.video_path = stored_path
643
+ db.commit()
644
+ return {"session_id": session_id, "status": "complete", "video_path": stored_path}
645
+
646
+
647
+ @router.post("/{session_id}/document", response_model=schemas.SessionDocumentOut)
648
+ def upload_document(
649
+ session_id: int,
650
+ file: UploadFile = File(...),
651
+ blocks_json: str = Form(...),
652
+ db: Session = Depends(get_db),
653
+ current_user: models.User = Depends(get_current_user),
654
+ ):
655
+ session = _get_session_or_404(db, current_user.id, session_id)
656
+ extension, mime_type = _resolve_document_format(file.filename, file.content_type)
657
+ _validate_document_upload(file, extension, mime_type)
658
+ normalized_blocks_json = _normalize_document_blocks_payload(blocks_json)
659
+
660
+ if session.document_path:
661
+ storage_client.delete_file(session.document_path)
662
+
663
+ key = f"sessions/{session_id}/document{extension}"
664
+ stored_path = storage_client.save_file(key, file.file)
665
+ session.document_path = stored_path
666
+ session.document_name = file.filename or f"document{extension}"
667
+ session.document_mime_type = mime_type
668
+ session.document_blocks_json = normalized_blocks_json
669
+ db.commit()
670
+ db.refresh(session)
671
+
672
+ document = _serialize_document(session)
673
+ if document is None:
674
+ raise HTTPException(status_code=500, detail="Document was saved but could not be serialized.")
675
+ return document
676
+
677
+
678
+ @router.post("/{session_id}/transcribe")
679
+ async def transcribe_session(
680
+ session_id: int,
681
+ payload: schemas.TranscriptionRequest | None = None,
682
+ db: Session = Depends(get_db),
683
+ current_user: models.User = Depends(get_current_user),
684
+ ):
685
+ session = _get_session_or_404(db, current_user.id, session_id)
686
+ if not session.video_path:
687
+ raise HTTPException(status_code=400, detail="No video uploaded")
688
+ try:
689
+ selected_backend = transcription.resolve_transcription_backend(
690
+ payload.backend if payload else None,
691
+ payload.model if payload else None,
692
+ )
693
+ selected_model = transcription.resolve_transcription_model(
694
+ payload.model if payload else None,
695
+ selected_backend,
696
+ )
697
+ transcription.ensure_transcription_model_ready(selected_backend, selected_model)
698
+ except ValueError as exc:
699
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
700
+ session.transcription_status = "queued"
701
+ db.commit()
702
+
703
+ try:
704
+ job_id = await enqueue_transcription(session_id, current_user.id, selected_model, selected_backend)
705
+ except Exception as exc:
706
+ session.transcription_status = "idle"
707
+ db.commit()
708
+ raise HTTPException(status_code=503, detail="Job backend unavailable") from exc
709
+ return {"status": "queued", "job_id": job_id}
710
+
711
+
712
+ @router.delete("/{session_id}")
713
+ def delete_session(
714
+ session_id: int,
715
+ db: Session = Depends(get_db),
716
+ current_user: models.User = Depends(get_current_user),
717
+ ):
718
+ session = _get_session_or_404(db, current_user.id, session_id)
719
+ _delete_session_assets(session)
720
+ db.delete(session)
721
+ db.commit()
722
+ return {"status": "deleted"}
723
+
724
+
725
+ @router.delete("/{session_id}/video")
726
+ def delete_video(
727
+ session_id: int,
728
+ db: Session = Depends(get_db),
729
+ current_user: models.User = Depends(get_current_user),
730
+ ):
731
+ session = _get_session_or_404(db, current_user.id, session_id)
732
+ if session.video_path:
733
+ storage_client.delete_file(session.video_path)
734
+ session.video_path = None
735
+ session.transcription_status = _status_for_missing_video(session)
736
+ db.commit()
737
+ return {"status": "deleted", "transcription_status": session.transcription_status}
738
+
739
+
740
+ @router.get("/{session_id}/export")
741
+ def export_session(
742
+ session_id: int,
743
+ db: Session = Depends(get_db),
744
+ current_user: models.User = Depends(get_current_user),
745
+ ):
746
+ session = _get_session_or_404(db, current_user.id, session_id)
747
+ tmp_dir = tempfile.mkdtemp()
748
+ zip_path = os.path.join(tmp_dir, f"session_{session_id}.zip")
749
+ transcript_text = _build_transcript_text(session)
750
+ transcript_pdf = _build_transcript_pdf(session)
751
+
752
+ transcript_payload = [
753
+ {"start_ms": segment.start_ms, "end_ms": segment.end_ms, "text": segment.text}
754
+ for segment in session.transcript_segments
755
+ ]
756
+ metrics_payload = [
757
+ {
758
+ "t": point.t,
759
+ "wpm": point.wpm,
760
+ "rms": point.rms,
761
+ "eye_contact": point.eye_contact,
762
+ "smile": point.smile,
763
+ "yaw": point.yaw,
764
+ "pitch": point.pitch,
765
+ "roll": point.roll,
766
+ "filler_count": point.filler_count,
767
+ "pause_ms": point.pause_ms,
768
+ }
769
+ for point in session.metrics
770
+ ]
771
+ score_payload = session.score.data if session.score else {}
772
+
773
+ with zipfile.ZipFile(zip_path, "w") as zipf:
774
+ zipf.writestr("transcript.json", json.dumps(transcript_payload, indent=2))
775
+ zipf.writestr("transcript.txt", transcript_text)
776
+ zipf.writestr("transcript.pdf", transcript_pdf)
777
+ zipf.writestr("metrics.json", json.dumps(metrics_payload, indent=2))
778
+ zipf.writestr("score.json", json.dumps(score_payload, indent=2))
779
+ if session.document_blocks_json:
780
+ zipf.writestr("document_blocks.json", session.document_blocks_json)
781
+ if session.video_path:
782
+ try:
783
+ video_path = storage_client.get_local_path(session.video_path)
784
+ except RuntimeError:
785
+ video_path = None
786
+ if video_path and os.path.exists(video_path):
787
+ video_name = f"recording{Path(video_path).suffix or '.webm'}"
788
+ zipf.write(video_path, arcname=video_name)
789
+ if session.document_path:
790
+ try:
791
+ document_path = storage_client.get_local_path(session.document_path)
792
+ except RuntimeError:
793
+ document_path = None
794
+ if document_path and os.path.exists(document_path):
795
+ document_name = session.document_name or f"reading{Path(document_path).suffix or '.txt'}"
796
+ zipf.write(document_path, arcname=document_name)
797
+
798
+ return FileResponse(zip_path, filename=f"session_{session_id}.zip")
799
+
800
+
801
+ @router.get("/{session_id}/transcript.txt")
802
+ def download_transcript_txt(
803
+ session_id: int,
804
+ db: Session = Depends(get_db),
805
+ current_user: models.User = Depends(get_current_user),
806
+ ):
807
+ session = _get_session_or_404(db, current_user.id, session_id)
808
+ _raise_if_transcript_missing(session)
809
+ filename = _session_transcript_filename(session, "txt")
810
+ return Response(
811
+ content=_build_transcript_text(session),
812
+ media_type="text/plain; charset=utf-8",
813
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
814
+ )
815
+
816
+
817
+ @router.get("/{session_id}/transcript.pdf")
818
+ def download_transcript_pdf(
819
+ session_id: int,
820
+ db: Session = Depends(get_db),
821
+ current_user: models.User = Depends(get_current_user),
822
+ ):
823
+ session = _get_session_or_404(db, current_user.id, session_id)
824
+ _raise_if_transcript_missing(session)
825
+ filename = _session_transcript_filename(session, "pdf")
826
+ return Response(
827
+ content=_build_transcript_pdf(session),
828
+ media_type="application/pdf",
829
+ headers={"Content-Disposition": f'attachment; filename="{filename}"'},
830
+ )
831
+
832
+
833
+ @router.get("/{session_id}/video")
834
+ def get_video(
835
+ session_id: int,
836
+ db: Session = Depends(get_db),
837
+ current_user: models.User = Depends(get_current_user),
838
+ ):
839
+ session = _get_session_or_404(db, current_user.id, session_id)
840
+ if not session.video_path:
841
+ raise HTTPException(status_code=404, detail="Video not found")
842
+ try:
843
+ video_path = storage_client.get_local_path(session.video_path)
844
+ except RuntimeError as exc:
845
+ raise HTTPException(status_code=501, detail="Video download unavailable") from exc
846
+ if not os.path.exists(video_path):
847
+ raise HTTPException(status_code=404, detail="Video not found")
848
+ _extension, media_type = _resolve_video_format(video_path)
849
+ media_type = _detect_media_type(video_path, media_type)
850
+ return FileResponse(video_path, media_type=media_type)
851
+
852
+
853
+ def _get_session_or_404(db: Session, user_id: int, session_id: int) -> models.Session:
854
+ session = (
855
+ db.query(models.Session)
856
+ .filter(models.Session.id == session_id, models.Session.user_id == user_id)
857
+ .first()
858
+ )
859
+ if not session:
860
+ raise HTTPException(status_code=404, detail="Session not found")
861
+ return session
862
+
863
+
864
+ def _run_transcription(
865
+ session_id: int,
866
+ user_id: int,
867
+ model_name: str | None = None,
868
+ backend_id: str | None = None,
869
+ ) -> None:
870
+ db = SessionLocal()
871
+ session: models.Session | None = None
872
+ audio_path: str | None = None
873
+ try:
874
+ session = (
875
+ db.query(models.Session)
876
+ .filter(models.Session.id == session_id, models.Session.user_id == user_id)
877
+ .first()
878
+ )
879
+ if not session or not session.video_path:
880
+ return
881
+ session.transcription_status = "processing"
882
+ db.commit()
883
+ video_path = storage_client.get_local_path(session.video_path)
884
+ audio_path = transcription.extract_audio(video_path)
885
+ segments = transcription.transcribe_audio(audio_path, model_name, backend_id)
886
+ session.transcript_segments.clear()
887
+ db.flush()
888
+ for seg in segments:
889
+ db.add(
890
+ models.TranscriptSegment(
891
+ session_id=session.id,
892
+ start_ms=seg["start_ms"],
893
+ end_ms=seg["end_ms"],
894
+ text=seg["text"],
895
+ )
896
+ )
897
+ db.flush()
898
+ words = count_words(segments)
899
+ if session.duration_ms > 0:
900
+ session.wpm_avg = compute_wpm(words, session.duration_ms)
901
+ session.filler_count = count_fillers(segments)
902
+ score_payload = compute_score(
903
+ segments,
904
+ [
905
+ {
906
+ "t": point.t,
907
+ "wpm": point.wpm,
908
+ "rms": point.rms,
909
+ "eye_contact": point.eye_contact,
910
+ "smile": point.smile,
911
+ "yaw": point.yaw,
912
+ "pitch": point.pitch,
913
+ "roll": point.roll,
914
+ "filler_count": point.filler_count,
915
+ "pause_ms": point.pause_ms,
916
+ }
917
+ for point in session.metrics
918
+ ],
919
+ session.duration_ms,
920
+ _parse_keywords(session.target_keywords_json),
921
+ )
922
+ if session.score:
923
+ session.score.data = score_payload
924
+ else:
925
+ db.add(models.Score(session_id=session.id, data=score_payload))
926
+ session.transcription_status = "complete"
927
+ db.commit()
928
+ snapshot = compute_session_metrics(db, session.id)
929
+ if session.curriculum_day and 1 <= session.curriculum_day <= 30:
930
+ record_curriculum_completion(
931
+ db=db,
932
+ user_id=user_id,
933
+ day_number=session.curriculum_day,
934
+ session_id=session.id,
935
+ score=snapshot.overall_score,
936
+ )
937
+ except Exception:
938
+ if session is None and session_id:
939
+ session = db.query(models.Session).filter(models.Session.id == session_id).first()
940
+ if session:
941
+ if session.video_path:
942
+ session.transcription_status = "failed"
943
+ else:
944
+ session.transcription_status = _status_for_missing_video(session)
945
+ db.commit()
946
+ finally:
947
+ if audio_path:
948
+ try:
949
+ audio_file = Path(audio_path)
950
+ if audio_file.exists():
951
+ audio_file.unlink()
952
+ if audio_file.parent.exists():
953
+ audio_file.parent.rmdir()
954
+ except OSError:
955
+ pass
956
+ db.close()
957
+
958
+
959
+ def _session_to_out(session: models.Session) -> schemas.SessionOut:
960
+ tags = [tag.name for tag in session.tags]
961
+ score_data = session.score.data if session.score else None
962
+ return schemas.SessionOut(
963
+ id=session.id,
964
+ title=session.title,
965
+ mode=session.mode,
966
+ prompt=session.prompt,
967
+ goal=session.goal,
968
+ curriculum_day=session.curriculum_day,
969
+ exercise_id=session.exercise_id,
970
+ story_framework=session.story_framework,
971
+ target_keywords=_parse_keywords(session.target_keywords_json),
972
+ teleprompter_script=session.teleprompter_script,
973
+ started_at=session.started_at,
974
+ ended_at=session.ended_at,
975
+ duration_ms=session.duration_ms,
976
+ wpm_avg=session.wpm_avg,
977
+ filler_count=session.filler_count,
978
+ eye_contact_pct=session.eye_contact_pct,
979
+ video_path=session.video_path,
980
+ transcription_status=session.transcription_status,
981
+ created_at=session.created_at,
982
+ tags=tags,
983
+ transcript_segments=_serialize_transcript_segments(session.transcript_segments),
984
+ metrics=_serialize_metrics(session.metrics),
985
+ score=score_data,
986
+ document=_serialize_document(session),
987
+ )
apps/api/src/app/routes/settings.py ADDED
@@ -0,0 +1,41 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends
2
+ from fastapi import HTTPException
3
+
4
+ from app import models, schemas, transcription
5
+ from app.auth import get_current_user
6
+ from app.local_inference import check_engine, get_all_engine_statuses
7
+
8
+ router = APIRouter(prefix="/settings", tags=["settings"])
9
+
10
+
11
+ @router.get("/transcription", response_model=schemas.TranscriptionSettingsOut)
12
+ def get_transcription_settings(current_user: models.User = Depends(get_current_user)):
13
+ return {
14
+ "default_backend": transcription.get_default_transcription_backend(),
15
+ "backends": transcription.get_transcription_backends(),
16
+ "text_inference_engines": get_all_engine_statuses(),
17
+ }
18
+
19
+
20
+ @router.post("/transcription/models/download", response_model=schemas.TranscriptionModelOut)
21
+ def download_transcription_model(
22
+ payload: schemas.TranscriptionModelDownloadRequest,
23
+ current_user: models.User = Depends(get_current_user),
24
+ ):
25
+ try:
26
+ return transcription.download_transcription_model(payload.backend, payload.model)
27
+ except ValueError as exc:
28
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
29
+ except RuntimeError as exc:
30
+ raise HTTPException(status_code=503, detail=str(exc)) from exc
31
+
32
+
33
+ @router.post("/local-inference/check", response_model=schemas.InferenceEngineStatusOut)
34
+ def check_local_inference(
35
+ payload: schemas.InferenceEngineCheckRequest,
36
+ current_user: models.User = Depends(get_current_user),
37
+ ):
38
+ try:
39
+ return check_engine(payload.engine_id, payload.base_url)
40
+ except ValueError as exc:
41
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
apps/api/src/app/routes/setup.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import APIRouter, Depends, HTTPException, Response, status
2
+ from sqlalchemy.orm import Session
3
+
4
+ from app import models, schemas
5
+ from app.auth import hash_password, issue_token_pair
6
+ from app.db import get_db
7
+ from app.rate_limit import rate_limit
8
+
9
+ router = APIRouter(prefix="/setup", tags=["setup"])
10
+
11
+
12
+ @router.get("/status", response_model=schemas.SetupStatus)
13
+ def setup_status(db: Session = Depends(get_db)):
14
+ user_exists = db.query(models.User.id).first() is not None
15
+ return {"setup_required": not user_exists}
16
+
17
+
18
+ @router.post(
19
+ "",
20
+ response_model=schemas.Token,
21
+ status_code=status.HTTP_201_CREATED,
22
+ dependencies=[Depends(rate_limit(5))],
23
+ )
24
+ def initial_setup(payload: schemas.UserCreate, response: Response, db: Session = Depends(get_db)):
25
+ user_exists = db.query(models.User.id).first() is not None
26
+ if user_exists:
27
+ raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Setup already completed")
28
+
29
+ user = models.User(
30
+ email=payload.email,
31
+ hashed_password=hash_password(payload.password),
32
+ is_admin=True,
33
+ )
34
+ db.add(user)
35
+ db.commit()
36
+ db.refresh(user)
37
+ return issue_token_pair(db, response, user)
apps/api/src/app/schema_upgrades.py ADDED
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from sqlalchemy import inspect
2
+
3
+ from app.db import engine
4
+
5
+
6
+ SESSION_COLUMN_DDL: dict[str, str] = {
7
+ "document_path": "ALTER TABLE sessions ADD COLUMN document_path VARCHAR(1024)",
8
+ "document_name": "ALTER TABLE sessions ADD COLUMN document_name VARCHAR(255)",
9
+ "document_mime_type": "ALTER TABLE sessions ADD COLUMN document_mime_type VARCHAR(255)",
10
+ "document_blocks_json": "ALTER TABLE sessions ADD COLUMN document_blocks_json TEXT",
11
+ "curriculum_day": "ALTER TABLE sessions ADD COLUMN curriculum_day INTEGER",
12
+ "exercise_id": "ALTER TABLE sessions ADD COLUMN exercise_id VARCHAR(100)",
13
+ "story_framework": "ALTER TABLE sessions ADD COLUMN story_framework VARCHAR(80)",
14
+ "target_keywords_json": "ALTER TABLE sessions ADD COLUMN target_keywords_json TEXT",
15
+ "teleprompter_script": "ALTER TABLE sessions ADD COLUMN teleprompter_script TEXT",
16
+ }
17
+
18
+
19
+ def apply_startup_schema_upgrades() -> None:
20
+ inspector = inspect(engine)
21
+ if "sessions" not in inspector.get_table_names():
22
+ return
23
+
24
+ existing_columns = {column["name"] for column in inspector.get_columns("sessions")}
25
+ statements = [ddl for column_name, ddl in SESSION_COLUMN_DDL.items() if column_name not in existing_columns]
26
+ if not statements:
27
+ return
28
+
29
+ with engine.begin() as connection:
30
+ for statement in statements:
31
+ connection.exec_driver_sql(statement)
apps/api/src/app/schemas.py ADDED
@@ -0,0 +1,324 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from datetime import date, datetime
2
+ from typing import Literal
3
+
4
+ from pydantic import BaseModel, ConfigDict, EmailStr, Field, model_validator
5
+
6
+
7
+ class UserCreate(BaseModel):
8
+ email: EmailStr
9
+ password: str = Field(min_length=6)
10
+
11
+
12
+ class UserLogin(BaseModel):
13
+ email: str
14
+ password: str
15
+
16
+
17
+ class UserOut(BaseModel):
18
+ model_config = ConfigDict(from_attributes=True)
19
+
20
+ id: int
21
+ email: EmailStr
22
+ is_admin: bool = False
23
+
24
+
25
+ class Token(BaseModel):
26
+ access_token: str
27
+ refresh_token: str | None = None
28
+ token_type: str = "bearer"
29
+ expires_in: int | None = None
30
+ user: UserOut | None = None
31
+
32
+
33
+ class SetupStatus(BaseModel):
34
+ setup_required: bool
35
+
36
+
37
+ GoalMetric = Literal[
38
+ "wpm",
39
+ "filler_rate",
40
+ "overall_score",
41
+ "practice_days_per_week",
42
+ "session_duration_minutes",
43
+ "eye_contact_score",
44
+ "delivery_score",
45
+ ]
46
+ GoalComparison = Literal["gte", "lte", "between"]
47
+
48
+
49
+ class UserGoalCreate(BaseModel):
50
+ metric_name: GoalMetric
51
+ comparison: GoalComparison
52
+ target_value: float = Field(ge=0)
53
+ target_upper: float | None = Field(default=None, ge=0)
54
+ is_active: bool = True
55
+
56
+ @model_validator(mode="after")
57
+ def validate_range(self):
58
+ if self.comparison == "between":
59
+ if self.target_upper is None or self.target_upper <= self.target_value:
60
+ raise ValueError("between goals require target_upper greater than target_value")
61
+ elif self.target_upper is not None:
62
+ raise ValueError("target_upper is only valid for between goals")
63
+ return self
64
+
65
+
66
+ class UserGoalUpdate(BaseModel):
67
+ comparison: GoalComparison | None = None
68
+ target_value: float | None = Field(default=None, ge=0)
69
+ target_upper: float | None = Field(default=None, ge=0)
70
+ is_active: bool | None = None
71
+
72
+
73
+ class UserGoalOut(BaseModel):
74
+ id: int
75
+ metric_name: GoalMetric
76
+ comparison: GoalComparison
77
+ target_value: float
78
+ target_upper: float | None
79
+ is_active: bool
80
+ current_value: float | None
81
+ achieved: bool
82
+ created_at: datetime
83
+ updated_at: datetime
84
+
85
+
86
+ class PresenterProfileUpdate(BaseModel):
87
+ display_name: str | None = Field(default=None, max_length=120)
88
+ primary_role: str | None = Field(default=None, max_length=120)
89
+ focus_track: Literal[
90
+ "tech_storytelling",
91
+ "keynote_clarity",
92
+ "camera_confidence",
93
+ "executive_pitch",
94
+ "tech_presenter",
95
+ "tech_news_creator",
96
+ "engineering_leader",
97
+ ] | None = None
98
+ experience_level: Literal["beginner", "intermediate", "advanced"] | None = None
99
+ target_wpm_min: int | None = Field(default=None, ge=80, le=200)
100
+ target_wpm_max: int | None = Field(default=None, ge=90, le=220)
101
+ max_filler_rate: float | None = Field(default=None, ge=0, le=20)
102
+ min_eye_contact: float | None = Field(default=None, ge=0, le=1)
103
+ daily_minutes_goal: int | None = Field(default=None, ge=5, le=120)
104
+ weaknesses: list[str] | None = None
105
+ speaking_goal_statement: str | None = Field(default=None, max_length=500)
106
+ reset_start_date: bool = False
107
+
108
+
109
+ class PresenterProfileOut(BaseModel):
110
+ model_config = ConfigDict(from_attributes=True)
111
+
112
+ id: int
113
+ display_name: str
114
+ primary_role: str
115
+ focus_track: str
116
+ experience_level: str
117
+ target_wpm_min: int
118
+ target_wpm_max: int
119
+ max_filler_rate: float
120
+ min_eye_contact: float
121
+ daily_minutes_goal: int
122
+ weaknesses: list[str] = Field(default_factory=list)
123
+ speaking_goal_statement: str | None = None
124
+ curriculum_start_date: date
125
+
126
+
127
+ class CurriculumCompleteRequest(BaseModel):
128
+ session_id: int | None = None
129
+ score: float | None = None
130
+ reflection_notes: str | None = Field(default=None, max_length=2000)
131
+
132
+
133
+ class StoryboardRequest(BaseModel):
134
+ headline: str = Field(min_length=3, max_length=300)
135
+ summary: str = Field(default="", max_length=4000)
136
+ source: str | None = Field(default=None, max_length=120)
137
+ url: str | None = Field(default=None, max_length=1000)
138
+ category: str | None = Field(default="AI & Systems", max_length=80)
139
+ framework: Literal["briefing_60s", "explainer_2m", "contrarian_take", "war_story"] = "briefing_60s"
140
+ personal_angle: str | None = Field(default=None, max_length=1000)
141
+
142
+
143
+ class WeeklyNoteUpdate(BaseModel):
144
+ week_start_date: date
145
+ wins_note: str | None = Field(default=None, max_length=2000)
146
+ focus_next_week: str | None = Field(default=None, max_length=2000)
147
+
148
+
149
+ class TranscriptUpdateRequest(BaseModel):
150
+ transcript_segments: list["TranscriptSegmentCreate"] = Field(default_factory=list)
151
+
152
+
153
+ class TranscriptionRequest(BaseModel):
154
+ backend: str | None = None
155
+ model: str | None = None
156
+
157
+
158
+ class TranscriptionModelDownloadRequest(BaseModel):
159
+ backend: str
160
+ model: str
161
+
162
+
163
+ class InferenceEngineCheckRequest(BaseModel):
164
+ engine_id: str
165
+ base_url: str | None = None
166
+
167
+
168
+ class InferenceEngineStatusOut(BaseModel):
169
+ id: str
170
+ label: str
171
+ base_url: str
172
+ live: bool
173
+ detail: str
174
+ available_models: list[str] = Field(default_factory=list)
175
+
176
+
177
+ class TranscriptionModelOut(BaseModel):
178
+ id: str
179
+ name: str
180
+ repo_id: str
181
+ description: str
182
+ languages: str
183
+ available: bool = False
184
+ local_path: str | None = None
185
+
186
+
187
+ class TranscriptionBackendOut(BaseModel):
188
+ id: str
189
+ label: str
190
+ supported: bool
191
+ live: bool
192
+ detail: str
193
+ models: list[TranscriptionModelOut] = Field(default_factory=list)
194
+ default_model: str | None = None
195
+
196
+
197
+ class TranscriptionSettingsOut(BaseModel):
198
+ default_backend: str
199
+ backends: list[TranscriptionBackendOut] = Field(default_factory=list)
200
+ text_inference_engines: list[InferenceEngineStatusOut] = Field(default_factory=list)
201
+
202
+
203
+ class TranscriptSegmentCreate(BaseModel):
204
+ start_ms: int
205
+ end_ms: int
206
+ text: str
207
+
208
+
209
+ class TranscriptSegmentOut(TranscriptSegmentCreate):
210
+ model_config = ConfigDict(from_attributes=True)
211
+
212
+ id: int
213
+
214
+
215
+ class DocumentBlockOut(BaseModel):
216
+ index: int
217
+ text: str
218
+
219
+
220
+ class SessionDocumentOut(BaseModel):
221
+ name: str
222
+ mime_type: str
223
+ blocks: list[DocumentBlockOut] = Field(default_factory=list)
224
+
225
+
226
+ class MetricsPointCreate(BaseModel):
227
+ t: int
228
+ wpm: float = 0
229
+ rms: float = 0
230
+ eye_contact: float = 0
231
+ smile: float = 0
232
+ yaw: float = 0
233
+ pitch: float = 0
234
+ roll: float = 0
235
+ filler_count: int = 0
236
+ pause_ms: int = 0
237
+
238
+
239
+ class ScoreCreate(BaseModel):
240
+ data: dict
241
+
242
+
243
+ class ScoreOut(ScoreCreate):
244
+ model_config = ConfigDict(from_attributes=True)
245
+
246
+ id: int
247
+
248
+
249
+ class SessionCreate(BaseModel):
250
+ title: str | None = None
251
+ mode: str | None = None
252
+ prompt: str | None = None
253
+ goal: str | None = None
254
+ curriculum_day: int | None = Field(default=None, ge=1, le=30)
255
+ exercise_id: str | None = None
256
+ story_framework: str | None = None
257
+ target_keywords: list[str] = Field(default_factory=list)
258
+ teleprompter_script: str | None = None
259
+ started_at: datetime | None = None
260
+ ended_at: datetime | None = None
261
+ duration_ms: int = 0
262
+ wpm_avg: float = 0
263
+ filler_count: int = 0
264
+ eye_contact_pct: float = 0
265
+ tags: list[str] = Field(default_factory=list)
266
+ transcript_segments: list[TranscriptSegmentCreate] = Field(default_factory=list)
267
+ metrics: list[MetricsPointCreate] = Field(default_factory=list)
268
+ score: dict | None = None
269
+
270
+
271
+ class SessionOut(BaseModel):
272
+ model_config = ConfigDict(from_attributes=True)
273
+
274
+ id: int
275
+ title: str | None
276
+ mode: str | None
277
+ prompt: str | None
278
+ goal: str | None
279
+ curriculum_day: int | None = None
280
+ exercise_id: str | None = None
281
+ story_framework: str | None = None
282
+ target_keywords: list[str] = Field(default_factory=list)
283
+ teleprompter_script: str | None = None
284
+ started_at: datetime | None
285
+ ended_at: datetime | None
286
+ duration_ms: int
287
+ wpm_avg: float
288
+ filler_count: int
289
+ eye_contact_pct: float
290
+ video_path: str | None
291
+ transcription_status: str
292
+ created_at: datetime
293
+ tags: list[str] = Field(default_factory=list)
294
+ transcript_segments: list[TranscriptSegmentOut] = Field(default_factory=list)
295
+ metrics: list[MetricsPointCreate] = Field(default_factory=list)
296
+ score: dict | None = None
297
+ document: SessionDocumentOut | None = None
298
+
299
+
300
+ class SessionListItem(BaseModel):
301
+ model_config = ConfigDict(from_attributes=True)
302
+
303
+ id: int
304
+ title: str | None
305
+ mode: str | None
306
+ curriculum_day: int | None = None
307
+ duration_ms: int
308
+ wpm_avg: float
309
+ filler_count: int
310
+ eye_contact_pct: float
311
+ created_at: datetime
312
+ transcription_status: str
313
+ video_available: bool = False
314
+ score: dict | None = None
315
+
316
+
317
+ class UploadResponse(BaseModel):
318
+ session_id: int
319
+ status: str
320
+ video_path: str | None = None
321
+
322
+
323
+ class ExportResponse(BaseModel):
324
+ zip_path: str
apps/api/src/app/scoring.py ADDED
@@ -0,0 +1,216 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import re
2
+ from typing import List
3
+
4
+ FILLER_WORDS = ["um", "uh", "like", "you know", "actually", "basically", "literally"]
5
+ STRUCTURE_WORDS = ["first", "second", "third", "because", "therefore", "however", "so", "thus"]
6
+
7
+
8
+ def tokenize(text: str) -> List[str]:
9
+ return [w for w in re.sub(r"[^a-z0-9\s']", " ", text.lower()).split() if w]
10
+
11
+
12
+ def count_words(segments: List[dict]) -> int:
13
+ return sum(len(tokenize(seg["text"])) for seg in segments)
14
+
15
+
16
+ def count_fillers(segments: List[dict]) -> int:
17
+ text = " ".join(seg["text"].lower() for seg in segments)
18
+ total = 0
19
+ for filler in FILLER_WORDS:
20
+ escaped = re.escape(filler).replace(" ", r"\s+")
21
+ pattern = re.compile(rf"\b{escaped}\b")
22
+ total += len(pattern.findall(text))
23
+ return total
24
+
25
+
26
+ def compute_wpm(word_count: int, duration_ms: int) -> float:
27
+ if duration_ms <= 0:
28
+ return 0
29
+ return round((word_count / (duration_ms / 60000)) * 10) / 10
30
+
31
+
32
+ def score_wpm(wpm: float) -> int:
33
+ if 120 <= wpm <= 160:
34
+ return 18
35
+ if 100 <= wpm < 120 or 160 < wpm <= 180:
36
+ return 10
37
+ return 4
38
+
39
+
40
+ def score_fillers(fillers_per_minute: float) -> int:
41
+ if fillers_per_minute <= 2:
42
+ return 12
43
+ if fillers_per_minute <= 6:
44
+ return 8
45
+ return 3
46
+
47
+
48
+ def score_pauses(pause_events: List[int], duration_ms: int) -> int:
49
+ if not pause_events:
50
+ return 4
51
+ natural = len([p for p in pause_events if 600 <= p <= 1500])
52
+ long = len([p for p in pause_events if p > 1500])
53
+ minutes = max(duration_ms / 60000, 0.1)
54
+ rate = natural / minutes
55
+ if long > max(2, minutes):
56
+ return 4
57
+ if 2 <= rate <= 6:
58
+ return 10
59
+ if rate >= 1:
60
+ return 7
61
+ return 5
62
+
63
+
64
+ def score_eye_contact(eye_contact_pct: float) -> int:
65
+ if eye_contact_pct > 0.7:
66
+ return 15
67
+ if eye_contact_pct >= 0.4:
68
+ return 10
69
+ return 5
70
+
71
+
72
+ def score_smile(smile_avg: float) -> int:
73
+ return round(7 * max(0, min(1, smile_avg)))
74
+
75
+
76
+ def score_head_stability(yaw_var: float, pitch_var: float, roll_var: float) -> int:
77
+ total_var = yaw_var + pitch_var + roll_var
78
+ if total_var < 5:
79
+ return 8
80
+ if total_var < 12:
81
+ return 6
82
+ return 4
83
+
84
+
85
+ def score_structure(segments: List[dict]) -> int:
86
+ words = tokenize(" ".join(seg["text"] for seg in segments))
87
+ count = sum(1 for w in words if w in STRUCTURE_WORDS)
88
+ if count >= 4:
89
+ return 10
90
+ if count >= 2:
91
+ return 7
92
+ return 4
93
+
94
+
95
+ def score_repetition(segments: List[dict]) -> int:
96
+ words = tokenize(" ".join(seg["text"] for seg in segments))
97
+ freq = {}
98
+ for word in words:
99
+ freq[word] = freq.get(word, 0) + 1
100
+ repeated = len([c for c in freq.values() if c >= 6])
101
+ if repeated >= 3:
102
+ return -8
103
+ if repeated >= 1:
104
+ return -4
105
+ return 0
106
+
107
+
108
+ def score_clarity(segments: List[dict]) -> int:
109
+ sentences = [s.strip() for s in re.split(r"[.!?]+", " ".join(seg["text"] for seg in segments)) if s.strip()]
110
+ if not sentences:
111
+ return 0
112
+ lengths = [len(tokenize(s)) for s in sentences]
113
+ avg = sum(lengths) / len(lengths)
114
+ if 8 <= avg <= 20:
115
+ return 12
116
+ if 5 <= avg <= 25:
117
+ return 8
118
+ return 4
119
+
120
+
121
+ def score_keyword_coverage(segments: List[dict], keywords: List[str]) -> int:
122
+ if not keywords:
123
+ return 0
124
+ text = " ".join(seg["text"].lower() for seg in segments)
125
+ covered = sum(1 for k in keywords if k.lower() in text)
126
+ ratio = covered / len(keywords)
127
+ if ratio >= 0.8:
128
+ return 8
129
+ if ratio >= 0.5:
130
+ return 5
131
+ return 2
132
+
133
+
134
+ def avg(values: List[float]) -> float:
135
+ return sum(values) / len(values) if values else 0
136
+
137
+
138
+ def variance(values: List[float]) -> float:
139
+ if not values:
140
+ return 0
141
+ mean = avg(values)
142
+ return avg([(v - mean) ** 2 for v in values])
143
+
144
+
145
+ def compute_score(segments: List[dict], metrics: List[dict], duration_ms: int, keywords: List[str]):
146
+ word_count = count_words(segments)
147
+ wpm = compute_wpm(word_count, duration_ms)
148
+ filler_count = count_fillers(segments)
149
+ minutes = max(duration_ms / 60000, 0.1)
150
+ fillers_per_minute = filler_count / minutes
151
+ pause_events = [m.get("pause_ms", 0) for m in metrics if m.get("pause_ms", 0) > 0]
152
+
153
+ wpm_score = score_wpm(wpm)
154
+ filler_score = score_fillers(fillers_per_minute)
155
+ pause_score = score_pauses(pause_events, duration_ms)
156
+ speech = wpm_score + filler_score + pause_score
157
+
158
+ eye_contact_pct = avg([m.get("eye_contact", 0) for m in metrics])
159
+ smile_avg = avg([m.get("smile", 0) for m in metrics])
160
+ yaw_var = variance([m.get("yaw", 0) for m in metrics])
161
+ pitch_var = variance([m.get("pitch", 0) for m in metrics])
162
+ roll_var = variance([m.get("roll", 0) for m in metrics])
163
+
164
+ eye_score = score_eye_contact(eye_contact_pct)
165
+ smile_score = score_smile(smile_avg)
166
+ head_score = score_head_stability(yaw_var, pitch_var, roll_var)
167
+ delivery = eye_score + smile_score + head_score
168
+
169
+ structure_score = score_structure(segments)
170
+ repetition_score = score_repetition(segments)
171
+ clarity_score = score_clarity(segments)
172
+ keyword_score = score_keyword_coverage(segments, keywords)
173
+ content = max(0, structure_score + repetition_score + clarity_score + keyword_score)
174
+
175
+ total = max(0, min(100, speech + delivery + content))
176
+
177
+ fixes = []
178
+ if wpm_score <= 10:
179
+ fixes.append("Adjust pace to 120-160 WPM.")
180
+ if filler_score <= 8:
181
+ fixes.append("Reduce filler words with pauses.")
182
+ if eye_score <= 10:
183
+ fixes.append("Increase eye contact with the lens.")
184
+ if clarity_score <= 8:
185
+ fixes.append("Shorten sentences for clarity.")
186
+ if keyword_score <= 5 and keywords:
187
+ fixes.append("Cover target keywords.")
188
+
189
+ drill = (
190
+ "60-second pace drill"
191
+ if speech < delivery and speech < content
192
+ else "Eye contact mirror drill"
193
+ if delivery < content
194
+ else "Story arc outline drill"
195
+ )
196
+
197
+ return {
198
+ "total": total,
199
+ "speech": speech,
200
+ "delivery": delivery,
201
+ "content": content,
202
+ "details": {
203
+ "wpm": wpm_score,
204
+ "fillers": filler_score,
205
+ "pauses": pause_score,
206
+ "eyeContact": eye_score,
207
+ "smile": smile_score,
208
+ "headStability": head_score,
209
+ "structure": structure_score,
210
+ "repetition": repetition_score,
211
+ "clarity": clarity_score,
212
+ "keywordCoverage": keyword_score,
213
+ },
214
+ "topFixes": fixes[:3],
215
+ "recommendedDrill": drill,
216
+ }