Spaces:
Paused
Paused
Commit ·
ec17af3
0
Parent(s):
Deploy Tech Presenter Coach
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .dockerignore +12 -0
- .env.example +38 -0
- .github/ISSUE_TEMPLATE/bug_report.md +28 -0
- .github/ISSUE_TEMPLATE/feature_request.md +11 -0
- .github/workflows/ci.yml +102 -0
- .gitignore +42 -0
- CONTRIBUTING.md +26 -0
- Dockerfile +40 -0
- LICENSE +201 -0
- Makefile +85 -0
- NOTICE +29 -0
- README.md +385 -0
- apps/api/Dockerfile +21 -0
- apps/api/alembic.ini +38 -0
- apps/api/docker-entrypoint.sh +8 -0
- apps/api/migrations/env.py +45 -0
- apps/api/migrations/script.py.mako +22 -0
- apps/api/migrations/versions/20260607_0001_initial_foundation.py +141 -0
- apps/api/migrations/versions/20260608_0002_analytics_coaching.py +117 -0
- apps/api/migrations/versions/20260904_0003_user_goals.py +41 -0
- apps/api/mypy.ini +4 -0
- apps/api/requirements-desktop.txt +18 -0
- apps/api/requirements-dev.txt +4 -0
- apps/api/requirements.txt +21 -0
- apps/api/ruff.toml +9 -0
- apps/api/src/app/__init__.py +0 -0
- apps/api/src/app/auth.py +277 -0
- apps/api/src/app/config.py +68 -0
- apps/api/src/app/db.py +26 -0
- apps/api/src/app/hf_models.py +318 -0
- apps/api/src/app/local_inference.py +147 -0
- apps/api/src/app/main.py +62 -0
- apps/api/src/app/middleware/logging.py +31 -0
- apps/api/src/app/middleware/security_headers.py +21 -0
- apps/api/src/app/models.py +312 -0
- apps/api/src/app/rate_limit.py +27 -0
- apps/api/src/app/routes/__init__.py +3 -0
- apps/api/src/app/routes/auth.py +63 -0
- apps/api/src/app/routes/coaching.py +188 -0
- apps/api/src/app/routes/curriculum.py +112 -0
- apps/api/src/app/routes/dashboard.py +203 -0
- apps/api/src/app/routes/health.py +37 -0
- apps/api/src/app/routes/jobs.py +14 -0
- apps/api/src/app/routes/news.py +32 -0
- apps/api/src/app/routes/sessions.py +987 -0
- apps/api/src/app/routes/settings.py +41 -0
- apps/api/src/app/routes/setup.py +37 -0
- apps/api/src/app/schema_upgrades.py +31 -0
- apps/api/src/app/schemas.py +324 -0
- 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 |
+

|
| 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 |
+

|
| 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 |
+
}
|