diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..82c9cb5fe3811d4240e8965fcbd92fe9d2f24a1a --- /dev/null +++ b/.dockerignore @@ -0,0 +1,12 @@ +.git +.github +node_modules +apps/web/.next +.venv +data/* +!data/.gitkeep +*.db +__pycache__ +.pytest_cache +Speech_Machine_Banner.png +Screenshot_Speech_Machine.png diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..4514b86c861b13bb3b3225a1004f5f9e092d2879 --- /dev/null +++ b/.env.example @@ -0,0 +1,38 @@ +# Web +NEXT_PUBLIC_API_URL=http://localhost:8000 +NEXT_PUBLIC_LOCAL_ONLY_MODE=true + +# API +DATABASE_URL=postgresql+psycopg2://video:video@db:5432/video +JWT_SECRET=change-me +JWT_ALGORITHM=HS256 +ACCESS_TOKEN_EXPIRE_MINUTES=15 +REFRESH_TOKEN_EXPIRE_DAYS=7 +ENVIRONMENT=development +CORS_ORIGINS=http://localhost:3000 +LOCAL_ONLY_MODE=true +MAX_UPLOAD_MB=500 +REDIS_HOST=redis +REDIS_PORT=6379 +STORAGE_BACKEND=local +STORAGE_DIR=/data/storage +S3_ENDPOINT= +S3_ACCESS_KEY= +S3_SECRET_KEY= +S3_BUCKET= +S3_REGION= +TRANSCRIPTION_BACKEND=server +# Server transcription model: faster-whisper models plus nvidia/parakeet-tdt-0.6b-v3 +WHISPER_MODEL=small +WHISPER_DEVICE=cpu +# Desktop-only MLX Audio default model +MLX_AUDIO_MODEL=mlx-community/parakeet-tdt-0.6b-v3 +OLLAMA_BASE_URL=http://127.0.0.1:11434 +LMSTUDIO_BASE_URL=http://127.0.0.1:1234/v1 +LLAMA_CPP_BASE_URL=http://127.0.0.1:8080 +LOCAL_ENGINE_TIMEOUT_SECONDS=0.75 +COACHING_ENABLED=true +COACHING_PROVIDER=ollama +COACHING_MODEL=llama3.2:3b +COACHING_TIMEOUT_SECONDS=90 +COACHING_MAX_TRANSCRIPT_CHARS=8000 diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000000000000000000000000000000000000..9da3d7bc2e0710559525309a9f6c22289b7fafbc --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,28 @@ +--- +name: Bug Report +about: Report something that is not working +labels: bug +--- + +## Description + +## Steps to reproduce +1. +2. +3. + +## Expected behavior + +## Actual behavior + +## Environment +- OS: +- Browser: +- Docker version: +- Commit: + +## Logs + +```text +Paste relevant logs here and redact secrets. +``` diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000000000000000000000000000000000000..b6e381a9d61db4accffcd31796ef317e31660991 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,11 @@ +--- +name: Feature Request +about: Suggest an improvement +labels: enhancement +--- + +## Problem + +## Proposed solution + +## Alternatives considered diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000000000000000000000000000000000000..33011dbca5506059775aebebe9555e48365b1c2e --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,102 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +env: + NODE_VERSION: "20" + PYTHON_VERSION: "3.11" + +jobs: + web: + name: Web + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: package-lock.json + - run: npm ci + - run: npm --workspace apps/web run lint + - run: npm --workspace apps/web exec tsc -- --noEmit + - run: npm --workspace apps/web test -- --run + - run: npm --workspace apps/web run build + env: + NEXT_TELEMETRY_DISABLED: 1 + + shared: + name: Shared + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + cache: npm + cache-dependency-path: package-lock.json + - run: npm ci + - run: npm --workspace packages/shared exec tsc -- --noEmit + + api: + name: API + runs-on: ubuntu-latest + services: + postgres: + image: postgres:16 + env: + POSTGRES_USER: video + POSTGRES_PASSWORD: video + POSTGRES_DB: video + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U video -d video" + --health-interval 5s + --health-timeout 3s + --health-retries 5 + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 5s + --health-timeout 3s + --health-retries 5 + defaults: + run: + working-directory: apps/api + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: ${{ env.PYTHON_VERSION }} + cache: pip + cache-dependency-path: apps/api/requirements-dev.txt + - run: python -m pip install --upgrade pip + - run: pip install -r requirements-dev.txt + - run: ruff check . + - run: mypy src/app --ignore-missing-imports --no-error-summary + - run: PYTHONPATH=src pytest -q --cov=app --cov-report=term-missing + env: + DATABASE_URL: sqlite:///./ci_test.db + JWT_SECRET: ci-test-secret + ENVIRONMENT: test + CORS_ORIGINS: http://localhost:3000 + + docker: + name: Docker Build + runs-on: ubuntu-latest + needs: [web, shared, api] + steps: + - uses: actions/checkout@v4 + - run: docker compose build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..1376c2397897856722e779b863545a6063b1143f --- /dev/null +++ b/.gitignore @@ -0,0 +1,42 @@ +# Environment +.env +.env.* +!.env.example + +# Dependencies +node_modules/ +apps/api/.venv/ +.venv/ + +# Python +__pycache__/ +*.py[cod] +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ +.coverage +coverage.xml +htmlcov/ +test_*.db +local.db + +# Frontend +.next/ +apps/web/public/pdf.worker.min.mjs +out/ +dist/ +*.tsbuildinfo +coverage/ +playwright-report/ +test-results/ + +# Runtime data +uploads/ +storage/ +models/ +*.zip + +# OS / IDE +.DS_Store +.idea/ +.vscode/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000000000000000000000000000000000000..a74aa9e836ebbee893d936679ef38eb192f82b2e --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,26 @@ +# Contributing to Speech-Machine + +## Development setup + +1. Copy `.env.example` to `.env`. +2. Set `JWT_SECRET` to a generated secret. +3. Run `docker compose up --build`. +4. Open `http://localhost:3000` and complete first-run setup. + +## Tests + +```bash +npm --workspace apps/web test -- --run +npm --workspace packages/shared exec tsc -- --noEmit +cd apps/api && PYTHONPATH=src pytest -q +``` + +## Code style + +- Frontend: `npm --workspace apps/web run lint`. +- Backend: `cd apps/api && ruff check .`. +- Keep API route prefixes stable unless a migration plan is included. + +## Pull requests + +Use a branch from `main`, include tests for behavior changes, and verify Docker still builds. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..61487c7e9fb2cfc6dfb10570aa2e242a574aa8c1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,40 @@ +FROM node:20-bookworm-slim + +ENV DEBIAN_FRONTEND=noninteractive \ + PYTHONUNBUFFERED=1 \ + HOME=/app/data/home \ + INTERNAL_API_URL="http://127.0.0.1:8000" \ + ALLOW_FRAME_EMBEDDING="true" \ + PORT=7860 + +RUN apt-get update && apt-get install -y --no-install-recommends \ + python3 \ + python3-pip \ + ffmpeg \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +# Install Python backend + cloud DB/S3 dependencies +RUN pip3 install --no-cache-dir --break-system-packages \ + fastapi "uvicorn[standard]" sqlalchemy psycopg2-binary pydantic pydantic-settings \ + python-multipart "python-jose[cryptography]" "passlib[bcrypt]" "bcrypt<4.1" \ + httpx email-validator faster-whisper arq reportlab boto3 + +# Install Node.js workspace dependencies +COPY package.json package-lock.json tsconfig.base.json ./ +COPY packages/shared/package.json ./packages/shared/package.json +COPY apps/web/package.json ./apps/web/package.json +RUN npm ci --no-audit --no-fund + +# Copy application source and build Next.js production bundle +COPY . . +RUN chmod +x /app/infra/scripts/start_cloud.sh /app/infra/scripts/run_presenter_coach.sh \ + && mkdir -p /app/data/storage /app/data/home \ + && npm --workspace apps/web run build \ + && chmod -R 777 /app/data /app/apps/web/.next + +EXPOSE 7860 + +CMD ["/app/infra/scripts/start_cloud.sh"] diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..261eeb9e9f8b2b4b0d119366dda99c6fd7d35c64 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..98e3bd42df71b6df46d8a0e486c1c88b1db3bca4 --- /dev/null +++ b/Makefile @@ -0,0 +1,85 @@ +SHELL := /bin/bash + +COMPOSE ?= docker compose +API_VENV ?= $(HOME)/venvs/mlx +API_PYTHON ?= $(API_VENV)/bin/python +API_PYTEST ?= PYTHONPATH=src $(API_PYTHON) -m pytest -q +API_RUFF ?= $(API_VENV)/bin/ruff +API_MYPY ?= $(API_VENV)/bin/mypy +ADMIN_EMAIL ?= admin@example.com +ADMIN_PASSWORD ?= change-me-strong + +.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 + +help: + @awk 'BEGIN {FS = ":.*##"; printf "Speech-Machine local targets:\n"} /^[a-zA-Z0-9_-]+:.*##/ {printf " %-18s %s\n", $$1, $$2}' $(MAKEFILE_LIST) + +install: install-web install-api ## Install local web and API dependencies. + +install-web: ## Install root npm workspaces. + npm ci + +install-api: ## Install API dependencies into API_VENV, defaulting to ~/venvs/mlx. + cd apps/api && $(API_PYTHON) -m pip install -r requirements-dev.txt + +dev: local-up ## Start the local Docker stack. + +up: local-up ## Start the local Docker stack. + +local-up: .env ## Start the local Docker stack with rebuilds when needed. + $(COMPOSE) up --build + +local-build: ## Build local Docker images. + $(COMPOSE) build api worker web + +down: ## Stop local Docker services. + $(COMPOSE) down + +logs: ## Follow local Docker logs. + $(COMPOSE) logs -f api worker web + +reset-local: ## Stop services and delete local Docker volumes. + $(COMPOSE) down -v + +smoke: local-smoke ## Run local endpoint smoke checks. + +local-smoke: ## Check local API, setup, login, and web endpoints. + ADMIN_EMAIL="$(ADMIN_EMAIL)" ADMIN_PASSWORD="$(ADMIN_PASSWORD)" ./scripts/local-smoke.sh + +test: test-web test-api ## Run frontend and backend tests. + +test-web: ## Run frontend unit tests once. + npm --workspace apps/web test -- --run + +test-api: ## Run API tests through API_VENV. + cd apps/api && $(API_PYTEST) + +lint: lint-web lint-api ## Run web and API lint checks. + +lint-web: ## Run Next.js lint. + npm --workspace apps/web run lint + +lint-api: ## Run Ruff against the API. + cd apps/api && $(API_RUFF) check . + +typecheck: typecheck-shared typecheck-web typecheck-api ## Run TypeScript and Python type checks. + +typecheck-web: ## Run web TypeScript check. + npm --workspace apps/web exec tsc -- --noEmit + +typecheck-shared: ## Run shared package TypeScript check. + npm --workspace packages/shared exec tsc -- --noEmit + +typecheck-api: ## Run API mypy check. + cd apps/api && $(API_MYPY) src/app --ignore-missing-imports --no-error-summary + +check: test-web typecheck-shared lint-web typecheck-web test-api lint-api typecheck-api compose-config ## Run the local pre-commit check suite. + +compose-config: ## Validate Docker Compose config. + $(COMPOSE) config >/dev/null + +format: ## Format frontend files. + npm --workspace apps/web run format + +.env: + cp .env.example .env diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000000000000000000000000000000000000..880da9dd66d056091310c232e41d7750213275ed --- /dev/null +++ b/NOTICE @@ -0,0 +1,29 @@ +Speech-Machine β€” Personalized Tech Camera-Presenting Coach Edition +Copyright 2026 Susant Achary (Original Speech-Machine Author) + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at: + + http://www.apache.org/licenses/LICENSE-2.0 + +Summary of Modifications (Apache License 2.0, Section 4(b)): +- Preserved Speech-Machine's core monorepo architecture (Next.js 14 frontend, + FastAPI + SQLAlchemy backend, @video/shared scoring package) and existing + capabilities (MediaRecorder video capture, MediaPipe eye-contact/smile/head + tracking, Web Audio loudness/pitch analysis, Web Speech & faster-whisper + transcription, IndexedDB draft recovery, PDF/TXT document reading, and exports). +- Added a personalized 30-day Tech Presenter Training Curriculum with adaptive + presenter profiles, progress persistence, and one-click studio launch. +- Added Daily Speaking Warm-Ups, Camera-Presenting Challenges, and dynamic + weakness-targeted improvement drills. +- Upgraded the Camera Recording Studio with an on-lens built-in teleprompter + supporting auto-scroll, voice-synced highlighting, presenter bullet cues, + mirror mode, countdown timer, audio-only fallback, and editable transcripts. +- Added a Technology News & Storytelling Practice desk with live RSS/HN feeds, + curated tech briefings, custom topic/URL parsing, and 4 presenter storyboards. +- Added a built-in analytical presenter coaching engine fallback alongside + existing local LLM providers (Ollama, LM Studio, llama.cpp), plus local + background worker fallback when Redis/arq is not running. +- Added Weekly Review analytics, reflection notes, enhanced Dual-Video + Side-by-Side Recording Comparison, and a responsive Mac + mobile UI shell. diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..84775082232dd346089284d9ad9b1dd5c0e8299c --- /dev/null +++ b/README.md @@ -0,0 +1,385 @@ +--- +title: Tech Presenter Coach +emoji: πŸŽ™οΈ +colorFrom: red +colorTo: yellow +sdk: docker +app_port: 7860 +--- + +![Speech-Machine Banner](./Speech_Machine_Banner.png) + +# Speech-Machine Β· Personal Tech Presenter Coach Edition + +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. + +## Personal Tech Presenter Coach Upgrades + +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: + +- **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). +- **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. +- **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*). +- **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. +- **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. +- **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. +- **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. + +### Quick Start (Mac + Phone Browser) + +```bash +./infra/scripts/run_presenter_coach.sh +``` + +- **On your Mac**: Open `http://localhost:3000` +- **On your Phone (same Wi-Fi / Tailscale)**: Open `https://: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). + +## Sample UI + +Below is a sample Speech-Machine screen showing the product layout and session statistics view. + +![Speech-Machine Sample UI](./Screenshot_Speech_Machine.png) + +## What It Does + +- Records practice videos in the browser with camera and microphone selection. +- Uploads existing videos for asynchronous transcription and review. +- Downloads transcript output as `.txt` and `.pdf`. +- Saves full practice sessions with score, transcript, metrics, and review data. +- Restores unsaved recording drafts from browser storage after refresh. +- Tracks realtime speaking cues such as words per minute, filler words, pauses, and rolling captions. +- Tracks delivery signals such as eye contact, smile, loudness, and head movement. +- Supports a scrollable reading document panel during recording for `.pdf` and `.txt` files. +- Highlights the current reading block during recording using live browser speech cues when supported. +- Exports complete session bundles and supports video-only delete or full session delete. +- Supports first-run local admin setup and email/password authentication. + +## Core Capabilities + +### Recording and review + +- Browser-based recording with MediaRecorder +- Realtime preview and review playback +- Draft recovery for unsaved recordings +- Session comparison and score breakdown +- Timeline markers for speaking issues and improvement points + +### Speech analysis + +- Final server-side transcription +- Realtime browser speech cues where the Web Speech API is available +- Average and realtime WPM +- Filler-word detection +- Long-pause and silence detection +- Transcript search and filler highlighting + +### Delivery analysis + +- Eye-contact estimation using MediaPipe face landmarks +- Smile and head stability metrics +- Loudness tracking from the audio stream +- Composite scoring across delivery, speech, and content + +### Guided reading + +- Upload a reading document during recording +- Supported input: `.pdf`, `.txt` +- Vertical teleprompter-style reading panel below the recorder +- Live paragraph highlighting based on current spoken content when browser speech recognition is available + +## Platform Compatibility + +### Primary supported runtime + +The primary supported runtime is the Docker stack. + +| Platform | Status | Notes | +| --- | --- | --- | +| macOS | Supported | Best overall local development path with Docker Desktop | +| Linux | Supported | Good fit for local and server deployment | +| Windows | Supported | Use Docker Desktop; WSL2-backed setups are recommended | + +### Browser compatibility + +| Browser | Recording | Realtime speech cues | Notes | +| --- | --- | --- | --- | +| Chrome | Supported | Best support | Recommended browser | +| Edge | Supported | Good support | Recommended browser | +| Safari | Supported | Limited / browser-dependent | Recording path includes Safari-specific handling; Web Speech API support may be unavailable | +| Firefox | Partial | Limited | Core UI works, but speech-recognition behavior depends on browser capabilities | + +### Desktop runtime note + +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. + +## Minimum System Requirements + +These are practical minimums for local Docker usage, not hard-enforced limits. + +| Requirement | Minimum | Recommended | +| --- | --- | --- | +| CPU | 4 logical cores | 8 logical cores | +| Memory | 8 GB RAM | 16 GB RAM | +| Free disk | 10 GB | 20+ GB, especially when downloading transcription models | +| Browser | Modern Chromium / Safari | Latest Chrome or Edge | +| Camera / Mic | Required for recording mode | External mic recommended for better transcription quality | +| Docker | Docker Desktop or Docker Engine + Compose | Latest stable | + +Notes: +- Larger Whisper models need more RAM, disk, and transcription time. +- GPU is optional. CPU-only operation is supported. +- If you plan to keep multiple downloaded models locally, reserve additional disk space. + +## Supported Transcription Models + +### Docker / server backend + +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. + +- `openai/whisper-tiny` +- `openai/whisper-base` +- `openai/whisper-small` +- `openai/whisper-medium` +- `openai/whisper-large-v3` +- `openai/whisper-large-v3-turbo` + +### Optional MLX Audio backend + +Available only in the optional desktop-oriented path when `mlx-audio` is installed and the runtime is compatible. + +- `mlx-community/whisper-large-v3-turbo-asr-fp16` +- `distil-whisper/distil-large-v3` +- `mlx-community/Qwen3-ASR-1.7B-8bit` +- `mlx-community/Qwen3-ForcedAligner-0.6B-8bit` +- `mlx-community/parakeet-tdt-0.6b-v3` +- `mlx-community/Voxtral-Mini-3B-2507-bf16` +- `mlx-community/Voxtral-Mini-4B-Realtime-2602-4bit` +- `mlx-community/Voxtral-Mini-4B-Realtime-2602-fp16` +- `mlx-community/VibeVoice-ASR-bf16` + +## Latency Characteristics + +Speech-Machine does not ship a formal benchmark suite yet, so latency depends on browser, host CPU/GPU, selected model, and recording length. + +| Workflow | Expected behavior | +| --- | --- | +| Live recorder metrics | Near realtime in the browser | +| Live face / delivery metrics | Near realtime on modern hardware | +| Save session | Usually seconds, depending on file size and storage | +| Final transcription | Seconds to minutes depending on video duration and model size | +| Model download | One-time per model; depends on model size and network speed | + +Practical guidance: +- `Whisper Tiny` and `Whisper Base` are the fastest options. +- `Whisper Small` is a good default balance for local CPU usage. +- `Whisper Medium`, `Large v3`, and `Large v3 Turbo` need more memory and longer completion time. + +## Tech Stack + +### Frontend + +- Next.js 14 App Router +- React 18 +- TypeScript +- Tailwind CSS +- Zustand +- Recharts +- `idb` for IndexedDB draft persistence +- `pdfjs-dist` for PDF text extraction +- MediaPipe Tasks Vision +- Web Audio API +- Web Speech API + +### Backend + +- FastAPI +- SQLAlchemy +- PostgreSQL in Docker +- SQLite in desktop-oriented local runtime mode +- JWT authentication +- Local file storage with optional S3-compatible storage +- `ffmpeg` for audio extraction +- `faster-whisper==1.1.0` +- Optional `mlx-audio` +- `huggingface_hub` for on-demand local model downloads + +### Tooling + +- Docker Compose +- Vitest +- Playwright +- Pytest +- Make + +## Architecture + +```mermaid +flowchart LR + User([Speaker]) --> Browser + + subgraph Client[Browser / Next.js] + Browser[Next.js web app] + Capture[MediaRecorder
camera and microphone] + Live[MediaPipe + Web Audio
live delivery metrics] + Drafts[(IndexedDB
draft recovery)] + Browser --> Capture + Browser --> Live + Browser <--> Drafts + end + + Browser <-->|HTTPS / JSON
job polling| API + + subgraph Services[Docker application services] + API[FastAPI API
authentication and sessions] + Queue[(Redis
arq job queue)] + Worker[Background worker] + Database[(PostgreSQL
sessions and analytics)] + Storage[(Local or S3 storage
video and documents)] + + API <--> Database + API <--> Storage + API -->|enqueue transcription| Queue + Queue --> Worker + Worker <--> Storage + Worker --> Database + end + + subgraph Intelligence[Local intelligence] + Media[ffmpeg] + ASR[faster-whisper
or optional MLX Audio] + Coach[Ollama, LM Studio,
or llama.cpp] + Media --> ASR + end + + Worker --> Media + ASR -->|transcript and metrics| Worker + Worker -->|session context| Coach + Coach -->|structured coaching| Worker +``` + +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. + +See [the architecture guide](./docs/ARCHITECTURE.md) for the request lifecycle and security boundaries. + +## Repository Layout + +- `apps/web`: Next.js frontend +- `apps/api`: FastAPI backend +- `packages/shared`: shared types and scoring logic +- `infra`: desktop runtime helpers +- `docs`: packaging and supporting documentation + +## Quick Start + +### Recommended: Docker + +```bash +cp .env.example .env +make local-up +``` + +Open: + +- App: `http://localhost:3000` +- API docs: `http://localhost:8000/docs` + +On first launch, open the app and create the local admin account in the setup screen. + +You can also create the first local admin from the terminal: + +```bash +curl -X POST http://localhost:8000/setup \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@example.com","password":"change-me-strong"}' +``` + +Then run a local smoke check: + +```bash +make local-smoke +``` + +See [docs/local-machine.md](./docs/local-machine.md) for the full local workflow. + +### Local development without Docker + +#### API + +```bash +cd apps/api +python3 -m venv .venv +source .venv/bin/activate +pip install -r requirements.txt +uvicorn app.main:app --reload --host 0.0.0.0 --port 8000 +``` + +#### Web + +```bash +cd apps/web +npm install +npm run dev +``` + +## Build and Test + +```bash +make local-build +``` + +```bash +make check +``` + +## Lines of Code + +Source snapshot as of March 22, 2026, excluding `node_modules`, `.next`, caches, and lockfiles: + +| Area | Files | Approx. LOC | +| --- | --- | --- | +| API | 17 | 2,370 | +| Web | 36 | 4,444 | +| Shared | 4 | 290 | +| Infra | 4 | 263 | +| Total app source | 61 | 7,367 | + + + +## Current Feature Set + +- Browser recording and uploaded-video transcription +- Transcript download in `.txt` and `.pdf` +- Session export bundle +- Session comparison +- Full session delete and video-only delete +- Reading-document upload and guided reading panel +- Draft recovery +- Model download-on-demand from the UI +- User-defined practice goals with live progress and goal-aware AI coaching +- Docker-first local deployment + +## Planned / Upcoming Upgrades + +- Better progress reporting for long model downloads +- Broader browser support around live speech cues +- Stronger progress reporting for queued transcription workloads +- More production deployment presets +- Native desktop shell packaging +- Stronger benchmarking and latency reporting +- Richer document guidance and reading analytics + +## License + +This project is licensed under the Apache License 2.0. + +High-level summary: +- You may use, modify, and distribute the software. +- You must preserve copyright and license notices. +- Changes should be documented when redistributing modified versions. +- The software is provided on an β€œAS IS” basis without warranties. + +See [LICENSE](./LICENSE) for the full license text. + +## Developer + +Developed by **Susant Achary**. diff --git a/apps/api/Dockerfile b/apps/api/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..d8dc9c6ff88d649ce84dc33b131b68bc15f95112 --- /dev/null +++ b/apps/api/Dockerfile @@ -0,0 +1,21 @@ +FROM python:3.11-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ffmpeg build-essential libsndfile1 \ + && rm -rf /var/lib/apt/lists/* + +WORKDIR /app + +COPY requirements.txt ./ +RUN pip install --no-cache-dir -r requirements.txt + +COPY src ./src +COPY alembic.ini ./alembic.ini +COPY migrations ./migrations +COPY docker-entrypoint.sh ./docker-entrypoint.sh +RUN chmod +x ./docker-entrypoint.sh + +ENV PYTHONPATH=/app/src + +ENTRYPOINT ["./docker-entrypoint.sh"] +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/apps/api/alembic.ini b/apps/api/alembic.ini new file mode 100644 index 0000000000000000000000000000000000000000..92734d3569681bc550ee733a2067f2ce4003846c --- /dev/null +++ b/apps/api/alembic.ini @@ -0,0 +1,38 @@ +[alembic] +script_location = migrations +prepend_sys_path = . +sqlalchemy.url = + +[loggers] +keys = root,sqlalchemy,alembic + +[handlers] +keys = console + +[formatters] +keys = generic + +[logger_root] +level = WARN +handlers = console +qualname = + +[logger_sqlalchemy] +level = WARN +handlers = +qualname = sqlalchemy.engine + +[logger_alembic] +level = INFO +handlers = +qualname = alembic + +[handler_console] +class = StreamHandler +args = (sys.stderr,) +level = NOTSET +formatter = generic + +[formatter_generic] +format = %(levelname)-5.5s [%(name)s] %(message)s +datefmt = %H:%M:%S diff --git a/apps/api/docker-entrypoint.sh b/apps/api/docker-entrypoint.sh new file mode 100644 index 0000000000000000000000000000000000000000..a2be4c407aacfb3874ad5e0b9b4e9c0b6d9cea81 --- /dev/null +++ b/apps/api/docker-entrypoint.sh @@ -0,0 +1,8 @@ +#!/bin/sh +set -e + +if [ "${RUN_MIGRATIONS:-true}" = "true" ]; then + alembic upgrade head +fi + +exec "$@" diff --git a/apps/api/migrations/env.py b/apps/api/migrations/env.py new file mode 100644 index 0000000000000000000000000000000000000000..09d5c8410962aedf732b1c29c2c48a2857445395 --- /dev/null +++ b/apps/api/migrations/env.py @@ -0,0 +1,45 @@ +from logging.config import fileConfig + +from alembic import context +from sqlalchemy import engine_from_config, pool + +from app.config import settings +from app.db import Base +from app import models # noqa: F401 + +config = context.config +if config.config_file_name is not None: + fileConfig(config.config_file_name) + +target_metadata = Base.metadata + + +def run_migrations_offline() -> None: + context.configure( + url=settings.database_url, + target_metadata=target_metadata, + literal_binds=True, + dialect_opts={"paramstyle": "named"}, + ) + with context.begin_transaction(): + context.run_migrations() + + +def run_migrations_online() -> None: + configuration = config.get_section(config.config_ini_section, {}) + configuration["sqlalchemy.url"] = settings.database_url + connectable = engine_from_config( + configuration, + prefix="sqlalchemy.", + poolclass=pool.NullPool, + ) + with connectable.connect() as connection: + context.configure(connection=connection, target_metadata=target_metadata) + with context.begin_transaction(): + context.run_migrations() + + +if context.is_offline_mode(): + run_migrations_offline() +else: + run_migrations_online() diff --git a/apps/api/migrations/script.py.mako b/apps/api/migrations/script.py.mako new file mode 100644 index 0000000000000000000000000000000000000000..36cd8efeda0d3f671a90fc6c7b4131af5758bbb4 --- /dev/null +++ b/apps/api/migrations/script.py.mako @@ -0,0 +1,22 @@ +"""${message} + +Revision ID: ${up_revision} +Revises: ${down_revision | comma,n} +Create Date: ${create_date} +""" +from alembic import op +import sqlalchemy as sa +${imports if imports else ""} + +revision = ${repr(up_revision)} +down_revision = ${repr(down_revision)} +branch_labels = ${repr(branch_labels)} +depends_on = ${repr(depends_on)} + + +def upgrade() -> None: + ${upgrades if upgrades else "pass"} + + +def downgrade() -> None: + ${downgrades if downgrades else "pass"} diff --git a/apps/api/migrations/versions/20260607_0001_initial_foundation.py b/apps/api/migrations/versions/20260607_0001_initial_foundation.py new file mode 100644 index 0000000000000000000000000000000000000000..2496dea13612360358fd258144203c20e0e34b95 --- /dev/null +++ b/apps/api/migrations/versions/20260607_0001_initial_foundation.py @@ -0,0 +1,141 @@ +"""initial foundation schema + +Revision ID: 20260607_0001 +Revises: +Create Date: 2026-06-07 +""" +from alembic import op +import sqlalchemy as sa + +revision = "20260607_0001" +down_revision = None +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "users", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("email", sa.String(length=255), nullable=False), + sa.Column("hashed_password", sa.String(length=255), nullable=False), + sa.Column("is_admin", sa.Boolean(), nullable=False, server_default=sa.false()), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_users_id"), "users", ["id"], unique=False) + op.create_index(op.f("ix_users_email"), "users", ["email"], unique=True) + + op.create_table( + "refresh_tokens", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("jti_hash", sa.String(length=64), nullable=False), + sa.Column("expires_at", sa.DateTime(), nullable=False), + sa.Column("revoked_at", sa.DateTime(), nullable=True), + sa.Column("replaced_by_jti_hash", sa.String(length=64), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_refresh_tokens_id"), "refresh_tokens", ["id"], unique=False) + op.create_index(op.f("ix_refresh_tokens_user_id"), "refresh_tokens", ["user_id"], unique=False) + op.create_index(op.f("ix_refresh_tokens_jti_hash"), "refresh_tokens", ["jti_hash"], unique=True) + + op.create_table( + "sessions", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("title", sa.String(length=255), nullable=True), + sa.Column("mode", sa.String(length=80), nullable=True), + sa.Column("prompt", sa.Text(), nullable=True), + sa.Column("goal", sa.String(length=255), nullable=True), + sa.Column("started_at", sa.DateTime(), nullable=True), + sa.Column("ended_at", sa.DateTime(), nullable=True), + sa.Column("duration_ms", sa.Integer(), nullable=True), + sa.Column("wpm_avg", sa.Float(), nullable=True), + sa.Column("filler_count", sa.Integer(), nullable=True), + sa.Column("eye_contact_pct", sa.Float(), nullable=True), + sa.Column("video_path", sa.String(length=1024), nullable=True), + sa.Column("document_path", sa.String(length=1024), nullable=True), + sa.Column("document_name", sa.String(length=255), nullable=True), + sa.Column("document_mime_type", sa.String(length=255), nullable=True), + sa.Column("document_blocks_json", sa.Text(), nullable=True), + sa.Column("transcription_status", sa.String(length=32), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["user_id"], ["users.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_sessions_id"), "sessions", ["id"], unique=False) + + op.create_table( + "metrics_timeseries", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("session_id", sa.Integer(), nullable=False), + sa.Column("t", sa.Integer(), nullable=False), + sa.Column("wpm", sa.Float(), nullable=True), + sa.Column("rms", sa.Float(), nullable=True), + sa.Column("eye_contact", sa.Float(), nullable=True), + sa.Column("smile", sa.Float(), nullable=True), + sa.Column("yaw", sa.Float(), nullable=True), + sa.Column("pitch", sa.Float(), nullable=True), + sa.Column("roll", sa.Float(), nullable=True), + sa.Column("filler_count", sa.Integer(), nullable=True), + sa.Column("pause_ms", sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(["session_id"], ["sessions.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_metrics_timeseries_id"), "metrics_timeseries", ["id"], unique=False) + + op.create_table( + "scores", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("session_id", sa.Integer(), nullable=False), + sa.Column("data", sa.JSON(), nullable=False), + sa.ForeignKeyConstraint(["session_id"], ["sessions.id"]), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("session_id"), + ) + op.create_index(op.f("ix_scores_id"), "scores", ["id"], unique=False) + + op.create_table( + "tags", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("session_id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=80), nullable=False), + sa.ForeignKeyConstraint(["session_id"], ["sessions.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_tags_id"), "tags", ["id"], unique=False) + + op.create_table( + "transcript_segments", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("session_id", sa.Integer(), nullable=False), + sa.Column("start_ms", sa.Integer(), nullable=False), + sa.Column("end_ms", sa.Integer(), nullable=False), + sa.Column("text", sa.Text(), nullable=False), + sa.ForeignKeyConstraint(["session_id"], ["sessions.id"]), + sa.PrimaryKeyConstraint("id"), + ) + op.create_index(op.f("ix_transcript_segments_id"), "transcript_segments", ["id"], unique=False) + + +def downgrade() -> None: + op.drop_index(op.f("ix_transcript_segments_id"), table_name="transcript_segments") + op.drop_table("transcript_segments") + op.drop_index(op.f("ix_tags_id"), table_name="tags") + op.drop_table("tags") + op.drop_index(op.f("ix_scores_id"), table_name="scores") + op.drop_table("scores") + op.drop_index(op.f("ix_metrics_timeseries_id"), table_name="metrics_timeseries") + op.drop_table("metrics_timeseries") + op.drop_index(op.f("ix_sessions_id"), table_name="sessions") + op.drop_table("sessions") + op.drop_index(op.f("ix_refresh_tokens_jti_hash"), table_name="refresh_tokens") + op.drop_index(op.f("ix_refresh_tokens_user_id"), table_name="refresh_tokens") + op.drop_index(op.f("ix_refresh_tokens_id"), table_name="refresh_tokens") + op.drop_table("refresh_tokens") + op.drop_index(op.f("ix_users_email"), table_name="users") + op.drop_index(op.f("ix_users_id"), table_name="users") + op.drop_table("users") diff --git a/apps/api/migrations/versions/20260608_0002_analytics_coaching.py b/apps/api/migrations/versions/20260608_0002_analytics_coaching.py new file mode 100644 index 0000000000000000000000000000000000000000..4e51954e5eab10bf5443305c7ade4fd5d36ef47f --- /dev/null +++ b/apps/api/migrations/versions/20260608_0002_analytics_coaching.py @@ -0,0 +1,117 @@ +"""add analytics and coaching tables + +Revision ID: 20260608_0002 +Revises: 20260607_0001 +Create Date: 2026-06-08 +""" +from alembic import op +import sqlalchemy as sa + +revision = "20260608_0002" +down_revision = "20260607_0001" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "session_metrics", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("session_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("recorded_at", sa.DateTime(), nullable=False), + sa.Column("duration_seconds", sa.Float(), nullable=False, server_default="0"), + sa.Column("word_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("wpm", sa.Float(), nullable=False, server_default="0"), + sa.Column("filler_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("filler_rate", sa.Float(), nullable=False, server_default="0"), + sa.Column("pause_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("unique_word_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("vocabulary_density", sa.Float(), nullable=False, server_default="0"), + sa.Column("eye_contact_score", sa.Float(), nullable=True), + sa.Column("smile_score", sa.Float(), nullable=True), + sa.Column("head_stability_score", sa.Float(), nullable=True), + sa.Column("loudness_score", sa.Float(), nullable=True), + sa.Column("speech_score", sa.Float(), nullable=False, server_default="0"), + sa.Column("delivery_score", sa.Float(), nullable=False, server_default="0"), + sa.Column("overall_score", sa.Float(), nullable=False, server_default="0"), + sa.Column("filler_breakdown", sa.JSON(), nullable=True), + sa.Column("wpm_timeline", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.ForeignKeyConstraint(["session_id"], ["sessions.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("session_id"), + ) + op.create_index(op.f("ix_session_metrics_id"), "session_metrics", ["id"], unique=False) + op.create_index(op.f("ix_session_metrics_session_id"), "session_metrics", ["session_id"], unique=False) + op.create_index(op.f("ix_session_metrics_user_id"), "session_metrics", ["user_id"], unique=False) + op.create_index("ix_session_metrics_user_date", "session_metrics", ["user_id", "recorded_at"], unique=False) + + op.create_table( + "daily_aggregates", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("practice_date", sa.Date(), nullable=False), + sa.Column("session_count", sa.Integer(), nullable=False, server_default="0"), + sa.Column("total_duration_seconds", sa.Float(), nullable=False, server_default="0"), + sa.Column("avg_wpm", sa.Float(), nullable=False, server_default="0"), + sa.Column("avg_filler_rate", sa.Float(), nullable=False, server_default="0"), + sa.Column("avg_overall_score", sa.Float(), nullable=False, server_default="0"), + sa.Column("avg_speech_score", sa.Float(), nullable=False, server_default="0"), + sa.Column("avg_delivery_score", sa.Float(), nullable=False, server_default="0"), + sa.Column("best_overall_score", sa.Float(), nullable=False, server_default="0"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "practice_date", name="uq_daily_aggregates_user_date"), + ) + op.create_index(op.f("ix_daily_aggregates_id"), "daily_aggregates", ["id"], unique=False) + op.create_index(op.f("ix_daily_aggregates_user_id"), "daily_aggregates", ["user_id"], unique=False) + op.create_index("ix_daily_aggregates_user_date", "daily_aggregates", ["user_id", "practice_date"], unique=False) + + op.create_table( + "coaching_feedback", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("session_id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("status", sa.String(length=20), nullable=False, server_default="pending"), + sa.Column("model_used", sa.String(length=100), nullable=True), + sa.Column("generation_time_ms", sa.Integer(), nullable=True), + sa.Column("summary", sa.Text(), nullable=True), + sa.Column("strengths", sa.JSON(), nullable=True), + sa.Column("improvements", sa.JSON(), nullable=True), + sa.Column("drills", sa.JSON(), nullable=True), + sa.Column("practice_plan", sa.JSON(), nullable=True), + sa.Column("raw_response", sa.Text(), nullable=True), + sa.Column("compared_to_session_id", sa.Integer(), nullable=True), + sa.Column("trend_context", sa.JSON(), nullable=True), + sa.Column("created_at", sa.DateTime(), nullable=True), + sa.Column("error_message", sa.Text(), nullable=True), + sa.ForeignKeyConstraint(["session_id"], ["sessions.id"], ondelete="CASCADE"), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("session_id"), + ) + op.create_index(op.f("ix_coaching_feedback_id"), "coaching_feedback", ["id"], unique=False) + op.create_index(op.f("ix_coaching_feedback_session_id"), "coaching_feedback", ["session_id"], unique=False) + op.create_index(op.f("ix_coaching_feedback_user_id"), "coaching_feedback", ["user_id"], unique=False) + op.create_index("ix_coaching_feedback_user_created", "coaching_feedback", ["user_id", "created_at"], unique=False) + + +def downgrade() -> None: + op.drop_index("ix_coaching_feedback_user_created", table_name="coaching_feedback") + op.drop_index(op.f("ix_coaching_feedback_user_id"), table_name="coaching_feedback") + op.drop_index(op.f("ix_coaching_feedback_session_id"), table_name="coaching_feedback") + op.drop_index(op.f("ix_coaching_feedback_id"), table_name="coaching_feedback") + op.drop_table("coaching_feedback") + + op.drop_index("ix_daily_aggregates_user_date", table_name="daily_aggregates") + op.drop_index(op.f("ix_daily_aggregates_user_id"), table_name="daily_aggregates") + op.drop_index(op.f("ix_daily_aggregates_id"), table_name="daily_aggregates") + op.drop_table("daily_aggregates") + + op.drop_index("ix_session_metrics_user_date", table_name="session_metrics") + op.drop_index(op.f("ix_session_metrics_user_id"), table_name="session_metrics") + op.drop_index(op.f("ix_session_metrics_session_id"), table_name="session_metrics") + op.drop_index(op.f("ix_session_metrics_id"), table_name="session_metrics") + op.drop_table("session_metrics") diff --git a/apps/api/migrations/versions/20260904_0003_user_goals.py b/apps/api/migrations/versions/20260904_0003_user_goals.py new file mode 100644 index 0000000000000000000000000000000000000000..f7322767daa0d759af67965d142a11ea212ff6ac --- /dev/null +++ b/apps/api/migrations/versions/20260904_0003_user_goals.py @@ -0,0 +1,41 @@ +"""add user goals + +Revision ID: 20260904_0003 +Revises: 20260608_0002 +Create Date: 2026-09-04 +""" +from alembic import op +import sqlalchemy as sa + +revision = "20260904_0003" +down_revision = "20260608_0002" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.create_table( + "user_goals", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("user_id", sa.Integer(), nullable=False), + sa.Column("metric_name", sa.String(length=50), nullable=False), + sa.Column("comparison", sa.String(length=10), nullable=False), + sa.Column("target_value", sa.Float(), nullable=False), + sa.Column("target_upper", sa.Float(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()), + sa.Column("created_at", sa.DateTime(), nullable=False), + sa.Column("updated_at", sa.DateTime(), nullable=False), + sa.ForeignKeyConstraint(["user_id"], ["users.id"], ondelete="CASCADE"), + sa.PrimaryKeyConstraint("id"), + sa.UniqueConstraint("user_id", "metric_name", name="uq_user_goals_user_metric"), + ) + op.create_index(op.f("ix_user_goals_id"), "user_goals", ["id"], unique=False) + op.create_index(op.f("ix_user_goals_user_id"), "user_goals", ["user_id"], unique=False) + op.create_index("ix_user_goals_user_active", "user_goals", ["user_id", "is_active"], unique=False) + + +def downgrade() -> None: + op.drop_index("ix_user_goals_user_active", table_name="user_goals") + op.drop_index(op.f("ix_user_goals_user_id"), table_name="user_goals") + op.drop_index(op.f("ix_user_goals_id"), table_name="user_goals") + op.drop_table("user_goals") diff --git a/apps/api/mypy.ini b/apps/api/mypy.ini new file mode 100644 index 0000000000000000000000000000000000000000..e1568cf434f46246676ed93a0fc99040617fd926 --- /dev/null +++ b/apps/api/mypy.ini @@ -0,0 +1,4 @@ +[mypy] +python_version = 3.11 +ignore_missing_imports = True +ignore_errors = True diff --git a/apps/api/requirements-desktop.txt b/apps/api/requirements-desktop.txt new file mode 100644 index 0000000000000000000000000000000000000000..39c2712e93897ba1268c0c2abf6721f616e76256 --- /dev/null +++ b/apps/api/requirements-desktop.txt @@ -0,0 +1,18 @@ +fastapi==0.111.0 +uvicorn[standard]==0.30.0 +sqlalchemy==2.0.30 +pydantic==2.7.2 +pydantic-settings==2.2.1 +email-validator==2.2.0 +python-jose==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.9 +aiofiles==23.2.1 +faster-whisper==1.1.0 +httpx==0.27.0 +requests==2.32.3 +Cython +packaging +huggingface_hub==0.24.7 +nemo_toolkit[asr] +mlx-audio diff --git a/apps/api/requirements-dev.txt b/apps/api/requirements-dev.txt new file mode 100644 index 0000000000000000000000000000000000000000..6891f13f401b0f6a3e8cdfa8a4e1c68c56fe95a2 --- /dev/null +++ b/apps/api/requirements-dev.txt @@ -0,0 +1,4 @@ +-r requirements.txt +ruff==0.5.7 +mypy==1.11.1 +pytest-cov==5.0.0 diff --git a/apps/api/requirements.txt b/apps/api/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..ada50bc8f555328fc07a6855d83373490a3cc014 --- /dev/null +++ b/apps/api/requirements.txt @@ -0,0 +1,21 @@ +fastapi==0.111.0 +uvicorn[standard]==0.30.0 +sqlalchemy==2.0.30 +psycopg2-binary==2.9.9 +pydantic==2.7.2 +pydantic-settings==2.2.1 +email-validator==2.2.0 +python-jose==3.3.0 +passlib[bcrypt]==1.7.4 +python-multipart==0.0.9 +aiofiles==23.2.1 +boto3==1.34.107 +alembic==1.13.2 +arq==0.26.1 +redis[hiredis]==5.0.8 +faster-whisper==1.1.0 +pytest==8.2.1 +httpx==0.27.0 +requests==2.32.3 +Cython +packaging diff --git a/apps/api/ruff.toml b/apps/api/ruff.toml new file mode 100644 index 0000000000000000000000000000000000000000..a64b8126c22245b846939ccc1ee15ac5abe4849a --- /dev/null +++ b/apps/api/ruff.toml @@ -0,0 +1,9 @@ +target-version = "py311" +line-length = 110 + +[lint] +select = ["E", "F"] +ignore = ["E402", "E501"] + +[lint.per-file-ignores] +"tests/**/*.py" = ["S101"] diff --git a/apps/api/src/app/__init__.py b/apps/api/src/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/apps/api/src/app/auth.py b/apps/api/src/app/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..eb2b49629d8cb3ccc8af43ef479e71871b59e986 --- /dev/null +++ b/apps/api/src/app/auth.py @@ -0,0 +1,277 @@ +import hashlib +import uuid +from datetime import datetime, timedelta + +import bcrypt +from fastapi import Body, Cookie, Depends, Header, HTTPException, Query, Response, status +from fastapi.security import OAuth2PasswordBearer +from jose import JWTError, jwt +from pydantic import BaseModel +from sqlalchemy.orm import Session + +from app import models +from app.config import settings +from app.db import get_db + +oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/auth/login", auto_error=False) +REFRESH_COOKIE_NAME = "refresh_token" +ACCESS_HEADER_NAME = "X-Presenter-Token" +REFRESH_HEADER_NAME = "X-Refresh-Token" + + +class TokenPayload(BaseModel): + sub: str + email: str | None = None + role: str = "user" + jti: str + type: str + + +def hash_password(password: str) -> str: + return bcrypt.hashpw(password.encode("utf-8"), bcrypt.gensalt(rounds=12)).decode("utf-8") + + +def verify_password(password: str, hashed_password: str) -> bool: + return bcrypt.checkpw(password.encode("utf-8"), hashed_password.encode("utf-8")) + + +def _utcnow() -> datetime: + return datetime.utcnow() + + +def _hash_jti(jti: str) -> str: + return hashlib.sha256(jti.encode("utf-8")).hexdigest() + + +def _token_expire_delta(token_type: str) -> timedelta: + if token_type == "refresh": + return timedelta(days=settings.refresh_token_expire_days) + return timedelta(minutes=settings.access_token_expire_minutes) + + +def _create_token(user: models.User, token_type: str, jti: str | None = None) -> tuple[str, str, datetime]: + token_jti = jti or str(uuid.uuid4()) + expires_at = _utcnow() + _token_expire_delta(token_type) + payload = { + "sub": str(user.id), + "email": user.email, + "role": "admin" if user.is_admin else "user", + "iat": _utcnow(), + "exp": expires_at, + "jti": token_jti, + "type": token_type, + } + token = jwt.encode(payload, settings.jwt_secret, algorithm=settings.jwt_algorithm) + return token, token_jti, expires_at + + +def create_access_token(user: models.User) -> str: + token, _jti, _expires_at = _create_token(user, "access") + return token + + +def create_refresh_token(db: Session, user: models.User) -> str: + token, jti, expires_at = _create_token(user, "refresh") + db.add( + models.RefreshToken( + user_id=user.id, + jti_hash=_hash_jti(jti), + expires_at=expires_at, + ) + ) + db.commit() + return token + + +def set_refresh_cookie(response: Response, token: str) -> None: + response.set_cookie( + key=REFRESH_COOKIE_NAME, + value=token, + httponly=True, + secure=settings.cookie_secure, + samesite="lax", + max_age=settings.refresh_token_expire_days * 24 * 60 * 60, + path=settings.cookie_path, + ) + + +def clear_refresh_cookie(response: Response) -> None: + response.delete_cookie( + key=REFRESH_COOKIE_NAME, + httponly=True, + secure=settings.cookie_secure, + samesite="lax", + path=settings.cookie_path, + ) + + +def decode_token(token: str, expected_type: str = "access") -> TokenPayload: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token", + headers={"WWW-Authenticate": "Bearer"}, + ) + try: + payload = jwt.decode( + token, + settings.jwt_secret, + algorithms=[settings.jwt_algorithm], + options={"require_exp": True, "require_iat": True}, + ) + except JWTError as exc: + raise credentials_exception from exc + + if payload.get("type") != expected_type: + raise credentials_exception + + try: + return TokenPayload( + sub=str(payload["sub"]), + email=payload.get("email"), + role=payload.get("role", "user"), + jti=str(payload["jti"]), + type=str(payload["type"]), + ) + except (KeyError, ValueError) as exc: + raise credentials_exception from exc + + +def authenticate_user(db: Session, email: str, password: str) -> models.User | None: + user = db.query(models.User).filter(models.User.email == email).first() + if not user or not verify_password(password, user.hashed_password): + return None + return user + + +def issue_token_pair(db: Session, response: Response, user: models.User) -> dict: + access_token = create_access_token(user) + refresh_token = create_refresh_token(db, user) + set_refresh_cookie(response, refresh_token) + return { + "access_token": access_token, + "refresh_token": refresh_token, + "token_type": "bearer", + "expires_in": settings.access_token_expire_minutes * 60, + "user": {"id": user.id, "email": user.email, "is_admin": user.is_admin}, + } + + +def rotate_refresh_token( + db: Session, + response: Response, + refresh_token: str | None, +) -> dict: + if not refresh_token: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing refresh token") + + payload = decode_token(refresh_token, expected_type="refresh") + token_row = ( + db.query(models.RefreshToken) + .filter(models.RefreshToken.jti_hash == _hash_jti(payload.jti)) + .first() + ) + if token_row is None or token_row.revoked_at is not None or token_row.expires_at <= _utcnow(): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token") + + try: + user_id = int(payload.sub) + except ValueError as exc: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token") from exc + + user = db.query(models.User).filter(models.User.id == user_id).first() + if user is None: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid refresh token") + + access_token = create_access_token(user) + new_refresh_token, new_jti, new_expires_at = _create_token(user, "refresh") + token_row.revoked_at = _utcnow() + token_row.replaced_by_jti_hash = _hash_jti(new_jti) + db.add( + models.RefreshToken( + user_id=user.id, + jti_hash=_hash_jti(new_jti), + expires_at=new_expires_at, + ) + ) + db.commit() + set_refresh_cookie(response, new_refresh_token) + return { + "access_token": access_token, + "refresh_token": new_refresh_token, + "token_type": "bearer", + "expires_in": settings.access_token_expire_minutes * 60, + "user": {"id": user.id, "email": user.email, "is_admin": user.is_admin}, + } + + +def revoke_refresh_token(db: Session, response: Response, refresh_token: str | None) -> None: + if refresh_token: + try: + payload = decode_token(refresh_token, expected_type="refresh") + except HTTPException: + payload = None + if payload is not None: + token_row = ( + db.query(models.RefreshToken) + .filter(models.RefreshToken.jti_hash == _hash_jti(payload.jti)) + .first() + ) + if token_row is not None and token_row.revoked_at is None: + token_row.revoked_at = _utcnow() + db.commit() + clear_refresh_cookie(response) + + +def get_current_user( + db: Session = Depends(get_db), + bearer_token: str | None = Depends(oauth2_scheme), + header_token: str | None = Header(default=None, alias=ACCESS_HEADER_NAME), + query_token: str | None = Query(default=None, alias="_pt"), +) -> models.User: + credentials_exception = HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Could not validate credentials", + headers={"WWW-Authenticate": "Bearer"}, + ) + candidates = [tok for tok in (header_token, query_token, bearer_token) if tok] + if not candidates: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Not authenticated", + headers={"WWW-Authenticate": "Bearer"}, + ) + + payload: TokenPayload | None = None + last_error: HTTPException = credentials_exception + for candidate in candidates: + try: + payload = decode_token(candidate, expected_type="access") + break + except HTTPException as exc: + last_error = exc + + if payload is None: + raise last_error + + try: + user_id = int(payload.sub) + except ValueError as exc: + raise credentials_exception from exc + + user = db.query(models.User).filter(models.User.id == user_id).first() + if user is None: + raise credentials_exception + return user + + +def get_refresh_cookie( + refresh_token: str | None = Cookie(default=None, alias=REFRESH_COOKIE_NAME), + header_refresh_token: str | None = Header(default=None, alias=REFRESH_HEADER_NAME), + body_payload: dict | None = Body(default=None), +) -> str | None: + body_token = None + if isinstance(body_payload, dict): + raw = body_payload.get("refresh_token") + if isinstance(raw, str) and raw.strip(): + body_token = raw.strip() + return refresh_token or header_refresh_token or body_token diff --git a/apps/api/src/app/config.py b/apps/api/src/app/config.py new file mode 100644 index 0000000000000000000000000000000000000000..1dcdd67d9453e8aece97756958210a13bcf5874d --- /dev/null +++ b/apps/api/src/app/config.py @@ -0,0 +1,68 @@ +import json + +from pydantic import ConfigDict +from pydantic_settings import BaseSettings + + +class Settings(BaseSettings): + model_config = ConfigDict(env_prefix="", case_sensitive=False) + + database_url: str = "sqlite:///./local.db" + jwt_secret: str = "change-me" + jwt_algorithm: str = "HS256" + access_token_expire_minutes: int = 15 + refresh_token_expire_days: int = 7 + cookie_path: str = "/auth" + environment: str = "development" + cors_origins: str = "http://localhost:3000" + max_upload_mb: int = 500 + redis_host: str = "localhost" + redis_port: int = 6379 + storage_backend: str = "local" + local_only_mode: bool = False + local_admin_email: str = "admin@example.local" + local_admin_password: str = "" + storage_dir: str = "./data/storage" + s3_endpoint: str | None = None + s3_access_key: str | None = None + s3_secret_key: str | None = None + s3_bucket: str | None = None + s3_region: str | None = None + transcription_backend: str = "server" + whisper_model: str = "small" + whisper_device: str = "cpu" + mlx_audio_model: str = "mlx-community/parakeet-tdt-0.6b-v3" + ollama_base_url: str = "http://127.0.0.1:11434" + lmstudio_base_url: str = "http://127.0.0.1:1234/v1" + llama_cpp_base_url: str = "http://127.0.0.1:8080" + local_engine_timeout_seconds: float = 0.75 + coaching_enabled: bool = True + coaching_provider: str = "auto" + coaching_fallback_builtin: bool = True + coaching_model: str = "llama3.2:3b" + coaching_timeout_seconds: float = 90 + coaching_max_transcript_chars: int = 8000 + use_local_worker_fallback: bool = True + + @property + def cors_origin_list(self) -> list[str]: + value = self.cors_origins.strip() + if value.startswith("["): + try: + parsed = json.loads(value) + except json.JSONDecodeError: + parsed = None + if isinstance(parsed, list): + return [str(origin).strip() for origin in parsed if str(origin).strip()] + return [origin.strip() for origin in value.split(",") if origin.strip()] + + @property + def max_upload_bytes(self) -> int: + return self.max_upload_mb * 1024 * 1024 + + @property + def cookie_secure(self) -> bool: + return self.environment.lower() == "production" + + +settings = Settings() diff --git a/apps/api/src/app/db.py b/apps/api/src/app/db.py new file mode 100644 index 0000000000000000000000000000000000000000..2c7286d9348e45a7781edf03ecc4c40aae8a3e62 --- /dev/null +++ b/apps/api/src/app/db.py @@ -0,0 +1,26 @@ +from sqlalchemy import create_engine +from sqlalchemy.engine.url import make_url +from sqlalchemy.orm import declarative_base, sessionmaker + +from app.config import settings + +normalized_db_url = settings.database_url +if normalized_db_url.startswith("postgres://"): + normalized_db_url = normalized_db_url.replace("postgres://", "postgresql://", 1) + +url = make_url(normalized_db_url) +connect_args = {} +if url.drivername.startswith("sqlite"): + connect_args = {"check_same_thread": False} + +engine = create_engine(normalized_db_url, pool_pre_ping=True, connect_args=connect_args) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() + + +def get_db(): + db = SessionLocal() + try: + yield db + finally: + db.close() diff --git a/apps/api/src/app/hf_models.py b/apps/api/src/app/hf_models.py new file mode 100644 index 0000000000000000000000000000000000000000..7ade9c758446ea34959d482ffe3a8e4295a1f708 --- /dev/null +++ b/apps/api/src/app/hf_models.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +import inspect +import json +import re +import shutil +from pathlib import Path +from typing import Any, Callable + +from app.config import settings + +SERVER_BACKEND = "server" +MLX_AUDIO_BACKEND = "mlx_audio" +DOWNLOAD_MARKER = ".sparks-model-download.json" + +SERVER_WHISPER_MODELS: tuple[dict[str, str], ...] = ( + { + "id": "openai/whisper-tiny", + "name": "Whisper Tiny", + "repo_id": "openai/whisper-tiny", + "runtime_model_id": "tiny", + "description": "OpenAI Whisper Tiny served through the faster-whisper runtime.", + "languages": "99+ languages", + "backend": SERVER_BACKEND, + }, + { + "id": "openai/whisper-base", + "name": "Whisper Base", + "repo_id": "openai/whisper-base", + "runtime_model_id": "base", + "description": "OpenAI Whisper Base served through the faster-whisper runtime.", + "languages": "99+ languages", + "backend": SERVER_BACKEND, + }, + { + "id": "openai/whisper-small", + "name": "Whisper Small", + "repo_id": "openai/whisper-small", + "runtime_model_id": "small", + "description": "OpenAI Whisper Small served through the faster-whisper runtime.", + "languages": "99+ languages", + "backend": SERVER_BACKEND, + }, + { + "id": "openai/whisper-medium", + "name": "Whisper Medium", + "repo_id": "openai/whisper-medium", + "runtime_model_id": "medium", + "description": "OpenAI Whisper Medium served through the faster-whisper runtime.", + "languages": "99+ languages", + "backend": SERVER_BACKEND, + }, + { + "id": "openai/whisper-large-v3", + "name": "Whisper Large v3", + "repo_id": "openai/whisper-large-v3", + "runtime_model_id": "large-v3", + "description": "OpenAI Whisper Large v3 served through the faster-whisper runtime.", + "languages": "99+ languages", + "backend": SERVER_BACKEND, + }, + { + "id": "openai/whisper-large-v3-turbo", + "name": "Whisper Large v3 Turbo", + "repo_id": "openai/whisper-large-v3-turbo", + "runtime_model_id": "turbo", + "description": "OpenAI Whisper Large v3 Turbo served through the faster-whisper runtime.", + "languages": "99+ languages", + "backend": SERVER_BACKEND, + }, +) + +MLX_AUDIO_STT_MODELS: tuple[dict[str, str], ...] = ( + { + "id": "mlx-community/whisper-large-v3-turbo-asr-fp16", + "name": "Whisper", + "repo_id": "mlx-community/whisper-large-v3-turbo-asr-fp16", + "description": "OpenAI's robust speech-to-text model for multilingual transcription.", + "languages": "99+ languages", + "backend": MLX_AUDIO_BACKEND, + }, + { + "id": "distil-whisper/distil-large-v3", + "name": "Distil-Whisper", + "repo_id": "distil-whisper/distil-large-v3", + "description": "Distilled Whisper variant optimized for faster English transcription.", + "languages": "English", + "backend": MLX_AUDIO_BACKEND, + }, + { + "id": "mlx-community/Qwen3-ASR-1.7B-8bit", + "name": "Qwen3-ASR", + "repo_id": "mlx-community/Qwen3-ASR-1.7B-8bit", + "description": "Alibaba's multilingual ASR model for speech recognition across major languages.", + "languages": "ZH, EN, JA, KO, and more", + "backend": MLX_AUDIO_BACKEND, + }, + { + "id": "mlx-community/Qwen3-ForcedAligner-0.6B-8bit", + "name": "Qwen3-ForcedAligner", + "repo_id": "mlx-community/Qwen3-ForcedAligner-0.6B-8bit", + "description": "Word-level audio alignment model for timestamp refinement and transcript sync.", + "languages": "ZH, EN, JA, KO, and more", + "backend": MLX_AUDIO_BACKEND, + }, + { + "id": "mlx-community/parakeet-tdt-0.6b-v3", + "name": "Parakeet", + "repo_id": "mlx-community/parakeet-tdt-0.6b-v3", + "description": "NVIDIA's accurate speech recognizer with strong multilingual coverage.", + "languages": "25 European languages", + "backend": MLX_AUDIO_BACKEND, + }, + { + "id": "mlx-community/Voxtral-Mini-3B-2507-bf16", + "name": "Voxtral", + "repo_id": "mlx-community/Voxtral-Mini-3B-2507-bf16", + "description": "Mistral's compact speech model for general-purpose transcription.", + "languages": "Multiple languages", + "backend": MLX_AUDIO_BACKEND, + }, + { + "id": "mlx-community/Voxtral-Mini-4B-Realtime-2602-4bit", + "name": "Voxtral Realtime 4bit", + "repo_id": "mlx-community/Voxtral-Mini-4B-Realtime-2602-4bit", + "description": "Streaming-friendly Voxtral realtime model quantized for lighter local use.", + "languages": "Multiple languages", + "backend": MLX_AUDIO_BACKEND, + }, + { + "id": "mlx-community/Voxtral-Mini-4B-Realtime-2602-fp16", + "name": "Voxtral Realtime fp16", + "repo_id": "mlx-community/Voxtral-Mini-4B-Realtime-2602-fp16", + "description": "Higher precision realtime Voxtral transcription model for local speech capture.", + "languages": "Multiple languages", + "backend": MLX_AUDIO_BACKEND, + }, + { + "id": "mlx-community/VibeVoice-ASR-bf16", + "name": "VibeVoice-ASR", + "repo_id": "mlx-community/VibeVoice-ASR-bf16", + "description": "Microsoft's ASR model with diarization and timestamp support.", + "languages": "Multiple languages", + "backend": MLX_AUDIO_BACKEND, + }, +) + + +def get_models_root() -> Path: + storage_dir = Path(settings.storage_dir).expanduser() + return storage_dir.parent / "models" / "hf" + + +def _sanitize_repo_id(repo_id: str) -> str: + return re.sub(r"[^A-Za-z0-9._-]+", "-", repo_id).strip("-") + + +def _model_dir(repo_id: str) -> Path: + return get_models_root() / _sanitize_repo_id(repo_id) + + +def _download_marker_path(repo_id: str) -> Path: + return _model_dir(repo_id) / DOWNLOAD_MARKER + + +def _is_model_downloaded(repo_id: str) -> bool: + model_dir = _model_dir(repo_id) + return model_dir.exists() and _download_marker_path(repo_id).exists() + + +def _build_model_status(model: dict[str, str]) -> dict[str, Any]: + local_path = str(_model_dir(model["repo_id"])) if _is_model_downloaded(model["repo_id"]) else None + return { + "id": model["id"], + "name": model["name"], + "repo_id": model["repo_id"], + "description": model["description"], + "languages": model["languages"], + "available": local_path is not None, + "local_path": local_path, + } + + +def _prepare_model_dir(repo_id: str) -> Path: + target_dir = _model_dir(repo_id) + target_dir.parent.mkdir(parents=True, exist_ok=True) + if target_dir.exists() and not _download_marker_path(repo_id).exists(): + shutil.rmtree(target_dir, ignore_errors=True) + return target_dir + + +def _finalize_download(repo_id: str) -> None: + _download_marker_path(repo_id).write_text( + json.dumps({"repo_id": repo_id}, indent=2), + encoding="utf-8", + ) + + +def _download_with_marker(model: dict[str, Any], downloader: Callable[[Path], None]) -> dict[str, Any]: + if _is_model_downloaded(model["repo_id"]): + return _build_model_status(model) + + target_dir = _prepare_model_dir(model["repo_id"]) + + try: + downloader(target_dir) + except Exception as exc: # pragma: no cover - defensive wrapper around third-party download errors + if target_dir.exists() and not _download_marker_path(model["repo_id"]).exists(): + shutil.rmtree(target_dir, ignore_errors=True) + raise RuntimeError(f"Failed to download model '{model['repo_id']}': {exc}") from exc + + _finalize_download(model["repo_id"]) + return _build_model_status(model) + + +def get_server_model_ids() -> tuple[str, ...]: + return tuple(model["id"] for model in SERVER_WHISPER_MODELS) + + +def get_server_model_keys() -> set[str]: + keys: set[str] = set() + for model in SERVER_WHISPER_MODELS: + keys.add(model["id"]) + keys.add(model["repo_id"]) + keys.add(model["runtime_model_id"]) + return keys + + +def get_server_model_catalog() -> list[dict[str, Any]]: + return [_build_model_status(model) for model in SERVER_WHISPER_MODELS] + + +def resolve_server_model_entry(model_name: str | None = None) -> dict[str, Any]: + selected_model = model_name or settings.whisper_model + for model in SERVER_WHISPER_MODELS: + if selected_model in {model["id"], model["repo_id"], model["runtime_model_id"]}: + return {**_build_model_status(model), "runtime_model_id": model["runtime_model_id"]} + raise ValueError( + f"Unsupported server transcription model '{selected_model}'. " + f"Choose one of: {', '.join(get_server_model_ids())}" + ) + + +def download_server_model(model_name: str) -> dict[str, Any]: + model = resolve_server_model_entry(model_name) + + try: + import faster_whisper + from faster_whisper import utils as faster_whisper_utils + from faster_whisper.utils import download_model + except ImportError as exc: + raise RuntimeError( + "faster-whisper is not installed in this runtime. " + "Install it before downloading Whisper models." + ) from exc + + supported_models = getattr(faster_whisper_utils, "_MODELS", {}) + if isinstance(supported_models, dict) and model["runtime_model_id"] not in supported_models: + runtime_version = getattr(faster_whisper, "__version__", "installed") + raise ValueError( + f"Whisper model '{model['name']}' is not supported by faster-whisper {runtime_version}. " + f"Upgrade faster-whisper to a version that supports '{model['runtime_model_id']}' " + "before downloading this model." + ) + + parameters = inspect.signature(download_model).parameters + + def _download(target_dir: Path) -> None: + if "output_dir" in parameters: + download_model(model["runtime_model_id"], output_dir=str(target_dir)) + return + download_model(model["runtime_model_id"], str(target_dir)) + + return _download_with_marker(model, _download) + + +def get_mlx_audio_model_ids() -> tuple[str, ...]: + return tuple(model["id"] for model in MLX_AUDIO_STT_MODELS) + + +def get_mlx_audio_model_keys() -> set[str]: + keys: set[str] = set() + for model in MLX_AUDIO_STT_MODELS: + keys.add(model["id"]) + keys.add(model["repo_id"]) + return keys + + +def get_mlx_audio_model_catalog() -> list[dict[str, Any]]: + return [_build_model_status(model) for model in MLX_AUDIO_STT_MODELS] + + +def resolve_mlx_audio_model_entry(model_name: str | None = None) -> dict[str, Any]: + selected_model = model_name or settings.mlx_audio_model + for model in MLX_AUDIO_STT_MODELS: + if selected_model in {model["id"], model["repo_id"]}: + return _build_model_status(model) + raise ValueError( + f"Unsupported MLX Audio model '{selected_model}'. " + f"Choose one of: {', '.join(get_mlx_audio_model_ids())}" + ) + + +def download_mlx_audio_model(model_name: str) -> dict[str, Any]: + model = resolve_mlx_audio_model_entry(model_name) + + try: + from huggingface_hub import snapshot_download + except ImportError as exc: + raise RuntimeError( + "huggingface_hub is not installed in this desktop runtime. " + "Install it in the desktop environment to download MLX models." + ) from exc + + def _download(target_dir: Path) -> None: + snapshot_download(repo_id=model["repo_id"], local_dir=str(target_dir)) + + return _download_with_marker(model, _download) diff --git a/apps/api/src/app/local_inference.py b/apps/api/src/app/local_inference.py new file mode 100644 index 0000000000000000000000000000000000000000..b63f1711213d8832166488b8eefbdd14b3c81087 --- /dev/null +++ b/apps/api/src/app/local_inference.py @@ -0,0 +1,147 @@ +from __future__ import annotations + +from typing import Callable + +import requests + +from app.config import settings + +RECOMMENDED_LOCAL_MODELS = ( + "nvidia/parakeet-tdt-0.6b-v3", + "nvidia/parakeet-ctc-1.1b", + "mistralai/Voxtral-Mini-4B-Realtime-2602", + "Qwen/Qwen3-ASR-1.7B", +) + +ENGINE_LABELS = { + "ollama": "Ollama", + "lmstudio": "LM Studio", + "llamacpp": "llama.cpp", +} + + +def _default_engine_url(engine_id: str) -> str: + if engine_id == "ollama": + return settings.ollama_base_url + if engine_id == "lmstudio": + return settings.lmstudio_base_url + if engine_id == "llamacpp": + return settings.llama_cpp_base_url + raise ValueError(f"Unsupported local inference engine '{engine_id}'") + + +def _join_url(base_url: str, suffix: str) -> str: + return f"{base_url.rstrip('/')}/{suffix.lstrip('/')}" + + +def _normalize_engine_url(engine_id: str, base_url: str | None = None) -> str: + selected = (base_url or _default_engine_url(engine_id)).strip() + return selected.rstrip("/") + + +def _request_json(url: str) -> dict: + response = requests.get(url, timeout=settings.local_engine_timeout_seconds) + response.raise_for_status() + return response.json() + + +def _request_ok(url: str) -> bool: + response = requests.get(url, timeout=settings.local_engine_timeout_seconds) + response.raise_for_status() + return True + + +def _check_with_candidates( + engine_id: str, + base_url: str, + candidates: list[tuple[str, Callable[[dict], list[str]]]], + probe: Callable[[str], dict], +) -> dict: + last_error = "Connection failed" + for suffix, parser in candidates: + url = _join_url(base_url, suffix) + try: + payload = probe(url) + return { + "id": engine_id, + "label": ENGINE_LABELS[engine_id], + "base_url": base_url, + "live": True, + "detail": "Connected", + "available_models": parser(payload), + } + except requests.RequestException as exc: + last_error = str(exc) + return { + "id": engine_id, + "label": ENGINE_LABELS[engine_id], + "base_url": base_url, + "live": False, + "detail": last_error, + "available_models": [], + } + + +def _parse_ollama_models(payload: dict) -> list[str]: + return [item["name"] for item in payload.get("models", []) if item.get("name")] + + +def _parse_openai_models(payload: dict) -> list[str]: + return [item["id"] for item in payload.get("data", []) if item.get("id")] + + +def _parse_empty(_payload: dict) -> list[str]: + return [] + + +def check_engine(engine_id: str, base_url: str | None = None) -> dict: + normalized_url = _normalize_engine_url(engine_id, base_url) + + if engine_id == "ollama": + return _check_with_candidates( + engine_id, + normalized_url, + [ + ("api/tags", _parse_ollama_models), + ("v1/models", _parse_openai_models), + ], + _request_json, + ) + + if engine_id == "lmstudio": + return _check_with_candidates( + engine_id, + normalized_url, + [("models", _parse_openai_models), ("v1/models", _parse_openai_models)], + _request_json, + ) + + if engine_id == "llamacpp": + status = _check_with_candidates( + engine_id, + normalized_url, + [("v1/models", _parse_openai_models)], + _request_json, + ) + if status["live"]: + return status + + try: + _request_ok(_join_url(normalized_url, "health")) + return { + "id": engine_id, + "label": ENGINE_LABELS[engine_id], + "base_url": normalized_url, + "live": True, + "detail": "Connected", + "available_models": [], + } + except requests.RequestException as exc: + status["detail"] = str(exc) + return status + + raise ValueError(f"Unsupported local inference engine '{engine_id}'") + + +def get_all_engine_statuses() -> list[dict]: + return [check_engine(engine_id) for engine_id in ("ollama", "lmstudio", "llamacpp")] diff --git a/apps/api/src/app/main.py b/apps/api/src/app/main.py new file mode 100644 index 0000000000000000000000000000000000000000..257492b3f71c36e803b6f3a9ef654f60f87c3ea5 --- /dev/null +++ b/apps/api/src/app/main.py @@ -0,0 +1,62 @@ +from contextlib import asynccontextmanager +import logging +import sys + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from app.config import settings +from app.db import Base, engine +from app.middleware.logging import StructuredLoggingMiddleware +from app.middleware.security_headers import SecurityHeadersMiddleware +from app.routes import ( + auth, + coaching, + curriculum, + dashboard, + health, + jobs, + news, + sessions, + settings as settings_routes, + setup, +) +from app.schema_upgrades import apply_startup_schema_upgrades + +logging.basicConfig( + level=logging.INFO, + format="%(message)s", + handlers=[logging.StreamHandler(sys.stdout)], +) +logging.getLogger("uvicorn.access").setLevel(logging.WARNING) + + +@asynccontextmanager +async def lifespan(_app: FastAPI): + Base.metadata.create_all(bind=engine) + apply_startup_schema_upgrades() + yield + + +app = FastAPI(title="Speech-Machine Tech Presenter Coach API", lifespan=lifespan) + +app.add_middleware(SecurityHeadersMiddleware) +app.add_middleware(StructuredLoggingMiddleware) +app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origin_list, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], +) + +app.include_router(setup.router) +app.include_router(auth.router) +app.include_router(dashboard.router) +app.include_router(curriculum.router) +app.include_router(news.router) +app.include_router(coaching.router) +app.include_router(sessions.router) +app.include_router(settings_routes.router) +app.include_router(jobs.router) +app.include_router(health.router) diff --git a/apps/api/src/app/middleware/logging.py b/apps/api/src/app/middleware/logging.py new file mode 100644 index 0000000000000000000000000000000000000000..cc1985b7fab0a006a1fe3abea5d3864c4f15cee5 --- /dev/null +++ b/apps/api/src/app/middleware/logging.py @@ -0,0 +1,31 @@ +import json +import logging +import time +from collections.abc import Callable + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +logger = logging.getLogger("speech_machine.access") + + +class StructuredLoggingMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next: Callable) -> Response: + start = time.perf_counter() + response = await call_next(request) + duration_ms = round((time.perf_counter() - start) * 1000, 2) + payload = { + "method": request.method, + "path": request.url.path, + "status": response.status_code, + "duration_ms": duration_ms, + "client_ip": request.client.host if request.client else None, + } + if response.status_code >= 500: + logger.error(json.dumps(payload)) + elif response.status_code >= 400: + logger.warning(json.dumps(payload)) + else: + logger.info(json.dumps(payload)) + return response diff --git a/apps/api/src/app/middleware/security_headers.py b/apps/api/src/app/middleware/security_headers.py new file mode 100644 index 0000000000000000000000000000000000000000..572486ad952fa0c3dce7ee9f376d91834ea8587c --- /dev/null +++ b/apps/api/src/app/middleware/security_headers.py @@ -0,0 +1,21 @@ +from collections.abc import Callable + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + +from app.config import settings + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + async def dispatch(self, request: Request, call_next: Callable) -> Response: + response = await call_next(request) + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + if settings.environment.lower() == "production": + response.headers["X-Frame-Options"] = "SAMEORIGIN" + response.headers["Permissions-Policy"] = "camera=(self), microphone=(self), geolocation=()" + response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains" + else: + response.headers["Permissions-Policy"] = "camera=*, microphone=*, geolocation=()" + return response diff --git a/apps/api/src/app/models.py b/apps/api/src/app/models.py new file mode 100644 index 0000000000000000000000000000000000000000..84bf6efa445af0da55e3b1eafa988713c8e1dd64 --- /dev/null +++ b/apps/api/src/app/models.py @@ -0,0 +1,312 @@ +from datetime import date, datetime +from sqlalchemy import Boolean, Column, Date, DateTime, Float, ForeignKey, Index, Integer, String, Text, JSON, UniqueConstraint +from sqlalchemy.orm import relationship + +from app.db import Base + + +class User(Base): + __tablename__ = "users" + + id = Column(Integer, primary_key=True, index=True) + email = Column(String(255), unique=True, index=True, nullable=False) + hashed_password = Column(String(255), nullable=False) + is_admin = Column(Boolean, default=False, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow) + + sessions = relationship("Session", back_populates="user", cascade="all, delete-orphan") + refresh_tokens = relationship( + "RefreshToken", back_populates="user", cascade="all, delete-orphan" + ) + session_metrics = relationship("SessionMetrics", back_populates="user", cascade="all, delete-orphan") + daily_aggregates = relationship("DailyAggregate", back_populates="user", cascade="all, delete-orphan") + coaching_feedback = relationship("CoachingFeedback", back_populates="user", cascade="all, delete-orphan") + goals = relationship("UserGoal", back_populates="user", cascade="all, delete-orphan") + presenter_profile = relationship( + "PresenterProfile", back_populates="user", uselist=False, cascade="all, delete-orphan" + ) + curriculum_completions = relationship( + "CurriculumDayCompletion", back_populates="user", cascade="all, delete-orphan" + ) + weekly_reflections = relationship( + "WeeklyReflection", back_populates="user", cascade="all, delete-orphan" + ) + + +class RefreshToken(Base): + __tablename__ = "refresh_tokens" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False, index=True) + jti_hash = Column(String(64), unique=True, index=True, nullable=False) + expires_at = Column(DateTime, nullable=False) + revoked_at = Column(DateTime, nullable=True) + replaced_by_jti_hash = Column(String(64), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + + user = relationship("User", back_populates="refresh_tokens") + + +class Session(Base): + __tablename__ = "sessions" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + title = Column(String(255), nullable=True) + mode = Column(String(80), nullable=True) + prompt = Column(Text, nullable=True) + goal = Column(String(255), nullable=True) + curriculum_day = Column(Integer, nullable=True) + exercise_id = Column(String(100), nullable=True) + story_framework = Column(String(80), nullable=True) + target_keywords_json = Column(Text, nullable=True) + teleprompter_script = Column(Text, nullable=True) + started_at = Column(DateTime, nullable=True) + ended_at = Column(DateTime, nullable=True) + duration_ms = Column(Integer, default=0) + wpm_avg = Column(Float, default=0) + filler_count = Column(Integer, default=0) + eye_contact_pct = Column(Float, default=0) + video_path = Column(String(1024), nullable=True) + document_path = Column(String(1024), nullable=True) + document_name = Column(String(255), nullable=True) + document_mime_type = Column(String(255), nullable=True) + document_blocks_json = Column(Text, nullable=True) + transcription_status = Column(String(32), default="idle") + created_at = Column(DateTime, default=datetime.utcnow) + + user = relationship("User", back_populates="sessions") + transcript_segments = relationship( + "TranscriptSegment", back_populates="session", cascade="all, delete-orphan" + ) + metrics = relationship( + "MetricsTimeseries", back_populates="session", cascade="all, delete-orphan" + ) + score = relationship("Score", back_populates="session", uselist=False, cascade="all, delete-orphan") + tags = relationship("Tag", back_populates="session", cascade="all, delete-orphan") + metrics_snapshot = relationship( + "SessionMetrics", back_populates="session", uselist=False, cascade="all, delete-orphan" + ) + coaching_feedback = relationship( + "CoachingFeedback", back_populates="session", uselist=False, cascade="all, delete-orphan" + ) + + +class PresenterProfile(Base): + __tablename__ = "presenter_profiles" + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True, index=True) + display_name = Column(String(120), default="Tech Presenter", nullable=False) + primary_role = Column(String(120), default="Software Engineer & Tech Presenter", nullable=False) + focus_track = Column(String(80), default="tech_storytelling", nullable=False) + experience_level = Column(String(40), default="intermediate", nullable=False) + target_wpm_min = Column(Integer, default=125, nullable=False) + target_wpm_max = Column(Integer, default=155, nullable=False) + max_filler_rate = Column(Float, default=2.0, nullable=False) + min_eye_contact = Column(Float, default=0.7, nullable=False) + daily_minutes_goal = Column(Integer, default=15, nullable=False) + weaknesses = Column(JSON, nullable=True) + speaking_goal_statement = Column( + Text, + default="Become a confident, articulate, engaging technology presenter on camera.", + nullable=True, + ) + curriculum_start_date = Column(Date, default=date.today, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + user = relationship("User", back_populates="presenter_profile") + + +class CurriculumDayCompletion(Base): + __tablename__ = "curriculum_day_completions" + __table_args__ = ( + UniqueConstraint("user_id", "day_number", name="uq_curriculum_user_day"), + Index("ix_curriculum_user_day", "user_id", "day_number"), + ) + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + day_number = Column(Integer, nullable=False) + completed_session_id = Column(Integer, ForeignKey("sessions.id", ondelete="SET NULL"), nullable=True) + best_score = Column(Float, default=0, nullable=False) + attempts_count = Column(Integer, default=1, nullable=False) + reflection_notes = Column(Text, nullable=True) + completed_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + user = relationship("User", back_populates="curriculum_completions") + + +class WeeklyReflection(Base): + __tablename__ = "weekly_reflections" + __table_args__ = ( + UniqueConstraint("user_id", "week_start_date", name="uq_weekly_reflections_user_week"), + Index("ix_weekly_reflections_user_week", "user_id", "week_start_date"), + ) + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + week_start_date = Column(Date, nullable=False) + wins_note = Column(Text, nullable=True) + focus_next_week = Column(Text, nullable=True) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + user = relationship("User", back_populates="weekly_reflections") + + +class SessionMetrics(Base): + __tablename__ = "session_metrics" + __table_args__ = ( + Index("ix_session_metrics_user_date", "user_id", "recorded_at"), + ) + + id = Column(Integer, primary_key=True, index=True) + session_id = Column(Integer, ForeignKey("sessions.id", ondelete="CASCADE"), nullable=False, unique=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + recorded_at = Column(DateTime, nullable=False, default=datetime.utcnow) + + duration_seconds = Column(Float, default=0, nullable=False) + word_count = Column(Integer, default=0, nullable=False) + wpm = Column(Float, default=0, nullable=False) + filler_count = Column(Integer, default=0, nullable=False) + filler_rate = Column(Float, default=0, nullable=False) + pause_count = Column(Integer, default=0, nullable=False) + unique_word_count = Column(Integer, default=0, nullable=False) + vocabulary_density = Column(Float, default=0, nullable=False) + + eye_contact_score = Column(Float, nullable=True) + smile_score = Column(Float, nullable=True) + head_stability_score = Column(Float, nullable=True) + loudness_score = Column(Float, nullable=True) + + speech_score = Column(Float, default=0, nullable=False) + delivery_score = Column(Float, default=0, nullable=False) + overall_score = Column(Float, default=0, nullable=False) + filler_breakdown = Column(JSON, nullable=True) + wpm_timeline = Column(JSON, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + + session = relationship("Session", back_populates="metrics_snapshot") + user = relationship("User", back_populates="session_metrics") + + +class DailyAggregate(Base): + __tablename__ = "daily_aggregates" + __table_args__ = ( + UniqueConstraint("user_id", "practice_date", name="uq_daily_aggregates_user_date"), + Index("ix_daily_aggregates_user_date", "user_id", "practice_date"), + ) + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + practice_date = Column(Date, nullable=False) + session_count = Column(Integer, default=0, nullable=False) + total_duration_seconds = Column(Float, default=0, nullable=False) + avg_wpm = Column(Float, default=0, nullable=False) + avg_filler_rate = Column(Float, default=0, nullable=False) + avg_overall_score = Column(Float, default=0, nullable=False) + avg_speech_score = Column(Float, default=0, nullable=False) + avg_delivery_score = Column(Float, default=0, nullable=False) + best_overall_score = Column(Float, default=0, nullable=False) + + user = relationship("User", back_populates="daily_aggregates") + + +class CoachingFeedback(Base): + __tablename__ = "coaching_feedback" + __table_args__ = ( + Index("ix_coaching_feedback_user_created", "user_id", "created_at"), + ) + + id = Column(Integer, primary_key=True, index=True) + session_id = Column(Integer, ForeignKey("sessions.id", ondelete="CASCADE"), nullable=False, unique=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + status = Column(String(20), default="pending", nullable=False) + model_used = Column(String(100), nullable=True) + generation_time_ms = Column(Integer, nullable=True) + summary = Column(Text, nullable=True) + strengths = Column(JSON, nullable=True) + improvements = Column(JSON, nullable=True) + drills = Column(JSON, nullable=True) + practice_plan = Column(JSON, nullable=True) + raw_response = Column(Text, nullable=True) + compared_to_session_id = Column(Integer, nullable=True) + trend_context = Column(JSON, nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + error_message = Column(Text, nullable=True) + + session = relationship("Session", back_populates="coaching_feedback") + user = relationship("User", back_populates="coaching_feedback") + + +class UserGoal(Base): + __tablename__ = "user_goals" + __table_args__ = ( + UniqueConstraint("user_id", "metric_name", name="uq_user_goals_user_metric"), + Index("ix_user_goals_user_active", "user_id", "is_active"), + ) + + id = Column(Integer, primary_key=True, index=True) + user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True) + metric_name = Column(String(50), nullable=False) + comparison = Column(String(10), nullable=False) + target_value = Column(Float, nullable=False) + target_upper = Column(Float, nullable=True) + is_active = Column(Boolean, default=True, nullable=False) + created_at = Column(DateTime, default=datetime.utcnow, nullable=False) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + + user = relationship("User", back_populates="goals") + + +class TranscriptSegment(Base): + __tablename__ = "transcript_segments" + + id = Column(Integer, primary_key=True, index=True) + session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False) + start_ms = Column(Integer, nullable=False) + end_ms = Column(Integer, nullable=False) + text = Column(Text, nullable=False) + + session = relationship("Session", back_populates="transcript_segments") + + +class MetricsTimeseries(Base): + __tablename__ = "metrics_timeseries" + + id = Column(Integer, primary_key=True, index=True) + session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False) + t = Column(Integer, nullable=False) + wpm = Column(Float, default=0) + rms = Column(Float, default=0) + eye_contact = Column(Float, default=0) + smile = Column(Float, default=0) + yaw = Column(Float, default=0) + pitch = Column(Float, default=0) + roll = Column(Float, default=0) + filler_count = Column(Integer, default=0) + pause_ms = Column(Integer, default=0) + + session = relationship("Session", back_populates="metrics") + + +class Score(Base): + __tablename__ = "scores" + + id = Column(Integer, primary_key=True, index=True) + session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False, unique=True) + data = Column(JSON, nullable=False) + + session = relationship("Session", back_populates="score") + + +class Tag(Base): + __tablename__ = "tags" + + id = Column(Integer, primary_key=True, index=True) + session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False) + name = Column(String(80), nullable=False) + + session = relationship("Session", back_populates="tags") diff --git a/apps/api/src/app/rate_limit.py b/apps/api/src/app/rate_limit.py new file mode 100644 index 0000000000000000000000000000000000000000..548c77d9acd03eef107e8a2a32028075645ca770 --- /dev/null +++ b/apps/api/src/app/rate_limit.py @@ -0,0 +1,27 @@ +import time +from collections import defaultdict, deque +from collections.abc import Callable + +from fastapi import HTTPException, Request, status + +WINDOW_SECONDS = 60 +_hits: dict[str, deque[float]] = defaultdict(deque) + + +def rate_limit(limit: int) -> Callable: + def dependency(request: Request) -> None: + client = request.client.host if request.client else "unknown" + key = f"{client}:{request.url.path}" + now = time.time() + window_start = now - WINDOW_SECONDS + bucket = _hits[key] + while bucket and bucket[0] < window_start: + bucket.popleft() + if len(bucket) >= limit: + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Rate limit exceeded. Try again shortly.", + ) + bucket.append(now) + + return dependency diff --git a/apps/api/src/app/routes/__init__.py b/apps/api/src/app/routes/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..f2cb0ff47c29521359c2a555b53de5f807d67fa6 --- /dev/null +++ b/apps/api/src/app/routes/__init__.py @@ -0,0 +1,3 @@ +from app.routes import auth, sessions, settings + +__all__ = ["auth", "sessions", "settings"] diff --git a/apps/api/src/app/routes/auth.py b/apps/api/src/app/routes/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..9c7cacf7e19ece703f139b53b4c681952c73caf6 --- /dev/null +++ b/apps/api/src/app/routes/auth.py @@ -0,0 +1,63 @@ +from fastapi import APIRouter, Depends, HTTPException, Response, status +from sqlalchemy.orm import Session + +from app import models, schemas +from app.auth import ( + authenticate_user, + get_current_user, + get_refresh_cookie, + hash_password, + issue_token_pair, + revoke_refresh_token, + rotate_refresh_token, +) +from app.db import get_db +from app.rate_limit import rate_limit + +router = APIRouter(prefix="/auth", tags=["auth"]) + + +@router.post("/signup", response_model=schemas.Token) +def signup(payload: schemas.UserCreate, response: Response, db: Session = Depends(get_db)): + if db.query(models.User.id).first() is None: + raise HTTPException(status_code=409, detail="Initial setup required") + existing = db.query(models.User).filter(models.User.email == payload.email).first() + if existing: + raise HTTPException(status_code=400, detail="Email already registered") + user = models.User(email=payload.email, hashed_password=hash_password(payload.password), is_admin=False) + db.add(user) + db.commit() + db.refresh(user) + return issue_token_pair(db, response, user) + + +@router.post("/login", response_model=schemas.Token, dependencies=[Depends(rate_limit(10))]) +def login(payload: schemas.UserLogin, response: Response, db: Session = Depends(get_db)): + user = authenticate_user(db, payload.email, payload.password) + if not user: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid email or password") + return issue_token_pair(db, response, user) + + +@router.post("/refresh", response_model=schemas.Token) +def refresh( + response: Response, + refresh_token: str | None = Depends(get_refresh_cookie), + db: Session = Depends(get_db), +): + return rotate_refresh_token(db, response, refresh_token) + + +@router.get("/me", response_model=schemas.UserOut) +def me(current_user: models.User = Depends(get_current_user)): + return current_user + + +@router.post("/logout") +def logout( + response: Response, + refresh_token: str | None = Depends(get_refresh_cookie), + db: Session = Depends(get_db), +): + revoke_refresh_token(db, response, refresh_token) + return {"status": "ok"} diff --git a/apps/api/src/app/routes/coaching.py b/apps/api/src/app/routes/coaching.py new file mode 100644 index 0000000000000000000000000000000000000000..9a999ced2c5d994e2000b3d35cae3502c3d676a6 --- /dev/null +++ b/apps/api/src/app/routes/coaching.py @@ -0,0 +1,188 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app import models +from app.auth import get_current_user +from app.db import get_db +from app.services.analytics import compute_session_metrics, get_active_goal_context, get_session_history +from app.services.coaching import generate_coaching_feedback +from app.worker.enqueue import enqueue_coaching +from app.worker.jobs import _snapshot_to_dict, _transcript_text + +router = APIRouter(prefix="/coaching", tags=["coaching"]) + + +def _generate_inline_coaching(db: Session, session: models.Session, user_id: int) -> models.CoachingFeedback: + feedback = ( + db.query(models.CoachingFeedback) + .filter(models.CoachingFeedback.session_id == session.id) + .first() + ) + if feedback is None: + feedback = models.CoachingFeedback(session_id=session.id, user_id=user_id, status="generating") + db.add(feedback) + db.commit() + db.refresh(feedback) + + snapshot = compute_session_metrics(db, session.id) + metrics = _snapshot_to_dict(snapshot, session) + transcript = _transcript_text(session) + history = get_session_history(db, user_id, limit=10) + active_goals = get_active_goal_context(db, user_id) + + try: + result = generate_coaching_feedback(transcript, metrics, history, None, active_goals) + feedback.status = "completed" + feedback.summary = result["summary"] + feedback.strengths = result["strengths"] + feedback.improvements = result["improvements"] + feedback.drills = result["drills"] + feedback.practice_plan = result["practice_plan"] + feedback.raw_response = result["raw_response"] + feedback.model_used = result["model_used"] + feedback.generation_time_ms = result["generation_time_ms"] + feedback.error_message = None + feedback.trend_context = { + "sessions_analyzed": len(history), + "progress_note": result.get("progress_note", ""), + } + except Exception as exc: + feedback.status = "failed" + feedback.error_message = str(exc)[:1000] + + db.commit() + db.refresh(feedback) + return feedback + + +@router.get("/session/{session_id}") +def get_session_coaching( + session_id: int, + auto: bool = Query(default=False), + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + feedback = ( + db.query(models.CoachingFeedback) + .filter( + models.CoachingFeedback.session_id == session_id, + models.CoachingFeedback.user_id == current_user.id, + ) + .first() + ) + if feedback is None and auto: + session = ( + db.query(models.Session) + .filter(models.Session.id == session_id, models.Session.user_id == current_user.id) + .first() + ) + if session is not None and (session.transcript_segments or session.duration_ms > 0): + feedback = _generate_inline_coaching(db, session, current_user.id) + + if feedback is None: + return {"status": "not_available"} + return _serialize_feedback(feedback) + + +@router.get("/latest") +def latest_coaching( + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + feedback = ( + db.query(models.CoachingFeedback) + .filter( + models.CoachingFeedback.user_id == current_user.id, + models.CoachingFeedback.status == "completed", + ) + .order_by(models.CoachingFeedback.created_at.desc()) + .first() + ) + if feedback is None: + return {"status": "not_available"} + return _serialize_feedback(feedback) + + +@router.get("/history") +def coaching_history( + limit: int = Query(default=10, ge=1, le=50), + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + rows = ( + db.query(models.CoachingFeedback) + .filter( + models.CoachingFeedback.user_id == current_user.id, + models.CoachingFeedback.status == "completed", + ) + .order_by(models.CoachingFeedback.created_at.desc()) + .limit(limit) + .all() + ) + return [_serialize_feedback(row, include_raw=False) for row in rows] + + +@router.post("/session/{session_id}/retry") +async def retry_session_coaching( + session_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + session = ( + db.query(models.Session) + .filter(models.Session.id == session_id, models.Session.user_id == current_user.id) + .first() + ) + if session is None: + raise HTTPException(status_code=404, detail="Session not found") + + feedback = ( + db.query(models.CoachingFeedback) + .filter(models.CoachingFeedback.session_id == session_id) + .first() + ) + if feedback is None: + feedback = models.CoachingFeedback( + session_id=session_id, + user_id=current_user.id, + status="pending", + ) + db.add(feedback) + feedback.user_id = current_user.id + feedback.status = "pending" + feedback.error_message = None + feedback.trend_context = None + db.commit() + + try: + job_id = await enqueue_coaching(session_id, current_user.id) + except Exception as exc: + feedback.status = "failed" + feedback.error_message = "Job backend unavailable" + db.commit() + raise HTTPException(status_code=503, detail="Job backend unavailable") from exc + + feedback.trend_context = {"job_id": job_id} + db.commit() + return {"status": "queued", "job_id": job_id} + + +def _serialize_feedback(feedback: models.CoachingFeedback, include_raw: bool = False) -> dict: + payload = { + "id": feedback.id, + "session_id": feedback.session_id, + "status": feedback.status, + "summary": feedback.summary, + "strengths": feedback.strengths or [], + "improvements": feedback.improvements or [], + "drills": feedback.drills or [], + "practice_plan": feedback.practice_plan or {}, + "trend_context": feedback.trend_context or {}, + "model_used": feedback.model_used, + "generation_time_ms": feedback.generation_time_ms, + "created_at": feedback.created_at.isoformat() if feedback.created_at else None, + "error_message": feedback.error_message if feedback.status == "failed" else None, + } + if include_raw: + payload["raw_response"] = feedback.raw_response + return payload diff --git a/apps/api/src/app/routes/curriculum.py b/apps/api/src/app/routes/curriculum.py new file mode 100644 index 0000000000000000000000000000000000000000..adccf5cdd340ed225a0119ea2500614810ed2504 --- /dev/null +++ b/apps/api/src/app/routes/curriculum.py @@ -0,0 +1,112 @@ +from datetime import date + +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session + +from app import models, schemas +from app.auth import get_current_user +from app.db import get_db +from app.services.analytics import prepare_user_analytics +from app.services.curriculum import ( + get_curriculum_plan, + get_or_create_profile, + get_personalized_exercises, + record_curriculum_completion, + serialize_profile, +) + +router = APIRouter(prefix="/curriculum", tags=["curriculum"]) + + +@router.get("/profile") +def read_presenter_profile( + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + profile = get_or_create_profile(db, current_user.id) + return serialize_profile(profile) + + +@router.put("/profile") +def update_presenter_profile( + payload: schemas.PresenterProfileUpdate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + profile = get_or_create_profile(db, current_user.id) + updates = payload.model_dump(exclude_unset=True) + reset_start = updates.pop("reset_start_date", False) + + wpm_min = updates.get("target_wpm_min", profile.target_wpm_min) + wpm_max = updates.get("target_wpm_max", profile.target_wpm_max) + if wpm_max <= wpm_min: + raise HTTPException(status_code=422, detail="target_wpm_max must be greater than target_wpm_min") + + for key, value in updates.items(): + if value is not None: + setattr(profile, key, value) + if reset_start: + profile.curriculum_start_date = date.today() + + db.commit() + db.refresh(profile) + return serialize_profile(profile) + + +@router.get("/plan") +def read_curriculum_plan( + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + prepare_user_analytics(db, current_user.id) + return get_curriculum_plan(db, current_user.id) + + +@router.post("/days/{day_number}/complete") +def complete_curriculum_day( + day_number: int, + payload: schemas.CurriculumCompleteRequest, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + if day_number < 1 or day_number > 30: + raise HTTPException(status_code=422, detail="Day number must be between 1 and 30") + + score_value = payload.score + if payload.session_id is not None: + linked_session = ( + db.query(models.Session) + .filter(models.Session.id == payload.session_id, models.Session.user_id == current_user.id) + .first() + ) + if linked_session is None: + raise HTTPException(status_code=404, detail="Linked session not found") + if score_value is None and linked_session.score and isinstance(linked_session.score.data, dict): + score_value = float(linked_session.score.data.get("total", 0) or 0) + + row = record_curriculum_completion( + db=db, + user_id=current_user.id, + day_number=day_number, + session_id=payload.session_id, + score=score_value, + reflection_notes=payload.reflection_notes, + ) + return { + "day_number": row.day_number, + "completed": True, + "completed_session_id": row.completed_session_id, + "best_score": row.best_score, + "attempts_count": row.attempts_count, + "reflection_notes": row.reflection_notes, + "completed_at": row.completed_at.isoformat() if row.completed_at else None, + } + + +@router.get("/exercises") +def read_exercises( + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + prepare_user_analytics(db, current_user.id) + return get_personalized_exercises(db, current_user.id) diff --git a/apps/api/src/app/routes/dashboard.py b/apps/api/src/app/routes/dashboard.py new file mode 100644 index 0000000000000000000000000000000000000000..07d0fe0b24f52c789a6c72e197243f3abf091be2 --- /dev/null +++ b/apps/api/src/app/routes/dashboard.py @@ -0,0 +1,203 @@ +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy.orm import Session + +from app import models, schemas +from app.auth import get_current_user +from app.db import get_db +from app.services.analytics import ( + compare_sessions, + get_activity_heatmap, + get_filler_breakdown, + get_goal_current_values, + get_overview_stats, + get_session_history, + get_trend_data, + get_user_goals, + get_weekly_review, + prepare_user_analytics, + save_weekly_reflection, + serialize_goal, +) + +router = APIRouter(prefix="/dashboard", tags=["dashboard"]) + + +def _validate_goal_values(comparison: str, target_value: float, target_upper: float | None) -> None: + if comparison == "between" and (target_upper is None or target_upper <= target_value): + raise HTTPException(status_code=422, detail="between goals require target_upper greater than target_value") + if comparison != "between" and target_upper is not None: + raise HTTPException(status_code=422, detail="target_upper is only valid for between goals") + + +@router.get("/overview") +def overview( + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + prepare_user_analytics(db, current_user.id) + return get_overview_stats(db, current_user.id) + + +@router.get("/weekly-review") +def weekly_review( + weeks_ago: int = Query(default=0, ge=0, le=12), + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + prepare_user_analytics(db, current_user.id) + return get_weekly_review(db, current_user.id, weeks_ago=weeks_ago) + + +@router.put("/weekly-review/note") +def update_weekly_note( + payload: schemas.WeeklyNoteUpdate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + return save_weekly_reflection( + db, + current_user.id, + payload.week_start_date, + payload.wins_note, + payload.focus_next_week, + ) + + +@router.get("/goals", response_model=list[schemas.UserGoalOut]) +def goals( + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + prepare_user_analytics(db, current_user.id) + return get_user_goals(db, current_user.id) + + +@router.post("/goals", response_model=schemas.UserGoalOut, status_code=201) +def create_goal( + payload: schemas.UserGoalCreate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + existing = ( + db.query(models.UserGoal) + .filter( + models.UserGoal.user_id == current_user.id, + models.UserGoal.metric_name == payload.metric_name, + ) + .first() + ) + if existing is not None: + raise HTTPException(status_code=409, detail="A goal for this metric already exists") + goal = models.UserGoal(user_id=current_user.id, **payload.model_dump()) + db.add(goal) + db.commit() + db.refresh(goal) + current = get_goal_current_values(db, current_user.id).get(goal.metric_name) + return serialize_goal(goal, current) + + +@router.patch("/goals/{goal_id}", response_model=schemas.UserGoalOut) +def update_goal( + goal_id: int, + payload: schemas.UserGoalUpdate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + goal = ( + db.query(models.UserGoal) + .filter(models.UserGoal.id == goal_id, models.UserGoal.user_id == current_user.id) + .first() + ) + if goal is None: + raise HTTPException(status_code=404, detail="Goal not found") + updates = payload.model_dump(exclude_unset=True) + comparison = updates.get("comparison", goal.comparison) + target_value = updates.get("target_value", goal.target_value) + target_upper = updates.get("target_upper", goal.target_upper) + if comparison != "between" and "comparison" in updates and "target_upper" not in updates: + target_upper = None + updates["target_upper"] = None + _validate_goal_values(comparison, target_value, target_upper) + for key, value in updates.items(): + setattr(goal, key, value) + db.commit() + db.refresh(goal) + current = get_goal_current_values(db, current_user.id).get(goal.metric_name) + return serialize_goal(goal, current) + + +@router.delete("/goals/{goal_id}") +def delete_goal( + goal_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + goal = ( + db.query(models.UserGoal) + .filter(models.UserGoal.id == goal_id, models.UserGoal.user_id == current_user.id) + .first() + ) + if goal is None: + raise HTTPException(status_code=404, detail="Goal not found") + db.delete(goal) + db.commit() + return {"status": "deleted"} + + +@router.get("/trends/{metric}") +def trends( + metric: str, + days: int = Query(default=90, ge=7, le=365), + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + prepare_user_analytics(db, current_user.id) + try: + return get_trend_data(db, current_user.id, metric, days) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + + +@router.get("/sessions") +def sessions( + limit: int = Query(default=20, ge=1, le=100), + offset: int = Query(default=0, ge=0), + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + prepare_user_analytics(db, current_user.id) + return get_session_history(db, current_user.id, limit, offset) + + +@router.get("/heatmap") +def heatmap( + days: int = Query(default=365, ge=30, le=365), + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + prepare_user_analytics(db, current_user.id) + return get_activity_heatmap(db, current_user.id, days) + + +@router.get("/fillers") +def fillers( + last_n: int = Query(default=20, ge=5, le=100), + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + prepare_user_analytics(db, current_user.id) + return get_filler_breakdown(db, current_user.id, last_n) + + +@router.get("/compare") +def compare( + session_a: int = Query(...), + session_b: int = Query(...), + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + prepare_user_analytics(db, current_user.id) + try: + return compare_sessions(db, current_user.id, session_a, session_b) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc diff --git a/apps/api/src/app/routes/health.py b/apps/api/src/app/routes/health.py new file mode 100644 index 0000000000000000000000000000000000000000..74eafdfa0fbb1f20a2bea74e0ba051cbef886c7f --- /dev/null +++ b/apps/api/src/app/routes/health.py @@ -0,0 +1,37 @@ +from fastapi import APIRouter, Depends +from sqlalchemy import text +from sqlalchemy.orm import Session + +from app.config import settings +from app.db import get_db + +router = APIRouter(tags=["health"]) + + +@router.get("/health") +def health(): + return {"status": "ok"} + + +@router.get("/health/ready") +def readiness(db: Session = Depends(get_db)): + checks: dict[str, str] = {} + try: + db.execute(text("SELECT 1")) + checks["database"] = "ok" + except Exception as exc: + checks["database"] = f"error: {type(exc).__name__}" + + try: + import redis + + client = redis.Redis(host=settings.redis_host, port=settings.redis_port, socket_connect_timeout=1) + client.ping() + checks["redis"] = "ok" + except Exception as exc: + checks["redis"] = f"error: {type(exc).__name__}" + + return { + "status": "ok" if all(value == "ok" for value in checks.values()) else "degraded", + "checks": checks, + } diff --git a/apps/api/src/app/routes/jobs.py b/apps/api/src/app/routes/jobs.py new file mode 100644 index 0000000000000000000000000000000000000000..0a2a13b3b22c0ae9ddab306ba3293a4c1d1465ea --- /dev/null +++ b/apps/api/src/app/routes/jobs.py @@ -0,0 +1,14 @@ +from fastapi import APIRouter, Depends, HTTPException + +from app.auth import get_current_user +from app.worker.enqueue import get_job_status + +router = APIRouter(prefix="/jobs", tags=["jobs"]) + + +@router.get("/{job_id}") +async def job_status(job_id: str, _user=Depends(get_current_user)): + try: + return await get_job_status(job_id) + except Exception as exc: + raise HTTPException(status_code=503, detail="Job backend unavailable") from exc diff --git a/apps/api/src/app/routes/news.py b/apps/api/src/app/routes/news.py new file mode 100644 index 0000000000000000000000000000000000000000..42e63447499efd3870e81e9fbb67c303b547947b --- /dev/null +++ b/apps/api/src/app/routes/news.py @@ -0,0 +1,32 @@ +from fastapi import APIRouter, Depends, Query + +from app import models, schemas +from app.auth import get_current_user +from app.services.tech_news import build_storyboard, fetch_tech_news_feed + +router = APIRouter(prefix="/news", tags=["news"]) + + +@router.get("/feed") +def get_news_feed( + category: str | None = Query(default=None), + refresh: bool = Query(default=False), + _current_user: models.User = Depends(get_current_user), +): + return fetch_tech_news_feed(category=category, force_refresh=refresh) + + +@router.post("/storyboard") +def create_storyboard( + payload: schemas.StoryboardRequest, + _current_user: models.User = Depends(get_current_user), +): + return build_storyboard( + headline=payload.headline, + summary=payload.summary, + source=payload.source, + url=payload.url, + category=payload.category, + framework=payload.framework, + personal_angle=payload.personal_angle, + ) diff --git a/apps/api/src/app/routes/sessions.py b/apps/api/src/app/routes/sessions.py new file mode 100644 index 0000000000000000000000000000000000000000..b48199151dd9781d0ccaff8cd0ebd131c2e45b72 --- /dev/null +++ b/apps/api/src/app/routes/sessions.py @@ -0,0 +1,987 @@ +import json +import os +import re +import shutil +import tempfile +import textwrap +import zipfile +from pathlib import Path +from typing import Optional + +from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile +from fastapi.responses import FileResponse, Response +from sqlalchemy.orm import Session + +from app import models, schemas, transcription +from app.auth import get_current_user +from app.config import settings +from app.db import SessionLocal, get_db +from app.scoring import compute_score, compute_wpm, count_fillers, count_words +from app.services.analytics import compute_session_metrics +from app.services.curriculum import record_curriculum_completion +from app.storage import storage_client +from app.worker.enqueue import enqueue_transcription + +router = APIRouter(prefix="/sessions", tags=["sessions"]) + + +def _resolve_video_format(value: Optional[str]) -> tuple[str, str]: + if not value: + return "webm", "video/webm" + lower = value.lower() + if "audio/" in lower: + if "wav" in lower: + return "wav", "audio/wav" + if "mp4" in lower or "m4a" in lower or "aac" in lower: + return "mp4", "audio/mp4" + return "webm", "audio/webm" + if "video/" in lower: + if "mp4" in lower: + return "mp4", "video/mp4" + if "quicktime" in lower: + return "mp4", "video/quicktime" + if "webm" in lower: + return "webm", "video/webm" + return "webm", "video/webm" + suffix = Path(lower).suffix + if suffix == ".mp4": + return "mp4", "video/mp4" + if suffix == ".m4a": + return "mp4", "audio/mp4" + if suffix == ".wav": + return "wav", "audio/wav" + if suffix == ".mov": + return "mp4", "video/quicktime" + if suffix == ".webm": + return "webm", "video/webm" + return "webm", "video/webm" + + +def _detect_media_type(video_path: str, fallback: str) -> str: + try: + with open(video_path, "rb") as video_file: + header = video_file.read(16) + if len(header) >= 8 and header[4:8] == b"ftyp": + return "audio/mp4" if fallback.startswith("audio/") else "video/mp4" + if header.startswith(b"\x1a\x45\xdf\xa3"): + return "audio/webm" if fallback.startswith("audio/") else "video/webm" + if len(header) >= 12 and header[:4] == b"RIFF" and header[8:12] == b"WAVE": + return "audio/wav" + except OSError: + return fallback + return fallback + + +def _file_size_and_header(file_obj, header_size: int = 16) -> tuple[int, bytes]: + current = file_obj.tell() + file_obj.seek(0, os.SEEK_END) + size = file_obj.tell() + file_obj.seek(0) + header = file_obj.read(header_size) + file_obj.seek(current) + return size, header + + +def _validate_size(size: int, label: str) -> None: + if size > settings.max_upload_bytes: + raise HTTPException( + status_code=413, + detail=f"{label} exceeds the {settings.max_upload_mb} MB upload limit.", + ) + + +def _validate_video_header(header: bytes, media_type: str) -> None: + if media_type in {"video/mp4", "video/quicktime", "audio/mp4"}: + if len(header) >= 8 and header[4:8] == b"ftyp": + return + if media_type in {"video/webm", "audio/webm"} and header.startswith(b"\x1a\x45\xdf\xa3"): + return + if media_type == "audio/wav" and len(header) >= 12 and header[:4] == b"RIFF" and header[8:12] == b"WAVE": + return + raise HTTPException(status_code=415, detail="Unsupported or invalid video file.") + + +def _validate_video_upload(file: UploadFile, media_type: str) -> None: + size, header = _file_size_and_header(file.file) + _validate_size(size, "Video") + _validate_video_header(header, media_type) + file.file.seek(0) + + +def _validate_video_path(path: Path, media_type: str) -> None: + size = path.stat().st_size + _validate_size(size, "Video") + with open(path, "rb") as merged: + header = merged.read(16) + _validate_video_header(header, media_type) + + +def _validate_document_upload(file: UploadFile, extension: str, mime_type: str) -> None: + size, header = _file_size_and_header(file.file) + _validate_size(size, "Document") + if extension == ".pdf" and not header.startswith(b"%PDF-"): + raise HTTPException(status_code=415, detail="Invalid PDF file.") + if mime_type == "text/plain" and b"\x00" in header: + raise HTTPException(status_code=415, detail="Invalid text file.") + file.file.seek(0) + + +def _serialize_transcript_segments( + transcript_segments: list[models.TranscriptSegment], +) -> list[schemas.TranscriptSegmentOut]: + return [ + schemas.TranscriptSegmentOut( + id=segment.id, + start_ms=segment.start_ms, + end_ms=segment.end_ms, + text=segment.text, + ) + for segment in transcript_segments + ] + + +def _deserialize_document_blocks(raw_value: str | None) -> list[schemas.DocumentBlockOut]: + if not raw_value: + return [] + try: + payload = json.loads(raw_value) + except json.JSONDecodeError: + return [] + + if not isinstance(payload, list): + return [] + + blocks: list[schemas.DocumentBlockOut] = [] + for index, item in enumerate(payload): + text = item.get("text") if isinstance(item, dict) else item if isinstance(item, str) else "" + if not isinstance(text, str) or not text.strip(): + continue + blocks.append(schemas.DocumentBlockOut(index=index, text=text.strip())) + return blocks + + +def _serialize_document(session: models.Session) -> schemas.SessionDocumentOut | None: + blocks = _deserialize_document_blocks(session.document_blocks_json) + if not session.document_name or not session.document_mime_type or not blocks: + return None + return schemas.SessionDocumentOut( + name=session.document_name, + mime_type=session.document_mime_type, + blocks=blocks, + ) + + +def _serialize_metrics(metrics: list[models.MetricsTimeseries]) -> list[schemas.MetricsPointCreate]: + return [ + schemas.MetricsPointCreate( + t=point.t, + wpm=point.wpm, + rms=point.rms, + eye_contact=point.eye_contact, + smile=point.smile, + yaw=point.yaw, + pitch=point.pitch, + roll=point.roll, + filler_count=point.filler_count, + pause_ms=point.pause_ms, + ) + for point in metrics + ] + + +def _parse_keywords(raw: str | None) -> list[str]: + if not raw: + return [] + try: + parsed = json.loads(raw) + if isinstance(parsed, list): + return [str(k) for k in parsed if str(k).strip()] + except json.JSONDecodeError: + pass + return [] + + +def _safe_filename(value: str) -> str: + cleaned = re.sub(r"[^a-zA-Z0-9_-]+", "_", value).strip("_") + return cleaned or "session" + + +def _session_transcript_filename(session: models.Session, extension: str) -> str: + title = session.title or session.mode or f"session_{session.id}" + return f"{_safe_filename(title)}_transcript.{extension}" + + +def _format_timestamp(ms: int) -> str: + total_seconds = max(ms, 0) // 1000 + minutes, seconds = divmod(total_seconds, 60) + hours, minutes = divmod(minutes, 60) + if hours: + return f"{hours:02d}:{minutes:02d}:{seconds:02d}" + return f"{minutes:02d}:{seconds:02d}" + + +def _build_transcript_text(session: models.Session) -> str: + lines = [ + session.title or session.mode or f"Session {session.id}", + f"Duration: {_format_timestamp(session.duration_ms)}", + "", + ] + for segment in session.transcript_segments: + lines.append(f"[{_format_timestamp(segment.start_ms)}] {segment.text}") + return "\n".join(lines).strip() + "\n" + + +def _escape_pdf_text(text: str) -> str: + return text.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)") + + +def _pdf_stream(lines: list[str]) -> bytes: + commands = ["BT", "/F1 11 Tf", "14 TL", "72 770 Td"] + first_line = True + for line in lines: + wrapped_lines = textwrap.wrap( + line, + width=88, + replace_whitespace=False, + drop_whitespace=False, + ) or [""] + for wrapped in wrapped_lines: + if first_line: + commands.append(f"({_escape_pdf_text(wrapped)}) Tj") + first_line = False + else: + commands.append("T*") + commands.append(f"({_escape_pdf_text(wrapped)}) Tj") + commands.append("ET") + return "\n".join(commands).encode("latin-1", errors="replace") + + +def _build_transcript_pdf(session: models.Session) -> bytes: + transcript_lines = _build_transcript_text(session).splitlines() + lines_per_page = 48 + pages = [ + transcript_lines[index:index + lines_per_page] + for index in range(0, len(transcript_lines), lines_per_page) + ] or [["Transcript unavailable."]] + + objects: list[bytes] = [] + page_object_numbers: list[int] = [] + current_object = 4 + + for page_lines in pages: + page_object_numbers.append(current_object) + current_object += 2 + + objects.append(b"<< /Type /Catalog /Pages 2 0 R >>") + kids = " ".join(f"{number} 0 R" for number in page_object_numbers) + objects.append(f"<< /Type /Pages /Kids [{kids}] /Count {len(page_object_numbers)} >>".encode("latin-1")) + objects.append(b"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>") + + for index, page_lines in enumerate(pages): + page_object_number = page_object_numbers[index] + content_object_number = page_object_number + 1 + objects.append( + ( + f"<< /Type /Page /Parent 2 0 R /MediaBox [0 0 612 792] " + f"/Resources << /Font << /F1 3 0 R >> >> /Contents {content_object_number} 0 R >>" + ).encode("latin-1") + ) + content_bytes = _pdf_stream(page_lines) + objects.append( + b"<< /Length " + + str(len(content_bytes)).encode("latin-1") + + b" >>\nstream\n" + + content_bytes + + b"\nendstream" + ) + + pdf = bytearray(b"%PDF-1.4\n%\xe2\xe3\xcf\xd3\n") + offsets = [0] + for object_number, body in enumerate(objects, start=1): + offsets.append(len(pdf)) + pdf.extend(f"{object_number} 0 obj\n".encode("latin-1")) + pdf.extend(body) + pdf.extend(b"\nendobj\n") + + xref_offset = len(pdf) + pdf.extend(f"xref\n0 {len(objects) + 1}\n".encode("latin-1")) + pdf.extend(b"0000000000 65535 f \n") + for offset in offsets[1:]: + pdf.extend(f"{offset:010d} 00000 n \n".encode("latin-1")) + pdf.extend( + ( + f"trailer\n<< /Size {len(objects) + 1} /Root 1 0 R >>\n" + f"startxref\n{xref_offset}\n%%EOF\n" + ).encode("latin-1") + ) + return bytes(pdf) + + +def _raise_if_transcript_missing(session: models.Session) -> None: + if not session.transcript_segments: + raise HTTPException(status_code=400, detail="Transcript not available yet") + + +def _status_for_missing_video(session: models.Session) -> str: + if session.transcript_segments: + return "complete" + return "idle" + + +def _resolve_document_format(filename: str | None, content_type: str | None) -> tuple[str, str]: + suffix = Path(filename or "").suffix.lower() + normalized_content_type = (content_type or "").split(";")[0].strip().lower() + if suffix == ".pdf" or normalized_content_type == "application/pdf": + return ".pdf", "application/pdf" + if suffix == ".txt" or normalized_content_type == "text/plain": + return ".txt", "text/plain" + raise HTTPException(status_code=400, detail="Unsupported document type. Upload a PDF or TXT file.") + + +def _normalize_document_blocks_payload(raw_value: str) -> str: + try: + payload = json.loads(raw_value) + except json.JSONDecodeError as exc: + raise HTTPException(status_code=400, detail="Document blocks payload must be valid JSON.") from exc + + if not isinstance(payload, list): + raise HTTPException(status_code=400, detail="Document blocks payload must be a list.") + + blocks = [] + for item in payload: + text = item.get("text") if isinstance(item, dict) else item if isinstance(item, str) else "" + if not isinstance(text, str) or not text.strip(): + continue + blocks.append({"index": len(blocks), "text": text.strip()}) + + if not blocks: + raise HTTPException(status_code=400, detail="Document blocks payload is empty.") + + return json.dumps(blocks, ensure_ascii=False) + + +def _delete_session_assets(session: models.Session) -> None: + if session.video_path: + storage_client.delete_file(session.video_path) + if session.document_path: + storage_client.delete_file(session.document_path) + + +@router.post("", response_model=schemas.SessionOut) +def create_session( + payload: schemas.SessionCreate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + clean_keywords = [str(k).strip() for k in payload.target_keywords if str(k).strip()] + seg_dicts = [seg.model_dump() for seg in payload.transcript_segments] + metric_dicts = [point.model_dump() for point in payload.metrics] + + computed_wpm = payload.wpm_avg + computed_fillers = payload.filler_count + if seg_dicts: + words = count_words(seg_dicts) + if not computed_wpm and payload.duration_ms > 0: + computed_wpm = compute_wpm(words, payload.duration_ms) + if not computed_fillers: + computed_fillers = count_fillers(seg_dicts) + + session = models.Session( + user_id=current_user.id, + title=payload.title, + mode=payload.mode, + prompt=payload.prompt, + goal=payload.goal, + curriculum_day=payload.curriculum_day, + exercise_id=payload.exercise_id, + story_framework=payload.story_framework, + target_keywords_json=json.dumps(clean_keywords, ensure_ascii=False) if clean_keywords else None, + teleprompter_script=payload.teleprompter_script, + started_at=payload.started_at, + ended_at=payload.ended_at, + duration_ms=payload.duration_ms, + wpm_avg=computed_wpm, + filler_count=computed_fillers, + eye_contact_pct=payload.eye_contact_pct, + transcription_status="complete" if seg_dicts else "idle", + ) + db.add(session) + db.flush() + + for tag in payload.tags: + db.add(models.Tag(session_id=session.id, name=tag)) + + for seg in payload.transcript_segments: + db.add( + models.TranscriptSegment( + session_id=session.id, + start_ms=seg.start_ms, + end_ms=seg.end_ms, + text=seg.text, + ) + ) + + for point in payload.metrics: + db.add( + models.MetricsTimeseries( + session_id=session.id, + t=point.t, + wpm=point.wpm, + rms=point.rms, + eye_contact=point.eye_contact, + smile=point.smile, + yaw=point.yaw, + pitch=point.pitch, + roll=point.roll, + filler_count=point.filler_count, + pause_ms=point.pause_ms, + ) + ) + + score_payload = payload.score + if score_payload is None: + score_payload = compute_score( + seg_dicts, + metric_dicts, + payload.duration_ms, + clean_keywords, + ) + db.add(models.Score(session_id=session.id, data=score_payload)) + + db.commit() + db.refresh(session) + snapshot = compute_session_metrics(db, session.id) + + if payload.curriculum_day and 1 <= payload.curriculum_day <= 30: + record_curriculum_completion( + db=db, + user_id=current_user.id, + day_number=payload.curriculum_day, + session_id=session.id, + score=snapshot.overall_score, + ) + + db.refresh(session) + return _session_to_out(session) + + +@router.patch("/{session_id}/transcript", response_model=schemas.SessionOut) +def update_session_transcript( + session_id: int, + payload: schemas.TranscriptUpdateRequest, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + session = _get_session_or_404(db, current_user.id, session_id) + session.transcript_segments.clear() + db.flush() + + seg_dicts = [] + for seg in payload.transcript_segments: + text = seg.text.strip() + if not text: + continue + db.add( + models.TranscriptSegment( + session_id=session.id, + start_ms=max(0, seg.start_ms), + end_ms=max(seg.start_ms, seg.end_ms), + text=text, + ) + ) + seg_dicts.append({"start_ms": max(0, seg.start_ms), "end_ms": max(seg.start_ms, seg.end_ms), "text": text}) + db.flush() + + metric_dicts = [ + { + "t": point.t, + "wpm": point.wpm, + "rms": point.rms, + "eye_contact": point.eye_contact, + "smile": point.smile, + "yaw": point.yaw, + "pitch": point.pitch, + "roll": point.roll, + "filler_count": point.filler_count, + "pause_ms": point.pause_ms, + } + for point in session.metrics + ] + words = count_words(seg_dicts) + if session.duration_ms > 0: + session.wpm_avg = compute_wpm(words, session.duration_ms) + session.filler_count = count_fillers(seg_dicts) + session.transcription_status = "complete" if seg_dicts else _status_for_missing_video(session) + + score_payload = compute_score( + seg_dicts, + metric_dicts, + session.duration_ms, + _parse_keywords(session.target_keywords_json), + ) + if session.score: + session.score.data = score_payload + else: + db.add(models.Score(session_id=session.id, data=score_payload)) + + # Invalidate stale coaching so next fetch regenerates with updated transcript + existing_coaching = ( + db.query(models.CoachingFeedback) + .filter(models.CoachingFeedback.session_id == session.id) + .first() + ) + if existing_coaching: + db.delete(existing_coaching) + + db.commit() + snapshot = compute_session_metrics(db, session.id) + if session.curriculum_day and 1 <= session.curriculum_day <= 30: + record_curriculum_completion( + db=db, + user_id=current_user.id, + day_number=session.curriculum_day, + session_id=session.id, + score=snapshot.overall_score, + ) + db.refresh(session) + return _session_to_out(session) + + +@router.get("", response_model=list[schemas.SessionListItem]) +def list_sessions( + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + sessions = ( + db.query(models.Session) + .filter(models.Session.user_id == current_user.id) + .order_by(models.Session.created_at.desc()) + .all() + ) + items = [] + for sess in sessions: + score_data = sess.score.data if sess.score else None + items.append( + schemas.SessionListItem( + id=sess.id, + title=sess.title, + mode=sess.mode, + curriculum_day=sess.curriculum_day, + duration_ms=sess.duration_ms, + wpm_avg=sess.wpm_avg, + filler_count=sess.filler_count, + eye_contact_pct=sess.eye_contact_pct, + created_at=sess.created_at, + transcription_status=sess.transcription_status, + video_available=bool(sess.video_path), + score=score_data, + ) + ) + return items + + +@router.get("/{session_id}", response_model=schemas.SessionOut) +def get_session( + session_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + session = _get_session_or_404(db, current_user.id, session_id) + return _session_to_out(session) + + +@router.post("/{session_id}/upload", response_model=schemas.UploadResponse) +def upload_video( + session_id: int, + file: UploadFile = File(...), + chunk_index: Optional[int] = None, + total_chunks: Optional[int] = None, + upload_id: Optional[str] = None, + mime_type: Optional[str] = None, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + session = _get_session_or_404(db, current_user.id, session_id) + extension, resolved_mime = _resolve_video_format(mime_type or file.content_type or file.filename) + + if chunk_index is not None and total_chunks is not None: + if not upload_id: + raise HTTPException(status_code=400, detail="upload_id required") + chunk_size, _header = _file_size_and_header(file.file) + _validate_size(chunk_size, "Video chunk") + file.file.seek(0) + tmp_dir = Path(settings.storage_dir) / "tmp" / upload_id + tmp_dir.mkdir(parents=True, exist_ok=True) + chunk_path = tmp_dir / f"{chunk_index}.part" + with open(chunk_path, "wb") as out: + shutil.copyfileobj(file.file, out) + + if chunk_index == total_chunks - 1: + merged_path = tmp_dir / f"merged.{extension}" + with open(merged_path, "wb") as merged: + for idx in range(total_chunks): + part_path = tmp_dir / f"{idx}.part" + if not part_path.exists(): + raise HTTPException(status_code=400, detail="missing chunk") + with open(part_path, "rb") as part: + shutil.copyfileobj(part, merged) + _validate_video_path(merged_path, resolved_mime) + key = f"sessions/{session_id}/recording.{extension}" + with open(merged_path, "rb") as merged_file: + stored_path = storage_client.save_file(key, merged_file) + session.video_path = stored_path + db.commit() + shutil.rmtree(tmp_dir) + return {"session_id": session_id, "status": "complete", "video_path": stored_path} + + return {"session_id": session_id, "status": "partial"} + + _validate_video_upload(file, resolved_mime) + key = f"sessions/{session_id}/recording.{extension}" + stored_path = storage_client.save_file(key, file.file) + session.video_path = stored_path + db.commit() + return {"session_id": session_id, "status": "complete", "video_path": stored_path} + + +@router.post("/{session_id}/document", response_model=schemas.SessionDocumentOut) +def upload_document( + session_id: int, + file: UploadFile = File(...), + blocks_json: str = Form(...), + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + session = _get_session_or_404(db, current_user.id, session_id) + extension, mime_type = _resolve_document_format(file.filename, file.content_type) + _validate_document_upload(file, extension, mime_type) + normalized_blocks_json = _normalize_document_blocks_payload(blocks_json) + + if session.document_path: + storage_client.delete_file(session.document_path) + + key = f"sessions/{session_id}/document{extension}" + stored_path = storage_client.save_file(key, file.file) + session.document_path = stored_path + session.document_name = file.filename or f"document{extension}" + session.document_mime_type = mime_type + session.document_blocks_json = normalized_blocks_json + db.commit() + db.refresh(session) + + document = _serialize_document(session) + if document is None: + raise HTTPException(status_code=500, detail="Document was saved but could not be serialized.") + return document + + +@router.post("/{session_id}/transcribe") +async def transcribe_session( + session_id: int, + payload: schemas.TranscriptionRequest | None = None, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + session = _get_session_or_404(db, current_user.id, session_id) + if not session.video_path: + raise HTTPException(status_code=400, detail="No video uploaded") + try: + selected_backend = transcription.resolve_transcription_backend( + payload.backend if payload else None, + payload.model if payload else None, + ) + selected_model = transcription.resolve_transcription_model( + payload.model if payload else None, + selected_backend, + ) + transcription.ensure_transcription_model_ready(selected_backend, selected_model) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + session.transcription_status = "queued" + db.commit() + + try: + job_id = await enqueue_transcription(session_id, current_user.id, selected_model, selected_backend) + except Exception as exc: + session.transcription_status = "idle" + db.commit() + raise HTTPException(status_code=503, detail="Job backend unavailable") from exc + return {"status": "queued", "job_id": job_id} + + +@router.delete("/{session_id}") +def delete_session( + session_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + session = _get_session_or_404(db, current_user.id, session_id) + _delete_session_assets(session) + db.delete(session) + db.commit() + return {"status": "deleted"} + + +@router.delete("/{session_id}/video") +def delete_video( + session_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + session = _get_session_or_404(db, current_user.id, session_id) + if session.video_path: + storage_client.delete_file(session.video_path) + session.video_path = None + session.transcription_status = _status_for_missing_video(session) + db.commit() + return {"status": "deleted", "transcription_status": session.transcription_status} + + +@router.get("/{session_id}/export") +def export_session( + session_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + session = _get_session_or_404(db, current_user.id, session_id) + tmp_dir = tempfile.mkdtemp() + zip_path = os.path.join(tmp_dir, f"session_{session_id}.zip") + transcript_text = _build_transcript_text(session) + transcript_pdf = _build_transcript_pdf(session) + + transcript_payload = [ + {"start_ms": segment.start_ms, "end_ms": segment.end_ms, "text": segment.text} + for segment in session.transcript_segments + ] + metrics_payload = [ + { + "t": point.t, + "wpm": point.wpm, + "rms": point.rms, + "eye_contact": point.eye_contact, + "smile": point.smile, + "yaw": point.yaw, + "pitch": point.pitch, + "roll": point.roll, + "filler_count": point.filler_count, + "pause_ms": point.pause_ms, + } + for point in session.metrics + ] + score_payload = session.score.data if session.score else {} + + with zipfile.ZipFile(zip_path, "w") as zipf: + zipf.writestr("transcript.json", json.dumps(transcript_payload, indent=2)) + zipf.writestr("transcript.txt", transcript_text) + zipf.writestr("transcript.pdf", transcript_pdf) + zipf.writestr("metrics.json", json.dumps(metrics_payload, indent=2)) + zipf.writestr("score.json", json.dumps(score_payload, indent=2)) + if session.document_blocks_json: + zipf.writestr("document_blocks.json", session.document_blocks_json) + if session.video_path: + try: + video_path = storage_client.get_local_path(session.video_path) + except RuntimeError: + video_path = None + if video_path and os.path.exists(video_path): + video_name = f"recording{Path(video_path).suffix or '.webm'}" + zipf.write(video_path, arcname=video_name) + if session.document_path: + try: + document_path = storage_client.get_local_path(session.document_path) + except RuntimeError: + document_path = None + if document_path and os.path.exists(document_path): + document_name = session.document_name or f"reading{Path(document_path).suffix or '.txt'}" + zipf.write(document_path, arcname=document_name) + + return FileResponse(zip_path, filename=f"session_{session_id}.zip") + + +@router.get("/{session_id}/transcript.txt") +def download_transcript_txt( + session_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + session = _get_session_or_404(db, current_user.id, session_id) + _raise_if_transcript_missing(session) + filename = _session_transcript_filename(session, "txt") + return Response( + content=_build_transcript_text(session), + media_type="text/plain; charset=utf-8", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.get("/{session_id}/transcript.pdf") +def download_transcript_pdf( + session_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + session = _get_session_or_404(db, current_user.id, session_id) + _raise_if_transcript_missing(session) + filename = _session_transcript_filename(session, "pdf") + return Response( + content=_build_transcript_pdf(session), + media_type="application/pdf", + headers={"Content-Disposition": f'attachment; filename="{filename}"'}, + ) + + +@router.get("/{session_id}/video") +def get_video( + session_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user), +): + session = _get_session_or_404(db, current_user.id, session_id) + if not session.video_path: + raise HTTPException(status_code=404, detail="Video not found") + try: + video_path = storage_client.get_local_path(session.video_path) + except RuntimeError as exc: + raise HTTPException(status_code=501, detail="Video download unavailable") from exc + if not os.path.exists(video_path): + raise HTTPException(status_code=404, detail="Video not found") + _extension, media_type = _resolve_video_format(video_path) + media_type = _detect_media_type(video_path, media_type) + return FileResponse(video_path, media_type=media_type) + + +def _get_session_or_404(db: Session, user_id: int, session_id: int) -> models.Session: + session = ( + db.query(models.Session) + .filter(models.Session.id == session_id, models.Session.user_id == user_id) + .first() + ) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + return session + + +def _run_transcription( + session_id: int, + user_id: int, + model_name: str | None = None, + backend_id: str | None = None, +) -> None: + db = SessionLocal() + session: models.Session | None = None + audio_path: str | None = None + try: + session = ( + db.query(models.Session) + .filter(models.Session.id == session_id, models.Session.user_id == user_id) + .first() + ) + if not session or not session.video_path: + return + session.transcription_status = "processing" + db.commit() + video_path = storage_client.get_local_path(session.video_path) + audio_path = transcription.extract_audio(video_path) + segments = transcription.transcribe_audio(audio_path, model_name, backend_id) + session.transcript_segments.clear() + db.flush() + for seg in segments: + db.add( + models.TranscriptSegment( + session_id=session.id, + start_ms=seg["start_ms"], + end_ms=seg["end_ms"], + text=seg["text"], + ) + ) + db.flush() + words = count_words(segments) + if session.duration_ms > 0: + session.wpm_avg = compute_wpm(words, session.duration_ms) + session.filler_count = count_fillers(segments) + score_payload = compute_score( + segments, + [ + { + "t": point.t, + "wpm": point.wpm, + "rms": point.rms, + "eye_contact": point.eye_contact, + "smile": point.smile, + "yaw": point.yaw, + "pitch": point.pitch, + "roll": point.roll, + "filler_count": point.filler_count, + "pause_ms": point.pause_ms, + } + for point in session.metrics + ], + session.duration_ms, + _parse_keywords(session.target_keywords_json), + ) + if session.score: + session.score.data = score_payload + else: + db.add(models.Score(session_id=session.id, data=score_payload)) + session.transcription_status = "complete" + db.commit() + snapshot = compute_session_metrics(db, session.id) + if session.curriculum_day and 1 <= session.curriculum_day <= 30: + record_curriculum_completion( + db=db, + user_id=user_id, + day_number=session.curriculum_day, + session_id=session.id, + score=snapshot.overall_score, + ) + except Exception: + if session is None and session_id: + session = db.query(models.Session).filter(models.Session.id == session_id).first() + if session: + if session.video_path: + session.transcription_status = "failed" + else: + session.transcription_status = _status_for_missing_video(session) + db.commit() + finally: + if audio_path: + try: + audio_file = Path(audio_path) + if audio_file.exists(): + audio_file.unlink() + if audio_file.parent.exists(): + audio_file.parent.rmdir() + except OSError: + pass + db.close() + + +def _session_to_out(session: models.Session) -> schemas.SessionOut: + tags = [tag.name for tag in session.tags] + score_data = session.score.data if session.score else None + return schemas.SessionOut( + id=session.id, + title=session.title, + mode=session.mode, + prompt=session.prompt, + goal=session.goal, + curriculum_day=session.curriculum_day, + exercise_id=session.exercise_id, + story_framework=session.story_framework, + target_keywords=_parse_keywords(session.target_keywords_json), + teleprompter_script=session.teleprompter_script, + started_at=session.started_at, + ended_at=session.ended_at, + duration_ms=session.duration_ms, + wpm_avg=session.wpm_avg, + filler_count=session.filler_count, + eye_contact_pct=session.eye_contact_pct, + video_path=session.video_path, + transcription_status=session.transcription_status, + created_at=session.created_at, + tags=tags, + transcript_segments=_serialize_transcript_segments(session.transcript_segments), + metrics=_serialize_metrics(session.metrics), + score=score_data, + document=_serialize_document(session), + ) diff --git a/apps/api/src/app/routes/settings.py b/apps/api/src/app/routes/settings.py new file mode 100644 index 0000000000000000000000000000000000000000..81060b1d6a4a8f3e1f8aab37e5a671640ea42465 --- /dev/null +++ b/apps/api/src/app/routes/settings.py @@ -0,0 +1,41 @@ +from fastapi import APIRouter, Depends +from fastapi import HTTPException + +from app import models, schemas, transcription +from app.auth import get_current_user +from app.local_inference import check_engine, get_all_engine_statuses + +router = APIRouter(prefix="/settings", tags=["settings"]) + + +@router.get("/transcription", response_model=schemas.TranscriptionSettingsOut) +def get_transcription_settings(current_user: models.User = Depends(get_current_user)): + return { + "default_backend": transcription.get_default_transcription_backend(), + "backends": transcription.get_transcription_backends(), + "text_inference_engines": get_all_engine_statuses(), + } + + +@router.post("/transcription/models/download", response_model=schemas.TranscriptionModelOut) +def download_transcription_model( + payload: schemas.TranscriptionModelDownloadRequest, + current_user: models.User = Depends(get_current_user), +): + try: + return transcription.download_transcription_model(payload.backend, payload.model) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + + +@router.post("/local-inference/check", response_model=schemas.InferenceEngineStatusOut) +def check_local_inference( + payload: schemas.InferenceEngineCheckRequest, + current_user: models.User = Depends(get_current_user), +): + try: + return check_engine(payload.engine_id, payload.base_url) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc diff --git a/apps/api/src/app/routes/setup.py b/apps/api/src/app/routes/setup.py new file mode 100644 index 0000000000000000000000000000000000000000..300c2bdc81390e1a9e330323f4832fee7ac18758 --- /dev/null +++ b/apps/api/src/app/routes/setup.py @@ -0,0 +1,37 @@ +from fastapi import APIRouter, Depends, HTTPException, Response, status +from sqlalchemy.orm import Session + +from app import models, schemas +from app.auth import hash_password, issue_token_pair +from app.db import get_db +from app.rate_limit import rate_limit + +router = APIRouter(prefix="/setup", tags=["setup"]) + + +@router.get("/status", response_model=schemas.SetupStatus) +def setup_status(db: Session = Depends(get_db)): + user_exists = db.query(models.User.id).first() is not None + return {"setup_required": not user_exists} + + +@router.post( + "", + response_model=schemas.Token, + status_code=status.HTTP_201_CREATED, + dependencies=[Depends(rate_limit(5))], +) +def initial_setup(payload: schemas.UserCreate, response: Response, db: Session = Depends(get_db)): + user_exists = db.query(models.User.id).first() is not None + if user_exists: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail="Setup already completed") + + user = models.User( + email=payload.email, + hashed_password=hash_password(payload.password), + is_admin=True, + ) + db.add(user) + db.commit() + db.refresh(user) + return issue_token_pair(db, response, user) diff --git a/apps/api/src/app/schema_upgrades.py b/apps/api/src/app/schema_upgrades.py new file mode 100644 index 0000000000000000000000000000000000000000..b974a6f63fc9e70a20a23c8ecc4932b0bef7bbb7 --- /dev/null +++ b/apps/api/src/app/schema_upgrades.py @@ -0,0 +1,31 @@ +from sqlalchemy import inspect + +from app.db import engine + + +SESSION_COLUMN_DDL: dict[str, str] = { + "document_path": "ALTER TABLE sessions ADD COLUMN document_path VARCHAR(1024)", + "document_name": "ALTER TABLE sessions ADD COLUMN document_name VARCHAR(255)", + "document_mime_type": "ALTER TABLE sessions ADD COLUMN document_mime_type VARCHAR(255)", + "document_blocks_json": "ALTER TABLE sessions ADD COLUMN document_blocks_json TEXT", + "curriculum_day": "ALTER TABLE sessions ADD COLUMN curriculum_day INTEGER", + "exercise_id": "ALTER TABLE sessions ADD COLUMN exercise_id VARCHAR(100)", + "story_framework": "ALTER TABLE sessions ADD COLUMN story_framework VARCHAR(80)", + "target_keywords_json": "ALTER TABLE sessions ADD COLUMN target_keywords_json TEXT", + "teleprompter_script": "ALTER TABLE sessions ADD COLUMN teleprompter_script TEXT", +} + + +def apply_startup_schema_upgrades() -> None: + inspector = inspect(engine) + if "sessions" not in inspector.get_table_names(): + return + + existing_columns = {column["name"] for column in inspector.get_columns("sessions")} + statements = [ddl for column_name, ddl in SESSION_COLUMN_DDL.items() if column_name not in existing_columns] + if not statements: + return + + with engine.begin() as connection: + for statement in statements: + connection.exec_driver_sql(statement) diff --git a/apps/api/src/app/schemas.py b/apps/api/src/app/schemas.py new file mode 100644 index 0000000000000000000000000000000000000000..fd054f6794924ae22b30f333db6f12b7a85ce73a --- /dev/null +++ b/apps/api/src/app/schemas.py @@ -0,0 +1,324 @@ +from datetime import date, datetime +from typing import Literal + +from pydantic import BaseModel, ConfigDict, EmailStr, Field, model_validator + + +class UserCreate(BaseModel): + email: EmailStr + password: str = Field(min_length=6) + + +class UserLogin(BaseModel): + email: str + password: str + + +class UserOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + email: EmailStr + is_admin: bool = False + + +class Token(BaseModel): + access_token: str + refresh_token: str | None = None + token_type: str = "bearer" + expires_in: int | None = None + user: UserOut | None = None + + +class SetupStatus(BaseModel): + setup_required: bool + + +GoalMetric = Literal[ + "wpm", + "filler_rate", + "overall_score", + "practice_days_per_week", + "session_duration_minutes", + "eye_contact_score", + "delivery_score", +] +GoalComparison = Literal["gte", "lte", "between"] + + +class UserGoalCreate(BaseModel): + metric_name: GoalMetric + comparison: GoalComparison + target_value: float = Field(ge=0) + target_upper: float | None = Field(default=None, ge=0) + is_active: bool = True + + @model_validator(mode="after") + def validate_range(self): + if self.comparison == "between": + if self.target_upper is None or self.target_upper <= self.target_value: + raise ValueError("between goals require target_upper greater than target_value") + elif self.target_upper is not None: + raise ValueError("target_upper is only valid for between goals") + return self + + +class UserGoalUpdate(BaseModel): + comparison: GoalComparison | None = None + target_value: float | None = Field(default=None, ge=0) + target_upper: float | None = Field(default=None, ge=0) + is_active: bool | None = None + + +class UserGoalOut(BaseModel): + id: int + metric_name: GoalMetric + comparison: GoalComparison + target_value: float + target_upper: float | None + is_active: bool + current_value: float | None + achieved: bool + created_at: datetime + updated_at: datetime + + +class PresenterProfileUpdate(BaseModel): + display_name: str | None = Field(default=None, max_length=120) + primary_role: str | None = Field(default=None, max_length=120) + focus_track: Literal[ + "tech_storytelling", + "keynote_clarity", + "camera_confidence", + "executive_pitch", + "tech_presenter", + "tech_news_creator", + "engineering_leader", + ] | None = None + experience_level: Literal["beginner", "intermediate", "advanced"] | None = None + target_wpm_min: int | None = Field(default=None, ge=80, le=200) + target_wpm_max: int | None = Field(default=None, ge=90, le=220) + max_filler_rate: float | None = Field(default=None, ge=0, le=20) + min_eye_contact: float | None = Field(default=None, ge=0, le=1) + daily_minutes_goal: int | None = Field(default=None, ge=5, le=120) + weaknesses: list[str] | None = None + speaking_goal_statement: str | None = Field(default=None, max_length=500) + reset_start_date: bool = False + + +class PresenterProfileOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + display_name: str + primary_role: str + focus_track: str + experience_level: str + target_wpm_min: int + target_wpm_max: int + max_filler_rate: float + min_eye_contact: float + daily_minutes_goal: int + weaknesses: list[str] = Field(default_factory=list) + speaking_goal_statement: str | None = None + curriculum_start_date: date + + +class CurriculumCompleteRequest(BaseModel): + session_id: int | None = None + score: float | None = None + reflection_notes: str | None = Field(default=None, max_length=2000) + + +class StoryboardRequest(BaseModel): + headline: str = Field(min_length=3, max_length=300) + summary: str = Field(default="", max_length=4000) + source: str | None = Field(default=None, max_length=120) + url: str | None = Field(default=None, max_length=1000) + category: str | None = Field(default="AI & Systems", max_length=80) + framework: Literal["briefing_60s", "explainer_2m", "contrarian_take", "war_story"] = "briefing_60s" + personal_angle: str | None = Field(default=None, max_length=1000) + + +class WeeklyNoteUpdate(BaseModel): + week_start_date: date + wins_note: str | None = Field(default=None, max_length=2000) + focus_next_week: str | None = Field(default=None, max_length=2000) + + +class TranscriptUpdateRequest(BaseModel): + transcript_segments: list["TranscriptSegmentCreate"] = Field(default_factory=list) + + +class TranscriptionRequest(BaseModel): + backend: str | None = None + model: str | None = None + + +class TranscriptionModelDownloadRequest(BaseModel): + backend: str + model: str + + +class InferenceEngineCheckRequest(BaseModel): + engine_id: str + base_url: str | None = None + + +class InferenceEngineStatusOut(BaseModel): + id: str + label: str + base_url: str + live: bool + detail: str + available_models: list[str] = Field(default_factory=list) + + +class TranscriptionModelOut(BaseModel): + id: str + name: str + repo_id: str + description: str + languages: str + available: bool = False + local_path: str | None = None + + +class TranscriptionBackendOut(BaseModel): + id: str + label: str + supported: bool + live: bool + detail: str + models: list[TranscriptionModelOut] = Field(default_factory=list) + default_model: str | None = None + + +class TranscriptionSettingsOut(BaseModel): + default_backend: str + backends: list[TranscriptionBackendOut] = Field(default_factory=list) + text_inference_engines: list[InferenceEngineStatusOut] = Field(default_factory=list) + + +class TranscriptSegmentCreate(BaseModel): + start_ms: int + end_ms: int + text: str + + +class TranscriptSegmentOut(TranscriptSegmentCreate): + model_config = ConfigDict(from_attributes=True) + + id: int + + +class DocumentBlockOut(BaseModel): + index: int + text: str + + +class SessionDocumentOut(BaseModel): + name: str + mime_type: str + blocks: list[DocumentBlockOut] = Field(default_factory=list) + + +class MetricsPointCreate(BaseModel): + t: int + wpm: float = 0 + rms: float = 0 + eye_contact: float = 0 + smile: float = 0 + yaw: float = 0 + pitch: float = 0 + roll: float = 0 + filler_count: int = 0 + pause_ms: int = 0 + + +class ScoreCreate(BaseModel): + data: dict + + +class ScoreOut(ScoreCreate): + model_config = ConfigDict(from_attributes=True) + + id: int + + +class SessionCreate(BaseModel): + title: str | None = None + mode: str | None = None + prompt: str | None = None + goal: str | None = None + curriculum_day: int | None = Field(default=None, ge=1, le=30) + exercise_id: str | None = None + story_framework: str | None = None + target_keywords: list[str] = Field(default_factory=list) + teleprompter_script: str | None = None + started_at: datetime | None = None + ended_at: datetime | None = None + duration_ms: int = 0 + wpm_avg: float = 0 + filler_count: int = 0 + eye_contact_pct: float = 0 + tags: list[str] = Field(default_factory=list) + transcript_segments: list[TranscriptSegmentCreate] = Field(default_factory=list) + metrics: list[MetricsPointCreate] = Field(default_factory=list) + score: dict | None = None + + +class SessionOut(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + title: str | None + mode: str | None + prompt: str | None + goal: str | None + curriculum_day: int | None = None + exercise_id: str | None = None + story_framework: str | None = None + target_keywords: list[str] = Field(default_factory=list) + teleprompter_script: str | None = None + started_at: datetime | None + ended_at: datetime | None + duration_ms: int + wpm_avg: float + filler_count: int + eye_contact_pct: float + video_path: str | None + transcription_status: str + created_at: datetime + tags: list[str] = Field(default_factory=list) + transcript_segments: list[TranscriptSegmentOut] = Field(default_factory=list) + metrics: list[MetricsPointCreate] = Field(default_factory=list) + score: dict | None = None + document: SessionDocumentOut | None = None + + +class SessionListItem(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + title: str | None + mode: str | None + curriculum_day: int | None = None + duration_ms: int + wpm_avg: float + filler_count: int + eye_contact_pct: float + created_at: datetime + transcription_status: str + video_available: bool = False + score: dict | None = None + + +class UploadResponse(BaseModel): + session_id: int + status: str + video_path: str | None = None + + +class ExportResponse(BaseModel): + zip_path: str diff --git a/apps/api/src/app/scoring.py b/apps/api/src/app/scoring.py new file mode 100644 index 0000000000000000000000000000000000000000..f4fb75a738aaf226d055ebe16c427c935d0e343f --- /dev/null +++ b/apps/api/src/app/scoring.py @@ -0,0 +1,216 @@ +import re +from typing import List + +FILLER_WORDS = ["um", "uh", "like", "you know", "actually", "basically", "literally"] +STRUCTURE_WORDS = ["first", "second", "third", "because", "therefore", "however", "so", "thus"] + + +def tokenize(text: str) -> List[str]: + return [w for w in re.sub(r"[^a-z0-9\s']", " ", text.lower()).split() if w] + + +def count_words(segments: List[dict]) -> int: + return sum(len(tokenize(seg["text"])) for seg in segments) + + +def count_fillers(segments: List[dict]) -> int: + text = " ".join(seg["text"].lower() for seg in segments) + total = 0 + for filler in FILLER_WORDS: + escaped = re.escape(filler).replace(" ", r"\s+") + pattern = re.compile(rf"\b{escaped}\b") + total += len(pattern.findall(text)) + return total + + +def compute_wpm(word_count: int, duration_ms: int) -> float: + if duration_ms <= 0: + return 0 + return round((word_count / (duration_ms / 60000)) * 10) / 10 + + +def score_wpm(wpm: float) -> int: + if 120 <= wpm <= 160: + return 18 + if 100 <= wpm < 120 or 160 < wpm <= 180: + return 10 + return 4 + + +def score_fillers(fillers_per_minute: float) -> int: + if fillers_per_minute <= 2: + return 12 + if fillers_per_minute <= 6: + return 8 + return 3 + + +def score_pauses(pause_events: List[int], duration_ms: int) -> int: + if not pause_events: + return 4 + natural = len([p for p in pause_events if 600 <= p <= 1500]) + long = len([p for p in pause_events if p > 1500]) + minutes = max(duration_ms / 60000, 0.1) + rate = natural / minutes + if long > max(2, minutes): + return 4 + if 2 <= rate <= 6: + return 10 + if rate >= 1: + return 7 + return 5 + + +def score_eye_contact(eye_contact_pct: float) -> int: + if eye_contact_pct > 0.7: + return 15 + if eye_contact_pct >= 0.4: + return 10 + return 5 + + +def score_smile(smile_avg: float) -> int: + return round(7 * max(0, min(1, smile_avg))) + + +def score_head_stability(yaw_var: float, pitch_var: float, roll_var: float) -> int: + total_var = yaw_var + pitch_var + roll_var + if total_var < 5: + return 8 + if total_var < 12: + return 6 + return 4 + + +def score_structure(segments: List[dict]) -> int: + words = tokenize(" ".join(seg["text"] for seg in segments)) + count = sum(1 for w in words if w in STRUCTURE_WORDS) + if count >= 4: + return 10 + if count >= 2: + return 7 + return 4 + + +def score_repetition(segments: List[dict]) -> int: + words = tokenize(" ".join(seg["text"] for seg in segments)) + freq = {} + for word in words: + freq[word] = freq.get(word, 0) + 1 + repeated = len([c for c in freq.values() if c >= 6]) + if repeated >= 3: + return -8 + if repeated >= 1: + return -4 + return 0 + + +def score_clarity(segments: List[dict]) -> int: + sentences = [s.strip() for s in re.split(r"[.!?]+", " ".join(seg["text"] for seg in segments)) if s.strip()] + if not sentences: + return 0 + lengths = [len(tokenize(s)) for s in sentences] + avg = sum(lengths) / len(lengths) + if 8 <= avg <= 20: + return 12 + if 5 <= avg <= 25: + return 8 + return 4 + + +def score_keyword_coverage(segments: List[dict], keywords: List[str]) -> int: + if not keywords: + return 0 + text = " ".join(seg["text"].lower() for seg in segments) + covered = sum(1 for k in keywords if k.lower() in text) + ratio = covered / len(keywords) + if ratio >= 0.8: + return 8 + if ratio >= 0.5: + return 5 + return 2 + + +def avg(values: List[float]) -> float: + return sum(values) / len(values) if values else 0 + + +def variance(values: List[float]) -> float: + if not values: + return 0 + mean = avg(values) + return avg([(v - mean) ** 2 for v in values]) + + +def compute_score(segments: List[dict], metrics: List[dict], duration_ms: int, keywords: List[str]): + word_count = count_words(segments) + wpm = compute_wpm(word_count, duration_ms) + filler_count = count_fillers(segments) + minutes = max(duration_ms / 60000, 0.1) + fillers_per_minute = filler_count / minutes + pause_events = [m.get("pause_ms", 0) for m in metrics if m.get("pause_ms", 0) > 0] + + wpm_score = score_wpm(wpm) + filler_score = score_fillers(fillers_per_minute) + pause_score = score_pauses(pause_events, duration_ms) + speech = wpm_score + filler_score + pause_score + + eye_contact_pct = avg([m.get("eye_contact", 0) for m in metrics]) + smile_avg = avg([m.get("smile", 0) for m in metrics]) + yaw_var = variance([m.get("yaw", 0) for m in metrics]) + pitch_var = variance([m.get("pitch", 0) for m in metrics]) + roll_var = variance([m.get("roll", 0) for m in metrics]) + + eye_score = score_eye_contact(eye_contact_pct) + smile_score = score_smile(smile_avg) + head_score = score_head_stability(yaw_var, pitch_var, roll_var) + delivery = eye_score + smile_score + head_score + + structure_score = score_structure(segments) + repetition_score = score_repetition(segments) + clarity_score = score_clarity(segments) + keyword_score = score_keyword_coverage(segments, keywords) + content = max(0, structure_score + repetition_score + clarity_score + keyword_score) + + total = max(0, min(100, speech + delivery + content)) + + fixes = [] + if wpm_score <= 10: + fixes.append("Adjust pace to 120-160 WPM.") + if filler_score <= 8: + fixes.append("Reduce filler words with pauses.") + if eye_score <= 10: + fixes.append("Increase eye contact with the lens.") + if clarity_score <= 8: + fixes.append("Shorten sentences for clarity.") + if keyword_score <= 5 and keywords: + fixes.append("Cover target keywords.") + + drill = ( + "60-second pace drill" + if speech < delivery and speech < content + else "Eye contact mirror drill" + if delivery < content + else "Story arc outline drill" + ) + + return { + "total": total, + "speech": speech, + "delivery": delivery, + "content": content, + "details": { + "wpm": wpm_score, + "fillers": filler_score, + "pauses": pause_score, + "eyeContact": eye_score, + "smile": smile_score, + "headStability": head_score, + "structure": structure_score, + "repetition": repetition_score, + "clarity": clarity_score, + "keywordCoverage": keyword_score, + }, + "topFixes": fixes[:3], + "recommendedDrill": drill, + } diff --git a/apps/api/src/app/services/__init__.py b/apps/api/src/app/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8b137891791fe96927ad78e64b0aad7bded08bdc --- /dev/null +++ b/apps/api/src/app/services/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/api/src/app/services/analytics.py b/apps/api/src/app/services/analytics.py new file mode 100644 index 0000000000000000000000000000000000000000..3eaddaba98009ecf85417160704d98184547e6b7 --- /dev/null +++ b/apps/api/src/app/services/analytics.py @@ -0,0 +1,790 @@ +from __future__ import annotations + +import json +import os +import re +from datetime import date, datetime, time, timedelta +from typing import Any + +from sqlalchemy import func +from sqlalchemy.orm import Session + +from app import models +from app.scoring import FILLER_WORDS, compute_score, compute_wpm, count_fillers, tokenize + +ALLOWED_TREND_METRICS = { + "avg_wpm", + "avg_filler_rate", + "avg_overall_score", + "avg_speech_score", + "avg_delivery_score", + "session_count", + "total_duration_seconds", + "best_overall_score", +} + +GOAL_METRICS = { + "wpm", + "filler_rate", + "overall_score", + "practice_days_per_week", + "session_duration_minutes", + "eye_contact_score", + "delivery_score", +} + + +def _parse_target_keywords(raw: str | None) -> list[str]: + if not raw: + return [] + try: + parsed = json.loads(raw) + if isinstance(parsed, list): + return [str(item) for item in parsed if str(item).strip()] + except json.JSONDecodeError: + pass + return [] + + +def prepare_user_analytics(db: Session, user_id: int, limit: int = 100) -> None: + missing_sessions = ( + db.query(models.Session.id) + .outerjoin(models.SessionMetrics, models.SessionMetrics.session_id == models.Session.id) + .filter(models.Session.user_id == user_id, models.SessionMetrics.id.is_(None)) + .order_by(models.Session.created_at.desc()) + .limit(limit) + .all() + ) + for (session_id,) in missing_sessions: + compute_session_metrics(db, session_id) + + +def compute_session_metrics(db: Session, session_id: int) -> models.SessionMetrics: + session = db.query(models.Session).filter(models.Session.id == session_id).first() + if session is None: + raise ValueError(f"Session {session_id} not found") + + segments = _segment_payload(session) + metrics = _metric_payload(session) + duration_ms = _duration_ms(session) + duration_seconds = duration_ms / 1000 if duration_ms > 0 else 0 + word_count = sum(len(tokenize(segment["text"])) for segment in segments) + words = tokenize(" ".join(segment["text"] for segment in segments)) + unique_word_count = len(set(words)) + vocabulary_density = round(unique_word_count / word_count, 3) if word_count else 0 + filler_breakdown = _filler_breakdown(segments) + filler_count = sum(filler_breakdown.values()) or int(session.filler_count or 0) + filler_rate = round(filler_count / (duration_seconds / 60), 2) if duration_seconds > 0 else 0 + pause_count = len([point for point in metrics if point.get("pause_ms", 0) > 0]) + wpm = session.wpm_avg or compute_wpm(word_count, duration_ms) + score_data = session.score.data if session.score and isinstance(session.score.data, dict) else None + if score_data is None: + score_data = compute_score( + segments, + metrics, + duration_ms, + _parse_target_keywords(session.target_keywords_json), + ) + + eye_contact = _metric_avg(metrics, "eye_contact") + if (eye_contact is None or eye_contact == 0) and session.eye_contact_pct: + eye_contact = session.eye_contact_pct + smile = _metric_avg(metrics, "smile") + loudness = min(1.0, _metric_avg(metrics, "rms") / 100) if metrics else None + head_stability = _head_stability(metrics) + + existing = ( + db.query(models.SessionMetrics) + .filter(models.SessionMetrics.session_id == session_id) + .first() + ) + snapshot = existing or models.SessionMetrics(session_id=session_id, user_id=session.user_id) + snapshot.user_id = session.user_id + snapshot.recorded_at = session.created_at or datetime.utcnow() + snapshot.duration_seconds = duration_seconds + snapshot.word_count = word_count + snapshot.wpm = round(float(wpm or 0), 1) + snapshot.filler_count = filler_count + snapshot.filler_rate = filler_rate + snapshot.pause_count = pause_count + snapshot.unique_word_count = unique_word_count + snapshot.vocabulary_density = vocabulary_density + snapshot.eye_contact_score = _nullable_round(eye_contact) + snapshot.smile_score = _nullable_round(smile) + snapshot.head_stability_score = _nullable_round(head_stability) + snapshot.loudness_score = _nullable_round(loudness) + snapshot.speech_score = _normalize_score(score_data.get("speech", 0), 40) + snapshot.delivery_score = _normalize_score(score_data.get("delivery", 0), 30) + snapshot.overall_score = round(float(score_data.get("total", 0) or 0), 1) + snapshot.filler_breakdown = filler_breakdown + snapshot.wpm_timeline = _wpm_timeline(session) + + if existing is None: + db.add(snapshot) + db.commit() + db.refresh(snapshot) + update_daily_aggregate(db, session.user_id, snapshot.recorded_at.date()) + return snapshot + + +def update_daily_aggregate(db: Session, user_id: int, practice_date: date) -> models.DailyAggregate: + start = datetime.combine(practice_date, time.min) + end = start + timedelta(days=1) + rows = ( + db.query(models.SessionMetrics) + .filter( + models.SessionMetrics.user_id == user_id, + models.SessionMetrics.recorded_at >= start, + models.SessionMetrics.recorded_at < end, + ) + .all() + ) + aggregate = ( + db.query(models.DailyAggregate) + .filter( + models.DailyAggregate.user_id == user_id, + models.DailyAggregate.practice_date == practice_date, + ) + .first() + ) + if aggregate is None: + aggregate = models.DailyAggregate(user_id=user_id, practice_date=practice_date) + db.add(aggregate) + + aggregate.session_count = len(rows) + aggregate.total_duration_seconds = round(sum(row.duration_seconds for row in rows), 1) + aggregate.avg_wpm = _avg([row.wpm for row in rows]) + aggregate.avg_filler_rate = _avg([row.filler_rate for row in rows]) + aggregate.avg_overall_score = _avg([row.overall_score for row in rows]) + aggregate.avg_speech_score = _avg([row.speech_score for row in rows]) + aggregate.avg_delivery_score = _avg([row.delivery_score for row in rows]) + aggregate.best_overall_score = round(max([row.overall_score for row in rows] or [0]), 1) + db.commit() + db.refresh(aggregate) + return aggregate + + +def get_overview_stats(db: Session, user_id: int) -> dict[str, Any]: + total_sessions = db.query(func.count(models.SessionMetrics.id)).filter(models.SessionMetrics.user_id == user_id).scalar() or 0 + total_seconds = db.query(func.coalesce(func.sum(models.SessionMetrics.duration_seconds), 0)).filter(models.SessionMetrics.user_id == user_id).scalar() or 0 + start = datetime.utcnow() - timedelta(days=30) + recent = ( + db.query(models.SessionMetrics) + .filter(models.SessionMetrics.user_id == user_id, models.SessionMetrics.recorded_at >= start) + .all() + ) + practice_days = ( + db.query(func.count(func.distinct(models.DailyAggregate.practice_date))) + .filter( + models.DailyAggregate.user_id == user_id, + models.DailyAggregate.practice_date >= start.date(), + models.DailyAggregate.session_count > 0, + ) + .scalar() + or 0 + ) + practice_dates = [ + value[0].isoformat() + for value in db.query(models.DailyAggregate.practice_date) + .filter(models.DailyAggregate.user_id == user_id, models.DailyAggregate.session_count > 0) + .order_by(models.DailyAggregate.practice_date.asc()) + .all() + ] + streaks = calculate_streak(practice_dates) + curriculum_completed = ( + db.query(func.count(models.CurriculumDayCompletion.id)) + .filter(models.CurriculumDayCompletion.user_id == user_id) + .scalar() + or 0 + ) + today_agg = ( + db.query(models.DailyAggregate) + .filter( + models.DailyAggregate.user_id == user_id, + models.DailyAggregate.practice_date == date.today(), + ) + .first() + ) + today_minutes = round((today_agg.total_duration_seconds / 60), 1) if today_agg else 0.0 + + return { + "total_sessions": total_sessions, + "total_practice_minutes": round(float(total_seconds) / 60, 1), + "today_practice_minutes": today_minutes, + "current_streak": streaks["current_streak"], + "longest_streak": streaks["longest_streak"], + "curriculum_completed_days": curriculum_completed, + "recent_30d": { + "session_count": len(recent), + "avg_overall_score": _avg([row.overall_score for row in recent]), + "avg_wpm": _avg([row.wpm for row in recent]), + "avg_filler_rate": _avg([row.filler_rate for row in recent]), + "avg_delivery_score": _avg([row.delivery_score for row in recent]), + "practice_days": practice_days, + }, + } + + +def get_trend_data(db: Session, user_id: int, metric: str, days: int = 90) -> list[dict[str, Any]]: + if metric not in ALLOWED_TREND_METRICS: + raise ValueError(f"Invalid metric: {metric}") + bounded_days = min(max(days, 7), 365) + start = date.today() - timedelta(days=bounded_days) + rows = ( + db.query(models.DailyAggregate) + .filter(models.DailyAggregate.user_id == user_id, models.DailyAggregate.practice_date >= start) + .order_by(models.DailyAggregate.practice_date.asc()) + .limit(365) + .all() + ) + return [ + {"date": row.practice_date.isoformat(), "value": round(float(getattr(row, metric) or 0), 1)} + for row in rows + ] + + +def get_session_history(db: Session, user_id: int, limit: int = 20, offset: int = 0) -> list[dict[str, Any]]: + rows = ( + db.query(models.SessionMetrics, models.Session) + .join(models.Session, models.Session.id == models.SessionMetrics.session_id) + .filter(models.SessionMetrics.user_id == user_id) + .order_by(models.SessionMetrics.recorded_at.desc()) + .limit(min(limit, 100)) + .offset(offset) + .all() + ) + return [_session_metrics_to_dict(metric_row, sess_row) for metric_row, sess_row in rows] + + +def get_activity_heatmap(db: Session, user_id: int, days: int = 365) -> list[dict[str, Any]]: + bounded_days = min(max(days, 30), 365) + start = date.today() - timedelta(days=bounded_days) + rows = ( + db.query(models.DailyAggregate) + .filter(models.DailyAggregate.user_id == user_id, models.DailyAggregate.practice_date >= start) + .order_by(models.DailyAggregate.practice_date.asc()) + .limit(365) + .all() + ) + return [ + { + "date": row.practice_date.isoformat(), + "count": row.session_count, + "minutes": round(row.total_duration_seconds / 60, 1), + } + for row in rows + ] + + +def get_filler_breakdown(db: Session, user_id: int, last_n: int = 20) -> dict[str, int]: + rows = ( + db.query(models.SessionMetrics.filler_breakdown) + .filter(models.SessionMetrics.user_id == user_id) + .order_by(models.SessionMetrics.recorded_at.desc()) + .limit(min(last_n, 100)) + .all() + ) + combined: dict[str, int] = {} + for (breakdown,) in rows: + if not isinstance(breakdown, dict): + continue + for word, count in breakdown.items(): + combined[str(word)] = combined.get(str(word), 0) + int(count or 0) + return dict(sorted(combined.items(), key=lambda item: item[1], reverse=True)) + + +def _serialize_comparison_session(snapshot: models.SessionMetrics, session: models.Session | None) -> dict[str, Any]: + segments = session.transcript_segments if session else [] + full_text = " ".join(seg.text for seg in segments).strip() + score_data = session.score.data if session and session.score and isinstance(session.score.data, dict) else {} + video_available = bool( + session and session.video_path and (session.video_path.startswith("s3://") or os.path.exists(session.video_path)) + ) + return { + "id": snapshot.session_id, + "date": snapshot.recorded_at.isoformat(), + "title": (session.title if session else None) or f"Session #{snapshot.session_id}", + "mode": (session.mode if session else None) or "Practice", + "curriculum_day": session.curriculum_day if session else None, + "duration_seconds": round(snapshot.duration_seconds, 1), + "word_count": snapshot.word_count, + "wpm": snapshot.wpm, + "filler_count": snapshot.filler_count, + "filler_rate": snapshot.filler_rate, + "pause_count": snapshot.pause_count, + "eye_contact_pct": round((snapshot.eye_contact_score or 0) * 100, 1), + "vocabulary_density": snapshot.vocabulary_density, + "overall_score": snapshot.overall_score, + "speech_score": snapshot.speech_score, + "delivery_score": snapshot.delivery_score, + "filler_breakdown": snapshot.filler_breakdown or {}, + "video_available": video_available, + "opening_excerpt": full_text[:280] + ("..." if len(full_text) > 280 else ""), + "top_fixes": score_data.get("topFixes", []), + } + + +def compare_sessions(db: Session, user_id: int, session_a: int, session_b: int) -> dict[str, Any]: + rows = ( + db.query(models.SessionMetrics) + .filter( + models.SessionMetrics.user_id == user_id, + models.SessionMetrics.session_id.in_([session_a, session_b]), + ) + .all() + ) + by_id = {row.session_id: row for row in rows} + if session_a not in by_id or session_b not in by_id: + raise ValueError("One or both sessions not found") + + left = by_id[session_a] + right = by_id[session_b] + sess_rows = ( + db.query(models.Session) + .filter(models.Session.user_id == user_id, models.Session.id.in_([session_a, session_b])) + .all() + ) + sess_by_id = {s.id: s for s in sess_rows} + + def diff(attr: str) -> dict[str, float]: + a_value = float(getattr(left, attr) or 0) + b_value = float(getattr(right, attr) or 0) + return {"a": round(a_value, 2), "b": round(b_value, 2), "delta": round(b_value - a_value, 2)} + + comparison = { + "overall_score": diff("overall_score"), + "speech_score": diff("speech_score"), + "delivery_score": diff("delivery_score"), + "wpm": diff("wpm"), + "filler_rate": diff("filler_rate"), + "eye_contact_score": diff("eye_contact_score"), + "vocabulary_density": diff("vocabulary_density"), + } + + insights: list[str] = [] + score_delta = comparison["overall_score"]["delta"] + filler_delta = comparison["filler_rate"]["delta"] + eye_delta = round(comparison["eye_contact_score"]["delta"] * 100, 1) + if score_delta > 0: + insights.append(f"Overall presenting score climbed +{score_delta:.1f} points from Session A to Session B.") + elif score_delta < 0: + insights.append(f"Overall score dipped {score_delta:.1f} points β€” check pace and filler habits between takes.") + if filler_delta < -0.3: + insights.append(f"Filler rate dropped by {abs(filler_delta):.1f} fillers/min β€” cleaner silent pauses!") + elif filler_delta > 0.5: + insights.append(f"Filler rate increased by +{filler_delta:.1f}/min on Session B; slow down transitions.") + if eye_delta >= 5: + insights.append(f"Camera lens eye contact jumped +{eye_delta:.0f}% between takes.") + if 125 <= right.wpm <= 160 and not (125 <= left.wpm <= 160): + insights.append(f"Speaking speed moved into the optimal 125–160 WPM keynote window ({right.wpm:.0f} WPM).") + if not insights: + insights.append("Consistent performance across both takes β€” push difficulty with a Cue-Card or Contrarian challenge next.") + + return { + "session_a": _serialize_comparison_session(left, sess_by_id.get(session_a)), + "session_b": _serialize_comparison_session(right, sess_by_id.get(session_b)), + "comparison": comparison, + "insights": insights, + } + + +def get_weekly_review(db: Session, user_id: int, weeks_ago: int = 0) -> dict[str, Any]: + bounded_weeks = max(0, min(weeks_ago, 12)) + today = date.today() + # Rolling 7-day window anchored at today (or shifted by weeks_ago * 7) + period_end = today - timedelta(days=bounded_weeks * 7) + period_start = period_end - timedelta(days=6) + prev_end = period_start - timedelta(days=1) + prev_start = prev_end - timedelta(days=6) + + def _window_rows(start_d: date, end_d: date) -> list[models.SessionMetrics]: + dt_start = datetime.combine(start_d, time.min) + dt_end = datetime.combine(end_d + timedelta(days=1), time.min) + return ( + db.query(models.SessionMetrics) + .filter( + models.SessionMetrics.user_id == user_id, + models.SessionMetrics.recorded_at >= dt_start, + models.SessionMetrics.recorded_at < dt_end, + ) + .order_by(models.SessionMetrics.recorded_at.asc()) + .all() + ) + + current_rows = _window_rows(period_start, period_end) + prev_rows = _window_rows(prev_start, prev_end) + + def _summarize(rows: list[models.SessionMetrics]) -> dict[str, Any]: + if not rows: + return { + "session_count": 0, + "active_days": 0, + "total_minutes": 0.0, + "avg_overall_score": 0.0, + "avg_speech_score": 0.0, + "avg_delivery_score": 0.0, + "avg_wpm": 0.0, + "avg_filler_rate": 0.0, + "avg_eye_contact_pct": 0.0, + "best_session_id": None, + "best_score": 0.0, + } + active_days = len({r.recorded_at.date().isoformat() for r in rows}) + eye_vals = [(r.eye_contact_score or 0) * 100 for r in rows if r.eye_contact_score is not None] + best_row = max(rows, key=lambda r: r.overall_score) + return { + "session_count": len(rows), + "active_days": active_days, + "total_minutes": round(sum(r.duration_seconds for r in rows) / 60, 1), + "avg_overall_score": _avg([r.overall_score for r in rows]), + "avg_speech_score": _avg([r.speech_score for r in rows]), + "avg_delivery_score": _avg([r.delivery_score for r in rows]), + "avg_wpm": _avg([r.wpm for r in rows]), + "avg_filler_rate": _avg([r.filler_rate for r in rows]), + "avg_eye_contact_pct": _avg(eye_vals) if eye_vals else 0.0, + "best_session_id": best_row.session_id, + "best_score": round(best_row.overall_score, 1), + } + + curr = _summarize(current_rows) + prev = _summarize(prev_rows) + + deltas = { + "overall_score": round(curr["avg_overall_score"] - prev["avg_overall_score"], 1) if prev["session_count"] else 0.0, + "filler_rate": round(curr["avg_filler_rate"] - prev["avg_filler_rate"], 2) if prev["session_count"] else 0.0, + "wpm": round(curr["avg_wpm"] - prev["avg_wpm"], 1) if prev["session_count"] else 0.0, + "eye_contact_pct": round(curr["avg_eye_contact_pct"] - prev["avg_eye_contact_pct"], 1) if prev["session_count"] else 0.0, + "minutes": round(curr["total_minutes"] - prev["total_minutes"], 1), + } + + filler_totals: dict[str, int] = {} + for r in current_rows: + if isinstance(r.filler_breakdown, dict): + for word, count in r.filler_breakdown.items(): + filler_totals[str(word)] = filler_totals.get(str(word), 0) + int(count or 0) + top_fillers = [ + {"word": w, "count": c} + for w, c in sorted(filler_totals.items(), key=lambda x: x[1], reverse=True)[:5] + ] + + wins: list[str] = [] + focus_items: list[str] = [] + + if curr["session_count"] == 0: + wins.append("Ready for a fresh training week β€” record one 60-second take today to ignite your streak.") + focus_items.append("Complete your recommended 30-Day Curriculum challenge in the Studio.") + else: + if curr["active_days"] >= 4: + wins.append(f"Strong consistency: practiced across {curr['active_days']} of 7 days ({curr['total_minutes']} mins total).") + else: + wins.append(f"Logged {curr['session_count']} practice take{'s' if curr['session_count'] != 1 else ''} with a best score of {curr['best_score']}/100.") + + if 125 <= curr["avg_wpm"] <= 160: + wins.append(f"Average speaking speed ({curr['avg_wpm']} WPM) stayed right inside the 125–160 WPM keynote window.") + elif curr["avg_wpm"] > 160: + focus_items.append(f"Pace averaged {curr['avg_wpm']} WPM β€” practice 1-second breath pauses between technical clauses.") + elif curr["avg_wpm"] > 0: + focus_items.append(f"Pace averaged {curr['avg_wpm']} WPM β€” tighten sentence openings toward 135 WPM.") + + if curr["avg_filler_rate"] <= 2.0: + wins.append(f"Crisp filler discipline at {curr['avg_filler_rate']}/min.") + else: + top_w = top_fillers[0]["word"] if top_fillers else "um/like" + focus_items.append(f"Cut filler rate from {curr['avg_filler_rate']}/min (<2.0/min target), especially '{top_w}'.") + + if curr["avg_eye_contact_pct"] >= 70: + wins.append(f"Confident camera connection at {curr['avg_eye_contact_pct']:.0f}% lens eye contact.") + else: + focus_items.append(f"Lift lens eye contact from {curr['avg_eye_contact_pct']:.0f}% toward 75%+ using Cue-Card teleprompter mode.") + + if not focus_items: + focus_items.append("Level up difficulty: record a 2-Minute Deep-Dive Tech News Explainer without reading word-for-word.") + + reflection = ( + db.query(models.WeeklyReflection) + .filter( + models.WeeklyReflection.user_id == user_id, + models.WeeklyReflection.week_start_date == period_start, + ) + .first() + ) + + first_vs_last_comparison = None + if len(current_rows) >= 2: + first_vs_last_comparison = { + "first_session_id": current_rows[0].session_id, + "latest_session_id": current_rows[-1].session_id, + "score_change": round(current_rows[-1].overall_score - current_rows[0].overall_score, 1), + } + + return { + "week_start_date": period_start.isoformat(), + "week_end_date": period_end.isoformat(), + "weeks_ago": bounded_weeks, + "current_week": curr, + "previous_week": prev, + "deltas": deltas, + "top_fillers": top_fillers, + "wins": wins, + "recommended_focus": focus_items, + "first_vs_latest": first_vs_last_comparison, + "reflection": { + "wins_note": reflection.wins_note if reflection else "", + "focus_next_week": reflection.focus_next_week if reflection else "", + }, + } + + +def save_weekly_reflection( + db: Session, + user_id: int, + week_start_date: date, + wins_note: str | None, + focus_next_week: str | None, +) -> dict[str, Any]: + row = ( + db.query(models.WeeklyReflection) + .filter( + models.WeeklyReflection.user_id == user_id, + models.WeeklyReflection.week_start_date == week_start_date, + ) + .first() + ) + if row is None: + row = models.WeeklyReflection( + user_id=user_id, + week_start_date=week_start_date, + wins_note=wins_note or "", + focus_next_week=focus_next_week or "", + ) + db.add(row) + else: + if wins_note is not None: + row.wins_note = wins_note + if focus_next_week is not None: + row.focus_next_week = focus_next_week + db.commit() + db.refresh(row) + return { + "week_start_date": row.week_start_date.isoformat(), + "wins_note": row.wins_note or "", + "focus_next_week": row.focus_next_week or "", + } + + +def get_user_goals(db: Session, user_id: int) -> list[dict[str, Any]]: + rows = ( + db.query(models.UserGoal) + .filter(models.UserGoal.user_id == user_id) + .order_by(models.UserGoal.is_active.desc(), models.UserGoal.created_at.asc()) + .all() + ) + current_values = get_goal_current_values(db, user_id) + return [serialize_goal(row, current_values.get(row.metric_name)) for row in rows] + + +def get_active_goal_context(db: Session, user_id: int) -> list[dict[str, Any]]: + rows = ( + db.query(models.UserGoal) + .filter(models.UserGoal.user_id == user_id, models.UserGoal.is_active.is_(True)) + .order_by(models.UserGoal.created_at.asc()) + .all() + ) + current_values = get_goal_current_values(db, user_id) + return [serialize_goal(row, current_values.get(row.metric_name)) for row in rows] + + +def get_goal_current_values(db: Session, user_id: int) -> dict[str, float | None]: + latest = ( + db.query(models.SessionMetrics) + .filter(models.SessionMetrics.user_id == user_id) + .order_by(models.SessionMetrics.recorded_at.desc()) + .first() + ) + week_start = date.today() - timedelta(days=6) + practice_days = ( + db.query(func.count(models.DailyAggregate.id)) + .filter( + models.DailyAggregate.user_id == user_id, + models.DailyAggregate.practice_date >= week_start, + models.DailyAggregate.session_count > 0, + ) + .scalar() + or 0 + ) + if latest is None: + return { + metric: (float(practice_days) if metric == "practice_days_per_week" else None) + for metric in GOAL_METRICS + } + return { + "wpm": float(latest.wpm), + "filler_rate": float(latest.filler_rate), + "overall_score": float(latest.overall_score), + "practice_days_per_week": float(practice_days), + "session_duration_minutes": round(float(latest.duration_seconds) / 60, 1), + "eye_contact_score": float(latest.eye_contact_score) if latest.eye_contact_score is not None else None, + "delivery_score": float(latest.delivery_score), + } + + +def serialize_goal(goal: models.UserGoal, current_value: float | None) -> dict[str, Any]: + achieved = False + if current_value is not None: + if goal.comparison == "gte": + achieved = current_value >= goal.target_value + elif goal.comparison == "lte": + achieved = current_value <= goal.target_value + elif goal.comparison == "between" and goal.target_upper is not None: + achieved = goal.target_value <= current_value <= goal.target_upper + return { + "id": goal.id, + "metric_name": goal.metric_name, + "comparison": goal.comparison, + "target_value": goal.target_value, + "target_upper": goal.target_upper, + "is_active": goal.is_active, + "current_value": current_value, + "achieved": achieved, + "created_at": goal.created_at, + "updated_at": goal.updated_at, + } + + +def calculate_streak(practice_dates: list[str]) -> dict[str, int]: + if not practice_dates: + return {"current_streak": 0, "longest_streak": 0} + parsed = sorted({datetime.strptime(value, "%Y-%m-%d").date() for value in practice_dates}) + longest = 1 + current_run = 1 + for index in range(1, len(parsed)): + gap = (parsed[index] - parsed[index - 1]).days + if gap == 1: + current_run += 1 + elif gap > 1: + current_run = 1 + longest = max(longest, current_run) + days_since_last = (date.today() - parsed[-1]).days + return {"current_streak": current_run if days_since_last <= 1 else 0, "longest_streak": longest} + + +def _session_metrics_to_dict(row: models.SessionMetrics, sess: models.Session | None = None) -> dict[str, Any]: + return { + "session_id": row.session_id, + "title": (sess.title if sess else None) or f"Session #{row.session_id}", + "mode": (sess.mode if sess else None) or "Practice", + "curriculum_day": sess.curriculum_day if sess else None, + "video_available": bool(sess and sess.video_path), + "recorded_at": row.recorded_at.isoformat(), + "duration_seconds": row.duration_seconds, + "wpm": row.wpm, + "filler_count": row.filler_count, + "filler_rate": row.filler_rate, + "speech_score": row.speech_score, + "delivery_score": row.delivery_score, + "overall_score": row.overall_score, + "eye_contact_score": row.eye_contact_score, + "vocabulary_density": row.vocabulary_density, + } + + +def _segment_payload(session: models.Session) -> list[dict[str, Any]]: + return [ + {"start_ms": segment.start_ms, "end_ms": segment.end_ms, "text": segment.text} + for segment in session.transcript_segments + ] + + +def _metric_payload(session: models.Session) -> list[dict[str, Any]]: + return [ + { + "t": point.t, + "wpm": point.wpm, + "rms": point.rms, + "eye_contact": point.eye_contact, + "smile": point.smile, + "yaw": point.yaw, + "pitch": point.pitch, + "roll": point.roll, + "filler_count": point.filler_count, + "pause_ms": point.pause_ms, + } + for point in session.metrics + ] + + +def _duration_ms(session: models.Session) -> int: + if session.duration_ms: + return session.duration_ms + if session.transcript_segments: + return max(segment.end_ms for segment in session.transcript_segments) + return 0 + + +def _filler_breakdown(segments: list[dict[str, Any]]) -> dict[str, int]: + text = " ".join(str(segment.get("text", "")).lower() for segment in segments) + counts: dict[str, int] = {} + for filler in FILLER_WORDS: + escaped = re.escape(filler).replace(" ", r"\s+") + pattern = re.compile(rf"\b{escaped}\b") + count = len(pattern.findall(text)) + if count: + counts[filler] = count + if not counts: + total = count_fillers(segments) + if total: + counts["fillers"] = total + return counts + + +def _wpm_timeline(session: models.Session) -> list[dict[str, float]]: + buckets: dict[int, dict[str, float]] = {} + for segment in session.transcript_segments: + minute = max(segment.start_ms, 0) // 60000 + duration_seconds = max((segment.end_ms - segment.start_ms) / 1000, 0) + words = len(tokenize(segment.text)) + bucket = buckets.setdefault(minute, {"words": 0, "seconds": 0}) + bucket["words"] += words + bucket["seconds"] += duration_seconds + return [ + {"minute": minute, "wpm": round(bucket["words"] / (bucket["seconds"] / 60), 1) if bucket["seconds"] else 0} + for minute, bucket in sorted(buckets.items()) + ] + + +def _metric_avg(metrics: list[dict[str, Any]], key: str) -> float | None: + values = [float(point.get(key) or 0) for point in metrics if point.get(key) is not None] + if not values: + return None + return sum(values) / len(values) + + +def _head_stability(metrics: list[dict[str, Any]]) -> float | None: + if not metrics: + return None + variance_total = sum(_variance([float(point.get(axis) or 0) for point in metrics]) for axis in ("yaw", "pitch", "roll")) + return max(0.0, min(1.0, 1 - variance_total / 20)) + + +def _variance(values: list[float]) -> float: + if not values: + return 0 + average = sum(values) / len(values) + return sum((value - average) ** 2 for value in values) / len(values) + + +def _avg(values: list[float]) -> float: + clean = [float(value or 0) for value in values] + return round(sum(clean) / len(clean), 1) if clean else 0.0 + + +def _normalize_score(value: Any, max_value: float) -> float: + if max_value <= 0: + return 0 + return round(max(0, min(100, (float(value or 0) / max_value) * 100)), 1) + + +def _nullable_round(value: float | None) -> float | None: + if value is None: + return None + return round(float(value), 3) diff --git a/apps/api/src/app/services/coaching.py b/apps/api/src/app/services/coaching.py new file mode 100644 index 0000000000000000000000000000000000000000..1193f353e901e2206ea066253c77c4aa0833d3fd --- /dev/null +++ b/apps/api/src/app/services/coaching.py @@ -0,0 +1,502 @@ +from __future__ import annotations + +import json +import re +import time +from typing import Any + +import requests + +from app.config import settings + +REQUIRED_KEYS = {"summary", "strengths", "improvements", "drills", "practice_plan"} + +TRANSITION_PHRASES = ( + "first", + "second", + "third", + "because", + "therefore", + "however", + "for example", + "for instance", + "in practice", + "the takeaway", + "here is why", + "why this matters", + "under the hood", + "as a result", +) + + +def build_coaching_prompt( + transcript: str, + metrics: dict[str, Any], + history: list[dict[str, Any]], + previous_feedback: dict[str, Any] | None = None, + active_goals: list[dict[str, Any]] | None = None, +) -> str: + max_chars = max(1000, settings.coaching_max_transcript_chars) + truncated = transcript[:max_chars] + if len(transcript) > max_chars: + truncated = f"{truncated}\n[transcript truncated]" + + history_lines = [] + for item in history[:10]: + history_lines.append( + f"- {item.get('recorded_at', 'unknown')}: score={item.get('overall_score', 0)}, " + f"wpm={item.get('wpm', 0)}, fillers/min={item.get('filler_rate', 0)}" + ) + previous = "" + if previous_feedback: + previous = f"Previous coaching focus: {json.dumps(previous_feedback, ensure_ascii=False)[:1200]}" + goal_lines = [] + for goal in active_goals or []: + target = str(goal.get("target_value", 0)) + if goal.get("comparison") == "between": + target = f"{target} to {goal.get('target_upper')}" + goal_lines.append( + f"- {goal.get('metric_name')}: target {goal.get('comparison')} {target}; " + f"current={goal.get('current_value', 'not available')}; achieved={goal.get('achieved', False)}" + ) + + return f"""You are a camera-presenting & technology storytelling AI coach. Analyze the practice session and return only valid JSON. + +Current metrics: +- duration_seconds: {metrics.get("duration_seconds", 0)} +- word_count: {metrics.get("word_count", 0)} +- wpm: {metrics.get("wpm", 0)} +- filler_count: {metrics.get("filler_count", 0)} +- filler_rate: {metrics.get("filler_rate", 0)} +- pause_count: {metrics.get("pause_count", 0)} +- vocabulary_density: {metrics.get("vocabulary_density", 0)} +- speech_score: {metrics.get("speech_score", 0)} +- delivery_score: {metrics.get("delivery_score", 0)} +- overall_score: {metrics.get("overall_score", 0)} +- eye_contact_score: {metrics.get("eye_contact_score", "n/a")} +- filler_breakdown: {json.dumps(metrics.get("filler_breakdown") or {}, ensure_ascii=False)} +- session_mode: {metrics.get("session_mode", "Practice")} +- curriculum_day: {metrics.get("curriculum_day", "none")} +- target_keywords: {json.dumps(metrics.get("target_keywords") or [], ensure_ascii=False)} + +Recent history: +{chr(10).join(history_lines) if history_lines else "- no prior sessions"} + +Active user goals: +{chr(10).join(goal_lines) if goal_lines else "- no active goals"} + +{previous} + +Transcript: +{truncated} + +Return this exact JSON shape, with no markdown: +{{ + "summary": "2-3 sentence assessment", + "strengths": [ + {{"area": "pace", "detail": "specific observation", "score_contribution": "why it matters"}} + ], + "improvements": [ + {{"area": "fillers", "detail": "specific next action", "priority": "high", "example": "optional example"}} + ], + "drills": [ + {{"name": "drill name", "description": "steps", "duration_minutes": 5, "targets": "metric"}} + ], + "practice_plan": {{ + "focus_areas": ["area"], + "suggested_duration_minutes": 15, + "difficulty": "beginner", + "next_session_goal": "specific measurable goal" + }}, + "progress_note": "optional progress comment" +}} + +Rules: +- Use 2-4 strengths and 2-4 improvements. +- Use 1-3 drills. +- Cite exact metrics where possible. +- Align the next_session_goal and drills with active user goals when present. +- Difficulty must be beginner, intermediate, or advanced. +- If history is empty, progress_note may be empty. +""" + + +def _strip_timestamps(transcript: str) -> str: + return re.sub(r"^\[\d+(?:\.\d+)?s\]\s*", "", transcript, flags=re.MULTILINE).strip() + + +def generate_builtin_coaching_feedback( + transcript: str, + metrics: dict[str, Any], + history: list[dict[str, Any]], + previous_feedback: dict[str, Any] | None = None, + active_goals: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + started = time.perf_counter() + clean_text = _strip_timestamps(transcript) + lower_text = clean_text.lower() + sentences = [s.strip() for s in re.split(r"[.!?]+", clean_text) if s.strip()] + avg_sentence_words = ( + round(sum(len(s.split()) for s in sentences) / len(sentences), 1) if sentences else 0.0 + ) + opening_line = sentences[0] if sentences else "" + + wpm = float(metrics.get("wpm") or 0) + duration_sec = float(metrics.get("duration_seconds") or 0) + word_count = int(metrics.get("word_count") or 0) + filler_count = int(metrics.get("filler_count") or 0) + filler_rate = float(metrics.get("filler_rate") or 0) + filler_breakdown: dict[str, int] = metrics.get("filler_breakdown") or {} + eye_contact = metrics.get("eye_contact_score") + eye_pct = round(float(eye_contact) * 100) if eye_contact is not None else None + overall_score = float(metrics.get("overall_score") or 0) + speech_score = float(metrics.get("speech_score") or 0) + delivery_score = float(metrics.get("delivery_score") or 0) + vocab_density = float(metrics.get("vocabulary_density") or 0) + pause_count = int(metrics.get("pause_count") or 0) + target_keywords: list[str] = [str(k) for k in (metrics.get("target_keywords") or []) if str(k).strip()] + + transitions_used = [phrase for phrase in TRANSITION_PHRASES if phrase in lower_text] + covered_keywords = [kw for kw in target_keywords if kw.lower() in lower_text] + missing_keywords = [kw for kw in target_keywords if kw.lower() not in lower_text] + + strengths: list[dict[str, str]] = [] + improvements: list[dict[str, str]] = [] + drills: list[dict[str, Any]] = [] + focus_areas: list[str] = [] + + # 1. Pace analysis + if 125 <= wpm <= 160: + strengths.append({ + "area": "Presenter Pace", + "detail": f"Your speaking speed averaged {wpm:.0f} WPM β€” right inside the ideal 125–160 WPM tech keynote window.", + "score_contribution": "Gives viewers enough cognitive room to absorb technical concepts without losing energy.", + }) + elif wpm > 160: + focus_areas.append("pace") + improvements.append({ + "area": "Rushing Pace", + "detail": f"You averaged {wpm:.0f} WPM, which risks compressing technical terms before the audience processes them. Insert a 1-second breath after every key claim.", + "priority": "high", + "example": opening_line[:110] if opening_line else "Pause for one beat right after your headline statement.", + }) + drills.append({ + "name": "140 WPM Beat-Pause Cadence Drill", + "description": "Deliver a 60-second architecture or product explanation using the teleprompter at 1.0x speed, deliberately pausing for one full breath at every period.", + "duration_minutes": 5, + "targets": "wpm", + }) + elif 0 < wpm < 120: + focus_areas.append("pace") + improvements.append({ + "area": "Vocal Momentum", + "detail": f"Your pace averaged {wpm:.0f} WPM. Tightening transitions between sentences toward 130–150 WPM will make your camera presence feel sharper and more authoritative.", + "priority": "medium", + "example": "Start each point with a crisp verb or headline rather than thinking aloud.", + }) + drills.append({ + "name": "60-Second Rapid Tech Briefing", + "description": "Summarize one tech news story in exactly 60 seconds using 3 bullet cues only, aiming for 135 WPM with crisp sentence endings.", + "duration_minutes": 5, + "targets": "wpm", + }) + + # 2. Filler analysis + top_fillers = sorted(filler_breakdown.items(), key=lambda item: item[1], reverse=True)[:3] + top_filler_str = ", ".join(f"'{w}' ({c}x)" for w, c in top_fillers) + if filler_rate <= 2.0 and word_count >= 15: + strengths.append({ + "area": "Clean Articulation", + "detail": f"You held filler words to {filler_rate:.1f}/min ({filler_count} total across {word_count} words), projecting calm authority.", + "score_contribution": "Clean transitions keep technical explanations crisp and credible.", + }) + elif filler_rate > 2.0 or filler_count >= 3: + focus_areas.append("fillers") + detail_suffix = f" Top habits: {top_filler_str}." if top_filler_str else "" + improvements.append({ + "area": "Filler Discipline", + "detail": f"You used {filler_count} fillers ({filler_rate:.1f}/min).{detail_suffix} Replace vocalized thinking with a silent 0.8-second pause while holding lens contact.", + "priority": "high" if filler_rate > 4.5 else "medium", + "example": f"Swap '{top_fillers[0][0]}' for a silent breath before your next clause." if top_fillers else "Close your lips briefly between thoughts instead of bridging with 'um' or 'like'.", + }) + drills.append({ + "name": "Silent Pause Replacement Sprint", + "description": "Speak for 90 seconds on how a favorite developer tool works. Every time you feel an 'um', 'like', or 'basically' coming, stop speaking for one beat, then start the next sentence cleanly.", + "duration_minutes": 6, + "targets": "filler_rate", + }) + + # 3. Camera Presence & Eye Contact + if eye_pct is not None and eye_pct >= 70: + strengths.append({ + "area": "Camera Lens Presence", + "detail": f"Strong lens connection at {eye_pct}% eye contact (delivery score {delivery_score:.0f}/100), making viewers feel spoken to directly.", + "score_contribution": "Direct lens contact builds immediate trust in on-camera tech demos and videos.", + }) + elif eye_pct is not None and eye_pct < 65: + focus_areas.append("eye_contact") + improvements.append({ + "area": "Lens Eye Contact", + "detail": f"Camera eye contact measured {eye_pct}%. Position the built-in teleprompter overlay at the top of the frame right beneath your webcam lens and speak to the lens as if to one engineer.", + "priority": "high" if eye_pct < 45 else "medium", + "example": "Lock onto the lens during your opening hook and your closing takeaway sentence.", + }) + drills.append({ + "name": "Lens-Lock Hook & Takeaway Drill", + "description": "Use Cue-Card teleprompter mode and deliver 3 back-to-back 30-second intros while keeping your eyes anchored on the camera ring for >75% of the take.", + "duration_minutes": 5, + "targets": "eye_contact_score", + }) + + # 4. Storytelling Structure, Clarity & Keywords + if len(transitions_used) >= 2: + strengths.append({ + "area": "Structured Signposting", + "detail": f"Clear narrative signposts ({', '.join(transitions_used[:4])}) guided the listener cleanly from premise to takeaway.", + "score_contribution": "Signposting helps audiences follow complex architecture and product arguments effortlessly.", + }) + else: + focus_areas.append("structure") + improvements.append({ + "area": "Tech Storytelling Structure", + "detail": "Add explicit structural signposts ('Here is the problem...', 'Under the hood...', 'For example...', 'The takeaway is...') so your audience always knows where they are in your story.", + "priority": "medium", + "example": "Open with a 1-sentence hook, state 2–3 numbered pillars, and land a concrete takeaway.", + }) + + if avg_sentence_words > 24: + improvements.append({ + "area": "Sentence Conciseness", + "detail": f"Your sentences averaged {avg_sentence_words:.0f} words. Break compound technical clauses into punchy 12–18 word sentences for stronger on-camera clarity.", + "priority": "medium", + "example": opening_line[:120] if opening_line else "Turn 'and so which means that' chains into crisp standalone statements.", + }) + elif 8 <= avg_sentence_words <= 20 and len(strengths) < 3: + strengths.append({ + "area": "Crisp Sentence Clarity", + "detail": f"Average sentence length of {avg_sentence_words:.0f} words kept your explanation conversational and easy to follow.", + "score_contribution": "Short, direct sentences sound natural on camera and quote well.", + }) + + if target_keywords: + if not missing_keywords: + strengths.append({ + "area": "Topic & Keyword Mastery", + "detail": f"You wove in 100% of your target concepts ({', '.join(covered_keywords[:5])}).", + "score_contribution": "Ensures every key technical takeaway lands as planned.", + }) + elif missing_keywords: + improvements.append({ + "area": "Key Concept Coverage", + "detail": f"Covered {len(covered_keywords)}/{len(target_keywords)} target terms; consider weaving in: {', '.join(missing_keywords[:4])}.", + "priority": "low", + "example": f"Anchor '{missing_keywords[0]}' with a concrete real-world example.", + }) + + # Ensure 2-4 strengths, 2-4 improvements, and 1-3 drills per contract + if len(strengths) < 2: + strengths.append({ + "area": "Consistent Practice Rep", + "detail": f"Completed a {max(1, round(duration_sec))}s on-camera rep ({word_count} words, vocabulary density {vocab_density:.2f}) with {pause_count} natural pause markers.", + "score_contribution": "Daily reps compound rapidly into effortless camera confidence.", + }) + if len(strengths) < 2: + strengths.append({ + "area": "Vocal Engagement", + "detail": f"Delivered an overall session score of {overall_score:.0f}/100 (speech {speech_score:.0f}/100) β€” a solid baseline to sharpen on your next take.", + "score_contribution": "Provides measurable benchmarks for your 30-day presenter progression.", + }) + + if len(improvements) < 2: + improvements.append({ + "area": "Cold-Open Hook Energy", + "detail": "Challenge yourself to make the first 8 seconds 15% more energetic: lead with a surprising stat, sharp problem, or relatable developer moment before diving into details.", + "priority": "low", + "example": opening_line[:110] if opening_line else "Instead of 'Today I want to talk about...', open directly with the stakes.", + }) + if len(improvements) < 2: + improvements.append({ + "area": "Deliberate Emphasis Pauses", + "detail": "Hold a deliberate 1-second pause right before your final takeaway sentence to underline its importance on camera.", + "priority": "low", + "example": "Pause, smile slightly at the lens, then deliver your closing one-liner.", + }) + + if not drills: + drills.append({ + "name": "Hook β†’ Mechanism β†’ Impact Take-Two", + "description": "Re-record the same topic in 90 seconds using 3 bullet cues: (1) 8-second sharp hook, (2) 1 clear analogy or mechanism, (3) 1 actionable takeaway while holding 75%+ lens contact.", + "duration_minutes": 5, + "targets": "overall_score", + }) + + # Align next_session_goal with active user goals when present + unmet_goals = [g for g in (active_goals or []) if not g.get("achieved")] + if unmet_goals: + g0 = unmet_goals[0] + target_desc = ( + f"{g0['target_value']}–{g0['target_upper']}" + if g0.get("comparison") == "between" + else f"{g0.get('comparison')} {g0.get('target_value')}" + ) + next_goal = f"Hit your active '{g0.get('metric_name')}' goal ({target_desc}) on a 90-second camera take." + elif "fillers" in focus_areas: + next_goal = f"Cut filler rate from {filler_rate:.1f}/min to under {max(1.5, round(filler_rate * 0.6, 1))}/min using silent beat-pauses." + elif "pace" in focus_areas: + next_goal = "Hold steady conversational cadence between 130 and 155 WPM across a 2-minute tech explanation." + elif "eye_contact" in focus_areas: + next_goal = "Maintain at least 75% camera lens eye contact while speaking from bullet cues." + else: + next_goal = f"Push overall score from {overall_score:.0f} to {min(98, int(overall_score + 6))}+ with a punchy 8-second hook and crisp signposts." + + # Progress note vs recent history + prior_rows = [row for row in history if row.get("overall_score") is not None] + progress_note = "" + if len(prior_rows) >= 2: + prior_scores = [float(r.get("overall_score") or 0) for r in prior_rows[1:6]] + if prior_scores: + avg_prior = sum(prior_scores) / len(prior_scores) + delta = overall_score - avg_prior + direction = f"up +{delta:.1f} pts" if delta >= 0 else f"{delta:.1f} pts" + progress_note = ( + f"Compared with your recent {len(prior_scores)}-session average ({avg_prior:.1f}/100), " + f"this session scored {overall_score:.1f}/100 ({direction})." + ) + + difficulty = "advanced" if overall_score >= 80 else "intermediate" if overall_score >= 58 else "beginner" + mode_label = metrics.get("session_mode") or "presenting" + day_badge = f" (30-Day Curriculum Day {metrics['curriculum_day']})" if metrics.get("curriculum_day") else "" + summary = ( + f"Solid {mode_label} take{day_badge} scoring {overall_score:.0f}/100 at {wpm:.0f} WPM " + f"with {filler_count} filler{'s' if filler_count != 1 else ''} ({filler_rate:.1f}/min)" + + (f" and {eye_pct}% lens eye contact." if eye_pct is not None else ".") + + f" Your strongest asset was {strengths[0]['area'].lower()}, while your highest-leverage next upgrade is {improvements[0]['area'].lower()}." + ) + + payload = { + "summary": summary, + "strengths": strengths[:4], + "improvements": improvements[:4], + "drills": drills[:3], + "practice_plan": { + "focus_areas": (focus_areas or ["storytelling", "camera_presence"])[:3], + "suggested_duration_minutes": 15, + "difficulty": difficulty, + "next_session_goal": next_goal, + }, + "progress_note": progress_note, + } + raw_json = json.dumps(payload, ensure_ascii=False) + payload["raw_response"] = raw_json + payload["model_used"] = "builtin-presenter-coach" + payload["generation_time_ms"] = max(1, int((time.perf_counter() - started) * 1000)) + return payload + + +def generate_coaching_feedback( + transcript: str, + metrics: dict[str, Any], + history: list[dict[str, Any]], + previous_feedback: dict[str, Any] | None = None, + active_goals: list[dict[str, Any]] | None = None, +) -> dict[str, Any]: + provider = settings.coaching_provider.lower() + if provider == "builtin": + return generate_builtin_coaching_feedback(transcript, metrics, history, previous_feedback, active_goals) + + prompt = build_coaching_prompt(transcript, metrics, history, previous_feedback, active_goals) + started = time.perf_counter() + + if provider == "auto": + try: + raw_text = _call_ollama(prompt, connect_timeout=1.5) + parsed = parse_coaching_response(raw_text) + parsed["raw_response"] = raw_text + parsed["model_used"] = settings.coaching_model + parsed["generation_time_ms"] = int((time.perf_counter() - started) * 1000) + return parsed + except Exception: + return generate_builtin_coaching_feedback(transcript, metrics, history, previous_feedback, active_goals) + + if provider not in {"ollama", "lmstudio", "llamacpp"}: + raise RuntimeError(f"Unsupported coaching provider '{settings.coaching_provider}'") + + try: + if provider == "ollama": + raw_text = _call_ollama(prompt) + else: + raw_text = _call_openai_compatible(prompt, provider) + parsed = parse_coaching_response(raw_text) + parsed["raw_response"] = raw_text + parsed["model_used"] = settings.coaching_model + parsed["generation_time_ms"] = int((time.perf_counter() - started) * 1000) + return parsed + except Exception: + if settings.coaching_fallback_builtin: + return generate_builtin_coaching_feedback(transcript, metrics, history, previous_feedback, active_goals) + raise + + +def parse_coaching_response(raw_text: str) -> dict[str, Any]: + cleaned = raw_text.strip() + if cleaned.startswith("```"): + cleaned = cleaned.split("\n", 1)[1] if "\n" in cleaned else cleaned[3:] + if cleaned.endswith("```"): + cleaned = cleaned[:-3] + cleaned = cleaned.strip() + start = cleaned.find("{") + end = cleaned.rfind("}") + if start >= 0 and end > start: + cleaned = cleaned[start:end + 1] + try: + parsed = json.loads(cleaned) + except json.JSONDecodeError as exc: + raise RuntimeError(f"Local LLM returned invalid JSON: {exc}") from exc + missing = REQUIRED_KEYS - set(parsed) + if missing: + raise RuntimeError(f"Local LLM response missing required keys: {', '.join(sorted(missing))}") + parsed.setdefault("progress_note", "") + return parsed + + +def _call_ollama(prompt: str, connect_timeout: float = 3.0) -> str: + url = f"{settings.ollama_base_url.rstrip('/')}/api/generate" + response = requests.post( + url, + json={ + "model": settings.coaching_model, + "prompt": prompt, + "stream": False, + "format": "json", + }, + timeout=(connect_timeout, settings.coaching_timeout_seconds), + ) + response.raise_for_status() + payload = response.json() + text = payload.get("response") + if not isinstance(text, str) or not text.strip(): + raise RuntimeError("Ollama returned an empty coaching response") + return text + + +def _call_openai_compatible(prompt: str, provider: str) -> str: + base_url = settings.lmstudio_base_url if provider == "lmstudio" else settings.llama_cpp_base_url + url = f"{base_url.rstrip('/')}/v1/chat/completions" + response = requests.post( + url, + json={ + "model": settings.coaching_model, + "temperature": 0.3, + "messages": [{"role": "user", "content": prompt}], + }, + timeout=(3.0, settings.coaching_timeout_seconds), + ) + response.raise_for_status() + payload = response.json() + try: + text = payload["choices"][0]["message"]["content"] + except (KeyError, IndexError, TypeError) as exc: + raise RuntimeError("OpenAI-compatible provider returned an invalid response") from exc + if not isinstance(text, str) or not text.strip(): + raise RuntimeError("OpenAI-compatible provider returned an empty coaching response") + return text diff --git a/apps/api/src/app/services/curriculum.py b/apps/api/src/app/services/curriculum.py new file mode 100644 index 0000000000000000000000000000000000000000..a4a6ebb83fb20fa59b6a5523e9b9c8af2cfdf5fa --- /dev/null +++ b/apps/api/src/app/services/curriculum.py @@ -0,0 +1,1365 @@ +from __future__ import annotations + +from datetime import date, datetime +from typing import Any + +from sqlalchemy.orm import Session + +from app import models + +FOCUS_TRACK_LABELS = { + "tech_storytelling": "Engaging Tech Storytelling & YouTube/Creator Presence", + "keynote_clarity": "Conference Keynote & Architecture Clarity", + "camera_confidence": "On-Camera Confidence, Pace & Zero Fillers", + "executive_pitch": "Founder / Product Demo & Executive Pitching", +} + +CURRICULUM_DAYS: list[dict[str, Any]] = [ + # ─── WEEK 1: Camera Presence & Vocal Foundation ─── + { + "day": 1, + "week": 1, + "week_theme": "Camera Presence & Vocal Foundation", + "title": "Baseline 60-Second Tech Intro", + "skill_focus": "Baseline Assessment", + "duration_sec": 60, + "mode": "Pitch", + "goal": "Establish baseline", + "warm_up": "Take 3 slow 4-second nasal breaths, roll your shoulders back, and speak 'Crisp code, clear story, calm lens' three times with chest resonance.", + "challenge": "Look directly into the camera lens for 60 seconds and introduce who you are, what technology problem excites you right now, and what you are building or learning.", + "teleprompter_script": ( + "Hi, I am practicing daily to become a sharper, more engaging technology presenter.\n\n" + "Right now, the area of technology that excites me most is how modern software tools turn complex ideas into products anyone can use.\n\n" + "Over the next thirty days, I am focusing on speaking with a steady rhythm, clean pauses instead of filler words, and natural eye contact right into the lens.\n\n" + "Here is one project I am working on todayβ€”and why it matters." + ), + "outline_cues": [ + "0:00 β€” Direct lens smile + crisp 1-sentence who-you-are hook", + "0:15 β€” One tech problem or shift you care deeply about", + "0:35 β€” What you are currently building, shipping, or exploring", + "0:50 β€” Clean closing takeaway + hold lens contact for 1 beat", + ], + "target_keywords": ["building", "technology", "problem", "matters"], + }, + { + "day": 2, + "week": 1, + "week_theme": "Camera Presence & Vocal Foundation", + "title": "Talking to the Lens Like One Engineer", + "skill_focus": "Eye Contact & Warmth", + "duration_sec": 75, + "mode": "Storytelling", + "goal": "Stronger eye contact", + "warm_up": "Place a small mental target on the physical camera ring. Smile lightly before pressing record so your opening frame starts warm.", + "challenge": "Explain your favorite programming language, framework, or developer tool as if recommending it across a coffee table to one trusted colleagueβ€”holding >75% lens contact.", + "teleprompter_script": ( + "If we sat down for coffee and you asked me which tool actually saves me hours every single week, here is my honest answer.\n\n" + "Most tools promise productivity, however they quietly add configuration overhead.\n\n" + "What makes my go-to tool different is speed of feedback. First, you see results in seconds. Second, errors tell you how to fix them.\n\n" + "So if you have not tried it on a real weekend build yet, start smallβ€”you will feel the difference immediately." + ), + "outline_cues": [ + "Hook: 'If you asked me which tool actually saves me hours every week...'", + "Contrast: Why most alternatives feel heavy or slow", + "First & Second concrete moments where this tool shines", + "Takeaway: One small way the viewer should try it today", + ], + "target_keywords": ["first", "second", "however", "because"], + }, + { + "day": 3, + "week": 1, + "week_theme": "Camera Presence & Vocal Foundation", + "title": "Replacing Fillers with Power Pauses", + "skill_focus": "Filler Elimination", + "duration_sec": 90, + "mode": "Interview", + "goal": "Reduce fillers", + "warm_up": "Read 4 sentences out loud and intentionally freeze silently for 1 full second at every periodβ€”lips closed, shoulders relaxed.", + "challenge": "Explain how an API request travels from a phone screen to a database and back. Whenever you feel 'um', 'uh', or 'like' rising, substitute a silent 1-second pause.", + "teleprompter_script": ( + "Every time you tap a button in a mobile app, a remarkably fast relay race happens behind the scenes.\n\n" + "First, your phone packages that tap into an HTTPS request. [Pause]\n\n" + "Second, an API gateway checks your identity and routes the call to the right backend service. [Pause]\n\n" + "Third, the service queries the database, shapes only the fields you need, and streams JSON back in milliseconds.\n\n" + "Therefore, great app speed is rarely magicβ€”it is clean boundaries at every hop." + ), + "outline_cues": [ + "Hook: Tap-to-screen relay race metaphor", + "Step 1: Client packages request [1s silent pause]", + "Step 2: Gateway auth + routing [1s silent pause]", + "Step 3: Database query + crisp takeaway", + ], + "target_keywords": ["first", "second", "third", "therefore", "request"], + }, + { + "day": 4, + "week": 1, + "week_theme": "Camera Presence & Vocal Foundation", + "title": "140 WPM Keynote Cadence Control", + "skill_focus": "Speaking Pace", + "duration_sec": 90, + "mode": "Reading", + "goal": "Improve pace", + "warm_up": "Hum gently on an exhale for 8 seconds, then count 1-to-10 at a relaxed, deliberate one-number-per-half-second tempo.", + "challenge": "Deliver a 90-second product update staying inside 125–155 WPMβ€”fast enough to hold attention, unhurried enough that technical nouns land crisply.", + "teleprompter_script": ( + "Today we are shipping an update that cuts cold-start latency in half for every user on mobile connections.\n\n" + "For months, we noticed that first-time loads felt sluggish on real-world cellular networks, even though our lab benchmarks looked green.\n\n" + "Because we trimmed bundle bloat and moved heavy parsing off the main thread, pages now become interactive in under one second.\n\n" + "Here is what that unlocks for your daily workflow starting right now." + ), + "outline_cues": [ + "Headline metric right up front (cut latency in half)", + "Real-world problem vs lab benchmark gap", + "Two crisp changes that made it possible", + "What users will feel immediately today", + ], + "target_keywords": ["latency", "because", "interactive", "workflow"], + }, + { + "day": 5, + "week": 1, + "week_theme": "Camera Presence & Vocal Foundation", + "title": "Plain-English Jargon Translation", + "skill_focus": "Clarity & Simplicity", + "duration_sec": 90, + "mode": "Storytelling", + "goal": "Reduce fillers", + "warm_up": "Pick one buzzword ('distributed cache', 'vector embedding', 'zero-knowledge proof') and state what it does in 10 one-syllable words.", + "challenge": "Explain caching, indexing, or AI embeddings on camera so clearly that both a smart product manager and a senior engineer nod along.", + "teleprompter_script": ( + "Think of a database index like the alphabetical tabs at the back of a thick cookbook.\n\n" + "Without an index, finding one recipe means flipping through all five hundred pages every single time someone gets hungry.\n\n" + "With an index, you jump straight to the exact page in two steps. However, every extra index costs a tiny bit of work whenever you add a new recipe.\n\n" + "So the real engineering craft is indexing what people search constantlyβ€”and leaving the rest lean." + ), + "outline_cues": [ + "Everyday visual analogy in sentence one", + "What happens without it (the painful slow path)", + "What happens with it + the honest engineering tradeoff", + "One-sentence rule of thumb to remember", + ], + "target_keywords": ["index", "however", "so", "tradeoff"], + }, + { + "day": 6, + "week": 1, + "week_theme": "Camera Presence & Vocal Foundation", + "title": "Breaking Monotone Technical Delivery", + "skill_focus": "Vocal Variety & Energy", + "duration_sec": 90, + "mode": "Pitch", + "goal": "Stronger eye contact", + "warm_up": "Say the sentence 'We cut build time from twenty minutes to forty seconds' three ways: whispered curiosity, bold emphasis on numbers, and warm celebration.", + "challenge": "Present a dramatic 'Before vs. After' technical improvement using deliberate pitch contrast on numbers and contrast words.", + "teleprompter_script": ( + "Twenty-two minutes. That is how long every engineer on our team used to wait just to know if a one-line fix passed CI.\n\n" + "People lost focus, switched tabs, and batched risky changes together.\n\n" + "Then we parallelized test shards and cached build layers. Twenty-two minutes dropped to forty-five seconds.\n\n" + "Suddenly, shipping stopped feeling like waiting at an airportβ€”and started feeling like creative flow again." + ), + "outline_cues": [ + "Punchy number opener with vocal weight ('Twenty-two minutes.')", + "Human frustration of the 'Before' state", + "The technical unlock + dramatic 'After' number", + "Emotional payoff: what changed for the humans", + ], + "target_keywords": ["minutes", "seconds", "because", "flow"], + }, + { + "day": 7, + "week": 1, + "week_theme": "Camera Presence & Vocal Foundation", + "title": "Week 1 Checkpoint: 2-Min Architecture Walkthrough", + "skill_focus": "Integrated Presence", + "duration_sec": 120, + "mode": "Pitch", + "goal": "Improve pace", + "warm_up": "20-second posture check + lens framing check (eyes near top third of frame) + 2 deep breaths.", + "challenge": "Deliver a crisp 2-minute walkthrough of a system or app you know wellβ€”combining steady 135 WPM pace, <2 fillers/min, and >70% lens eye contact.", + "teleprompter_script": ( + "Let me walk you through how our system turns a raw user recording into actionable coaching in under a minute.\n\n" + "First, the browser captures live video, audio loudness, and facial landmarks locally so real-time feedback feels instant.\n\n" + "Second, when you save a take, the API validates the recording and stores session metrics cleanly.\n\n" + "Third, our analysis pipeline scores speaking pace, filler habits, and structure, then generates targeted drills for your very next rep.\n\n" + "Therefore, every single practice session compounds into measurable progress." + ), + "outline_cues": [ + "Promise hook: What the system accomplishes end-to-end", + "First layer: Client-side capture & real-time signals", + "Second layer: API validation & reliable persistence", + "Third layer: Scoring + coaching loop + closing takeaway", + ], + "target_keywords": ["first", "second", "third", "therefore", "system"], + }, + # ─── WEEK 2: Clarity, Structure & Technical Explanations ─── + { + "day": 8, + "week": 2, + "week_theme": "Clarity, Structure & Technical Explanations", + "title": "The 3-Point Rule for Technical Talks", + "skill_focus": "Structural Signposting", + "duration_sec": 105, + "mode": "Pitch", + "goal": "Improve pace", + "warm_up": "Hold up 1, 2, 3 fingers off-camera as you rehearse your three transitions out loud.", + "challenge": "Explain why a team should adopt (or avoid) a specific technology using strict 3-pillar signposting: Headline β†’ First, Second, Third β†’ Bottom line.", + "teleprompter_script": ( + "Before migrating any production backend, evaluate three concrete signalsβ€”not hype.\n\n" + "First, look at operational simplicity: can an on-call engineer debug it at 2 a.m.?\n\n" + "Second, measure ecosystem maturity: are drivers, observability, and hiring proven?\n\n" + "Third, calculate migration cost versus customer value: does this directly improve reliability or shipping velocity?\n\n" + "Thus, when all three align, move decisively; otherwise, invest in your core product." + ), + "outline_cues": [ + "Headline rule: 3 signals before any migration", + "First: 2 a.m. debuggability", + "Second: Ecosystem & observability maturity", + "Third: Customer value vs migration tax + Bottom line", + ], + "target_keywords": ["first", "second", "third", "thus", "reliability"], + }, + { + "day": 9, + "week": 2, + "week_theme": "Clarity, Structure & Technical Explanations", + "title": "Hooking Viewers in the First 8 Seconds", + "skill_focus": "Cold-Open Hooks", + "duration_sec": 75, + "mode": "Storytelling", + "goal": "Stronger eye contact", + "warm_up": "Eliminate throat-clearing openers ('Hey guys, so today I wanted to...'). Start directly on the tension!", + "challenge": "Start your recording with zero preambleβ€”deliver an 8-second cold-open hook about a surprising software myth, then back it up in 60 seconds.", + "teleprompter_script": ( + "Adding more servers made our application slowerβ€”not faster. Here is why.\n\n" + "When traffic spiked last quarter, we doubled our compute instances, expecting response times to drop.\n\n" + "However, every new instance opened fresh connections against a single unpooled database, saturating locks within seconds.\n\n" + "Once we added connection pooling and read replicas, latency dropped eighty percent on half the hardware.\n\n" + "So before scaling horizontally, always find the shared bottleneck first." + ), + "outline_cues": [ + "0:00–0:08 Cold open paradox ('Adding more servers made us slower.')", + "0:08–0:30 What we expected vs what broke", + "0:30–0:55 Connection pooling fix & exact metric win", + "0:55–1:15 Memorable engineering principle", + ], + "target_keywords": ["however", "so", "first", "latency", "database"], + }, + { + "day": 10, + "week": 2, + "week_theme": "Clarity, Structure & Technical Explanations", + "title": "Explaining Tradeoffs Without Rambling", + "skill_focus": "Concise Reasoning", + "duration_sec": 120, + "mode": "Debate", + "goal": "Reduce fillers", + "warm_up": "Practice the crisp formula: 'Option A wins when X; Option B wins when Y; for our constraints, choose A because Z.'", + "challenge": "Compare SQL vs NoSQL, Monolith vs Microservices, or Local vs Cloud AI in 2 minutes without circling back or repeating yourself.", + "teleprompter_script": ( + "Choosing between local on-device AI and cloud frontier models comes down to three tradeoffs: privacy, latency, and reasoning depth.\n\n" + "First, local models win when sensitive audio or documents cannot leave the user's machine and sub-second offline response matters.\n\n" + "However, cloud models win when tasks require broad world knowledge and multi-step synthesis across huge contexts.\n\n" + "Therefore, the smartest modern apps combine both: fast local models for instant feedback, paired with cloud reasoning only when needed." + ), + "outline_cues": [ + "Frame the 3 axes up front (privacy, latency, depth)", + "When Path A wins decisively", + "However β€” when Path B wins decisively", + "Therefore β€” clear hybrid recommendation", + ], + "target_keywords": ["first", "however", "therefore", "latency", "models"], + }, + { + "day": 11, + "week": 2, + "week_theme": "Clarity, Structure & Technical Explanations", + "title": "Visual Analogies for Invisible Systems", + "skill_focus": "Mental Models", + "duration_sec": 90, + "mode": "Storytelling", + "goal": "Improve pace", + "warm_up": "Use slight hand gestures inside the chest-to-shoulder frame to illustrate spatial relationships.", + "challenge": "Use a vivid physical-world analogy to explain load balancing, rate limiting, Kubernetes, or Git branching on camera.", + "teleprompter_script": ( + "Rate limiting works just like the metered green light on a busy highway on-ramp during rush hour.\n\n" + "If five hundred cars surge onto the freeway at the exact same second, every lane grinds to a standstill for miles.\n\n" + "By spacing entries one car every two seconds, traffic on the main highway keeps flowing at sixty miles per hour.\n\n" + "Similarly, an API rate limiter protects every customer's experience when one noisy client suddenly spikes." + ), + "outline_cues": [ + "Concrete real-world picture (highway ramp meter)", + "What breaks without it (gridlock)", + "How the mechanism restores smooth flow", + "Bridge back to software architecture", + ], + "target_keywords": ["rate", "traffic", "api", "protects"], + }, + { + "day": 12, + "week": 2, + "week_theme": "Clarity, Structure & Technical Explanations", + "title": "Teleprompter Naturalness Mastery", + "skill_focus": "Conversational Script Reading", + "duration_sec": 90, + "mode": "Reading", + "goal": "Stronger eye contact", + "warm_up": "Set teleprompter font size comfortable near the top of your screen so your eyes barely move left-to-right.", + "challenge": "Read today's scripted product announcement using Auto-Scroll Teleprompter mode while sounding 100% spontaneousβ€”using micro-smiles, eyebrow lifts, and natural breaths.", + "teleprompter_script": ( + "Have you ever recorded a great product demo, watched it back, and realized you stared six inches below the lens the entire time?\n\n" + "We built an on-lens studio teleprompter right into your browser to solve that exact problem.\n\n" + "Because your script sits right beneath your camera, your eyes stay locked with your audience while your pacing stays effortless.\n\n" + "Try one ninety-second take todayβ€”and watch how much more confident your playback looks." + ), + "outline_cues": [ + "Conversational question hook with warm eyebrow lift", + "Announce the solution cleanly", + "Explain why it feels natural on playback", + "Friendly direct invitation to try it", + ], + "target_keywords": ["because", "camera", "script", "confident"], + }, + { + "day": 13, + "week": 2, + "week_theme": "Clarity, Structure & Technical Explanations", + "title": "Answering Tough Technical Questions on Camera", + "skill_focus": "Composure Under Pressure", + "duration_sec": 90, + "mode": "Interview", + "goal": "Reduce fillers", + "warm_up": "Practice the 2-second 'Acknowledge + Direct Answer' pause: nod once, inhale, state the answer in sentence one.", + "challenge": "Answer a skeptical questionβ€”'Why won't big incumbents crush your product in six months?' or 'What is the biggest flaw in your architecture?'β€”with calm honesty.", + "teleprompter_script": ( + "That is a fair question, and the honest answer is that every architecture has a ceiling if traffic grows tenfold.\n\n" + "First, our current single-region database keeps latency low and iteration fast for our current scale.\n\n" + "However, we already isolated tenant writes behind clean queue boundaries so we can shard horizontally before contention hits.\n\n" + "So we are intentionally optimizing for learning speed today without painting ourselves into a corner tomorrow." + ), + "outline_cues": [ + "Calm acknowledgment + direct answer in sentence 1", + "First: Why today's design is deliberately pragmatic", + "However: The escape hatch already built in", + "So: Strategic takeaway with steady lens contact", + ], + "target_keywords": ["first", "however", "so", "latency", "architecture"], + }, + { + "day": 14, + "week": 2, + "week_theme": "Clarity, Structure & Technical Explanations", + "title": "Week 2 Checkpoint: 3-Minute Live Product Demo Pitch", + "skill_focus": "Structured Demo Delivery", + "duration_sec": 150, + "mode": "Pitch", + "goal": "Improve pace", + "warm_up": "Review your Week 1 vs Week 2 filler rate on Progress, then set 1 intention for this checkpoint take.", + "challenge": "Deliver a 2.5-minute product demo pitch: Hook β†’ User Pain β†’ How It Works (3 steps) β†’ Proof/Metric β†’ Clear Call to Action.", + "teleprompter_script": ( + "Great engineers often build extraordinary softwareβ€”yet lose the room in the first thirty seconds when presenting it on camera.\n\n" + "The problem is rarely knowledge; it is lack of structured daily feedback on pace, filler habits, and lens presence.\n\n" + "First, our studio pairs an on-lens teleprompter with live WPM and eye-contact cues.\n\n" + "Second, every saved session generates concrete coaching drills tailored to your exact bottlenecks.\n\n" + "Third, weekly comparisons let you watch your Day 1 self side-by-side against today.\n\n" + "Start with just one daily takeβ€”and turn presenting into your unfair career advantage." + ), + "outline_cues": [ + "0:00 Hook: Great builders losing the room on camera", + "0:25 Root problem: No tight feedback loop", + "0:50 First, Second, Third product pillars", + "2:00 Closing vision & crisp call to action", + ], + "target_keywords": ["first", "second", "third", "problem", "feedback"], + }, + # ─── WEEK 3: Technology News & Engaging Storytelling ─── + { + "day": 15, + "week": 3, + "week_theme": "Technology News & Engaging Storytelling", + "title": "60-Second Breaking Tech News Briefing", + "skill_focus": "News Synthesis", + "duration_sec": 60, + "mode": "Storytelling", + "goal": "Improve pace", + "warm_up": "Open the Tech News tab, pick a fresh story, or use today's built-in AI infrastructure briefing.", + "challenge": "Deliver a sharp 60-second tech news segment: What just happened β†’ Why builders should care β†’ Your 1-sentence take.", + "teleprompter_script": ( + "A major shift is happening in AI engineering this year: inference cost per token has fallen nearly tenfold, yet total compute spend is climbing.\n\n" + "Why? Because teams are moving from single chat prompts to multi-step background agents that verify code, run tests, and query tools autonomously.\n\n" + "For developers, this means latency budgets and evaluation harnesses now matter far more than prompt tricks.\n\n" + "My take: the winners next year will be the teams with the cleanest verification loops." + ), + "outline_cues": [ + "Headline shift: Inference cheaper, total agent compute rising", + "Why it is happening (single prompt -> multi-step tool loops)", + "What changes for builders right now", + "Sharp personal prediction in the final 10 seconds", + ], + "target_keywords": ["because", "inference", "agents", "latency"], + }, + { + "day": 16, + "week": 3, + "week_theme": "Technology News & Engaging Storytelling", + "title": "The Engineering Postmortem War Story", + "skill_focus": "Narrative Tension", + "duration_sec": 120, + "mode": "Storytelling", + "goal": "Stronger eye contact", + "warm_up": "Great tech stories need stakes! State the exact moment you knew something was wrong in your first two sentences.", + "challenge": "Tell a 2-minute story about a tricky bug, outage, or failed experiment: The Stakes β†’ The Red Herring β†’ The Root Cause β†’ The Lesson.", + "teleprompter_script": ( + "At 4:55 p.m. on a Friday launch, our checkout dashboard suddenly flatlined to zero ordersβ€”while CPU and memory looked completely normal.\n\n" + "At first, everyone suspected the payment gateway. However, test charges succeeded in sandbox.\n\n" + "Twenty minutes in, we inspected a tiny CSS deployment and realized an invisible modal overlay was intercepting mobile taps on Safari.\n\n" + "We rolled back in ninety seconds, and orders surged back. Therefore, we added real-browser synthetic click tests to every deploy." + ), + "outline_cues": [ + "Scene & stakes (Friday launch, zero orders, green metrics)", + "The red herring we chased first", + "The surprising root cause discovery", + "The permanent guardrail lesson", + ], + "target_keywords": ["first", "however", "therefore", "launch"], + }, + { + "day": 17, + "week": 3, + "week_theme": "Technology News & Engaging Storytelling", + "title": "Contrarian Tech Opinion with Evidence", + "skill_focus": "Persuasive Point of View", + "duration_sec": 105, + "mode": "Debate", + "goal": "Reduce fillers", + "warm_up": "State a bold opinion calmlyβ€”persuasion on camera comes from measured certainty, not volume.", + "challenge": "Challenge a popular tech consensus (e.g., 'Boring tech beats bleeding-edge stacks for 90% of startups') backed by concrete evidence and fair nuance.", + "teleprompter_script": ( + "Unpopular engineering opinion: choosing 'boring' proven technology is the fastest way to build a radical product.\n\n" + "First, every hour you spend debugging an alpha framework is an hour stolen from talking to users.\n\n" + "Second, battle-tested databases and web frameworks have decades of documented failure modes.\n\n" + "However, save your innovation budget for the one core algorithm or UX workflow that truly differentiates you.\n\n" + "So keep your plumbing boringβ€”so your product can be bold." + ), + "outline_cues": [ + "Bold contrarian thesis in sentence 1", + "First & Second concrete proofs", + "Fair nuance ('However, where you SHOULD innovate')", + "Quotable closing line ('Keep plumbing boring so product is bold')", + ], + "target_keywords": ["first", "second", "however", "so", "product"], + }, + { + "day": 18, + "week": 3, + "week_theme": "Technology News & Engaging Storytelling", + "title": "Founder & Builder Origin Story Arc", + "skill_focus": "Authentic Connection", + "duration_sec": 120, + "mode": "Storytelling", + "goal": "Stronger eye contact", + "warm_up": "Recall the exact personal frustration that sparked a project you care about.", + "challenge": "Share the origin story behind something you built or why you entered tech: Frustration β†’ Spark β†’ First Prototype β†’ Where It's Going.", + "teleprompter_script": ( + "The best developer tools almost always start as a personal scratch you could not ignore.\n\n" + "A year ago, I dreaded watching my own recorded tech talks because nobody teaches engineers how to speak naturally to a glass camera lens.\n\n" + "So I hacked together a weekend prototype that tracked my speaking pace and filler words right inside the browser.\n\n" + "Today, that weekend experiment guides my daily practiceβ€”because confidence on camera is a trainable skill, not a personality trait." + ), + "outline_cues": [ + "Universal truth hook", + "Personal moment of frustration", + "The scrappy weekend prototype", + "Mission statement: why it matters for others", + ], + "target_keywords": ["so", "because", "camera", "prototype", "confidence"], + }, + { + "day": 19, + "week": 3, + "week_theme": "Technology News & Engaging Storytelling", + "title": "90-Second Live Headline Synthesis", + "skill_focus": "Bullet-Cue Fluency", + "duration_sec": 90, + "mode": "Storytelling", + "goal": "Reduce fillers", + "warm_up": "Switch the Studio Teleprompter to 'Cue Cards' mode so you speak from 4 bullets instead of reading full sentences!", + "challenge": "Speak ONLY from the 4 bullet cues (no word-for-word reading!) to break down a major trend in developer tooling, cloud, or AI.", + "teleprompter_script": ( + "Speak freely from your 4 bullet cues today:\n" + "1. State the headline trend in plain English.\n" + "2. Give one concrete developer workflow example.\n" + "3. Name one risk or hype trap to watch out for.\n" + "4. Close with what you are testing personally this week." + ), + "outline_cues": [ + "1. Headline trend in 1 crisp sentence", + "2. Concrete developer workflow example ('For instance...')", + "3. Honest risk or hype trap ('However...')", + "4. What I am testing next ('Therefore...')", + ], + "target_keywords": ["however", "therefore", "workflow", "testing"], + }, + { + "day": 20, + "week": 3, + "week_theme": "Technology News & Engaging Storytelling", + "title": "Human Impact Behind Technical Specs", + "skill_focus": "Audience Empathy", + "duration_sec": 105, + "mode": "Pitch", + "goal": "Stronger eye contact", + "warm_up": "For every technical metric ('99.99% uptime', '40KB bundle'), ask: 'Whose Tuesday afternoon gets better because of this?'", + "challenge": "Turn three dry technical specs into human storiesβ€”connecting engineering craft to real people on slow phones, tight budgets, or high-stakes jobs.", + "teleprompter_script": ( + "When we say we trimmed our web app bundle by eighty percent, that sounds like a dry benchmark.\n\n" + "However, in the real world, that means a founder checking analytics on a congested train or a student on a budget phone gets instant answers instead of a blank spinner.\n\n" + "First, offline-first caching respects unreliable networks. Second, responsive layouts respect small screens.\n\n" + "Ultimately, performance engineering is empathy written in code." + ), + "outline_cues": [ + "Start with the dry technical spec", + "Translate it immediately to a human moment", + "First & Second ways software respects the user", + "Closing line: 'Performance engineering is empathy in code'", + ], + "target_keywords": ["however", "first", "second", "performance"], + }, + { + "day": 21, + "week": 3, + "week_theme": "Technology News & Engaging Storytelling", + "title": "Week 3 Checkpoint: 3-Min Tech Show Segment", + "skill_focus": "Broadcast Storytelling", + "duration_sec": 180, + "mode": "Storytelling", + "goal": "Improve pace", + "warm_up": "Pick a headline from Tech News, generate a 2-Min Explainer Storyboard, and customize your personal angle.", + "challenge": "Host a complete 3-minute technology commentary segment as if recording for YouTube or a company all-handsβ€”combining hook, story, analogy, and sharp takeaway.", + "teleprompter_script": ( + "Welcome back. Today we are unpacking one of the biggest shifts in modern software developmentβ€”and what it actually means for how we build.\n\n" + "First, let us look at what just changed beneath the surface.\n\n" + "Second, think of the underlying architecture through a simple mental model so we separate real signal from marketing noise.\n\n" + "However, every new capability introduces fresh failure modes around reliability and security.\n\n" + "Therefore, here are the two practical habits worth adopting on your projects this month." + ), + "outline_cues": [ + "0:00 Broadcast cold open + promise to the viewer", + "0:30 First: What changed + concrete facts", + "1:15 Second: Analogy that makes the mechanism click", + "2:00 However / Therefore: Risks + 2 actionable takeaways", + ], + "target_keywords": ["first", "second", "however", "therefore", "architecture"], + }, + # ─── WEEK 4: High-Stakes Presenting, Polish & Signature Style ─── + { + "day": 22, + "week": 4, + "week_theme": "High-Stakes Presenting, Polish & Signature Style", + "title": "90-Second Executive Engineering Briefing", + "skill_focus": "Bottom-Line-Up-Front (BLUF)", + "duration_sec": 90, + "mode": "Pitch", + "goal": "Improve pace", + "warm_up": "Executives decide in 20 seconds: state Recommendation β†’ Impact β†’ Risk Mitigation β†’ Ask.", + "challenge": "Pitch a technical roadmap decision in 90 seconds using Bottom-Line-Up-Front (BLUF) clarity.", + "teleprompter_script": ( + "My recommendation is to dedicate two weeks this sprint to automated end-to-end checkout reliability before launching our new tier.\n\n" + "First, last month's regressions cost twelve hours of engineering firefighting and delayed two releases.\n\n" + "Second, a deterministic test suite cuts release verification from two hours to six minutes.\n\n" + "Therefore, investing ten days now protects revenue on launch day and accelerates every sprint after." + ), + "outline_cues": [ + "Sentence 1: Clear recommendation up front", + "First: Cost of status quo in hours/risk", + "Second: Measurable speedup from proposed investment", + "Therefore: Strategic payoff + crisp ask", + ], + "target_keywords": ["first", "second", "therefore", "reliability"], + }, + { + "day": 23, + "week": 4, + "week_theme": "High-Stakes Presenting, Polish & Signature Style", + "title": "Pattern Interrupts & Re-Engaging Attention", + "skill_focus": "Audience Retention", + "duration_sec": 120, + "mode": "Storytelling", + "goal": "Stronger eye contact", + "warm_up": "Plan a deliberate 'reset phrase' at 0:45 ('Now pause and look at what happens next...').", + "challenge": "Deliver a 2-minute technical talk that intentionally resets audience attention at the 45-second and 90-second marks using rhetorical questions and pace shifts.", + "teleprompter_script": ( + "Most presentations lose half the room right around the forty-five-second markβ€”not because the topic is dull, but because the rhythm never changes.\n\n" + "First, establish your core technical premise clearly and briskly.\n\n" + "Now pause for a second: what actually happens when real users hit this system at peak load?\n\n" + "That single question wakes up every listener's brain because curiosity demands an answer.\n\n" + "So vary your cadence, ask sharp questions, and earn attention every forty-five seconds." + ), + "outline_cues": [ + "0:00 Premise setup at brisk 145 WPM", + "0:45 Pattern interrupt: Slow down + ask sharp question", + "1:15 Concrete technical payoff", + "1:45 Crisp closing principle", + ], + "target_keywords": ["first", "because", "so", "system"], + }, + { + "day": 24, + "week": 4, + "week_theme": "High-Stakes Presenting, Polish & Signature Style", + "title": "Graceful Recovery When Losing Your Place", + "skill_focus": "Live Resilience", + "duration_sec": 90, + "mode": "Interview", + "goal": "Reduce fillers", + "warm_up": "Practice bridging calmly without apologizing: 'Let me zoom back out to the core point...'", + "challenge": "Speak impromptu on a moderately complex tech topic; mid-way through, intentionally stop for 2 seconds, smile calmly, use a clean bridge phrase, and finish strong with zero 'um/sorry'.", + "teleprompter_script": ( + "Even seasoned keynote speakers occasionally lose a detail mid-sentence under bright studio lights.\n\n" + "Amateurs apologize, look down, and fill the air with 'um, sorry, wait'.\n\n" + "Professionals simply pause, breathe, look squarely into the lens, and bridge with: 'Here is the core point that matters most.'\n\n" + "Because the audience does not have your script, calm silence always looks intentional." + ), + "outline_cues": [ + "Open impromptu on how a favorite system works", + "At 0:40, intentionally pause silently for 2 full seconds", + "Bridge cleanly: 'Stepping back, here is what matters most...'", + "Land a confident closing sentence", + ], + "target_keywords": ["because", "pause", "matters", "lens"], + }, + { + "day": 25, + "week": 4, + "week_theme": "High-Stakes Presenting, Polish & Signature Style", + "title": "Product Launch Keynote Opener", + "skill_focus": "Keynote Presence", + "duration_sec": 120, + "mode": "Pitch", + "goal": "Stronger eye contact", + "warm_up": "Stand up if possible (or sit tall at the front edge of your chair) for maximum breath support and keynote energy!", + "challenge": "Deliver a 2-minute Apple/Stripe-style product launch opener blending scripted precision on the headline lines with natural warmth in the middle.", + "teleprompter_script": ( + "Every developer remembers the magic of shipping their very first idea to the webβ€”and the frustration of how complicated modern deployment slowly became.\n\n" + "Today, we are making shipping feel effortless again.\n\n" + "First, zero-config previews spin up on every branch in three seconds.\n\n" + "Second, built-in observability highlights real user bottlenecks automatically.\n\n" + "Third, rollbacks happen in a single clickβ€”so your team can ship boldly every single day." + ), + "outline_cues": [ + "Shared developer memory + tension", + "Keynote reveal line with 1-second pause before & after", + "Three crisp capabilities (3s previews, observability, 1-click rollback)", + "Inspiring close with warm lens smile", + ], + "target_keywords": ["first", "second", "third", "so", "shipping"], + }, + { + "day": 26, + "week": 4, + "week_theme": "High-Stakes Presenting, Polish & Signature Style", + "title": "Crisp On-Camera Tutorial Teaching", + "skill_focus": "Instructional Clarity", + "duration_sec": 120, + "mode": "Reading", + "goal": "Improve pace", + "warm_up": "Use the teaching triad: 'What we will build β†’ Why we do this step β†’ Common pitfall to avoid.'", + "challenge": "Teach a 3-step technical mini-tutorial on camera (e.g., setting up JWT auth, writing a clean SQL query, or debugging network waterfalls) in 2 minutes.", + "teleprompter_script": ( + "In the next two minutes, you will learn how to fix slow API waterfalls in any frontend app.\n\n" + "First, open your network tab and spot sequential requests that wait on each other unnecessarily.\n\n" + "Second, group independent fetches with Promise.all so they fire in parallel.\n\n" + "However, watch out for one pitfall: use Promise.allSettled when an optional panel should not crash the whole page.\n\n" + "Try this on your dashboard route today and watch load time drop." + ), + "outline_cues": [ + "Promise: What the viewer learns in 2 minutes", + "Step 1: How to diagnose the issue", + "Step 2: Exact pattern to apply", + "Pitfall guardrail ('However...') + quick win", + ], + "target_keywords": ["first", "second", "however", "parallel"], + }, + { + "day": 27, + "week": 4, + "week_theme": "High-Stakes Presenting, Polish & Signature Style", + "title": "Fireside Q&A Rapid-Fire Precision", + "skill_focus": "Impromptu Brevity", + "duration_sec": 120, + "mode": "Interview", + "goal": "Reduce fillers", + "warm_up": "Answer each question in 3 sentences max: Direct answer β†’ Concrete example β†’ Takeaway.", + "challenge": "Answer TWO rapid-fire tech interview / podcast questions back-to-back in 60 seconds each: (1) Biggest shift in software right now? (2) Best habit you ever built as an engineer?", + "teleprompter_script": ( + "Question One: What is the biggest shift in software engineering right now?\n" + "Answer in three crisp sentences with one real example.\n\n" + "Question Two: What is the single highest-leverage habit you have built in your career?\n" + "Answer in three crisp sentences and land cleanly at two minutes." + ), + "outline_cues": [ + "Q1 (0:00–1:00): Headline answer -> Example -> Takeaway", + "Clean 1.5s transition breath at 1:00", + "Q2 (1:00–2:00): Habit -> Story -> Advice to listener", + ], + "target_keywords": ["because", "first", "habit", "software"], + }, + { + "day": 28, + "week": 4, + "week_theme": "High-Stakes Presenting, Polish & Signature Style", + "title": "Zero-Filler 2-Minute Mastery Sprint", + "skill_focus": "Peak Articulation", + "duration_sec": 120, + "mode": "Debate", + "goal": "Reduce fillers", + "warm_up": "Slow your opening sentence by 10%β€”most fillers happen when your mouth outruns your next thought.", + "challenge": "Speak for a full 2 minutes on any technology topic of your choice with ZERO filler words (<1.0/min) while keeping pace above 125 WPM.", + "teleprompter_script": ( + "Today is a precision challenge: two full minutes of clear, unhurried technical commentary with zero filler crutches.\n\n" + "First, state a technology thesis you genuinely believe in.\n\n" + "Second, walk through two real-world examples from products or systems you admire.\n\n" + "Third, pause deliberately before your final sentenceβ€”and deliver a closing line worth quoting." + ), + "outline_cues": [ + "0:00 Calm, unhurried thesis statement", + "0:30 Example 1 with crisp transitions", + "1:15 Example 2 + contrast ('However...')", + "1:50 1-second silent pause -> Quotable closing line", + ], + "target_keywords": ["first", "second", "third", "however"], + }, + { + "day": 29, + "week": 4, + "week_theme": "High-Stakes Presenting, Polish & Signature Style", + "title": "Dress Rehearsal: Signature 3-Min Tech Talk", + "skill_focus": "Signature Voice", + "duration_sec": 180, + "mode": "Storytelling", + "goal": "Stronger eye contact", + "warm_up": "Load your own custom script or outline into the Studio Teleprompter and set Cue Cards or Auto-Scroll.", + "challenge": "Deliver a 3-minute dress rehearsal of a signature talk, product demo, or YouTube episode you want to publish in the real world.", + "teleprompter_script": ( + "Open with a bold problem or surprising insight from your own work.\n\n" + "First, show why conventional approaches fall short.\n\n" + "Second, reveal the core insight or architecture that changes the game, anchored in a vivid analogy.\n\n" + "Third, share measurable proof or a real war story.\n\n" + "Finally, leave your audience with one concrete action they can take today." + ), + "outline_cues": [ + "0:00 Cold-open hook with energy & direct lens lock", + "0:40 Conventional approach & why it breaks", + "1:30 Core insight + visual analogy + proof", + "2:30 Actionable takeaway + confident sign-off", + ], + "target_keywords": ["first", "second", "third", "problem"], + }, + { + "day": 30, + "week": 4, + "week_theme": "High-Stakes Presenting, Polish & Signature Style", + "title": "Graduation Capstone & Day 1 Comparison", + "skill_focus": "Mastery & Reflection", + "duration_sec": 120, + "mode": "Pitch", + "goal": "Stronger eye contact", + "warm_up": "Breathe deeply, smile at the lens, and own the frameβ€”you have built a real presenting habit.", + "challenge": "Re-record your Tech Presenter Introduction & Vision (2 minutes), then open Progress & Compare to watch Day 30 side-by-side against Day 1!", + "teleprompter_script": ( + "Thirty days ago, I started practicing on camera to turn technical ideas into clear, compelling stories.\n\n" + "First, I learned to replace rushed filler words with calm, deliberate pauses.\n\n" + "Second, I learned to speak directly to the camera lens as if conversing with one curious builder.\n\n" + "Third, I learned to structure complex engineering ideas into hooks, analogies, and clear takeaways.\n\n" + "Here is the technology problem I am tackling nextβ€”and why I am excited to share the journey." + ), + "outline_cues": [ + " Hook: Who you are as a builder & tech communicator today", + " First, Second, Third breakthroughs in how you communicate", + " The exciting technical frontier you are tackling next", + " Open Progress & Compare afterward to watch Day 30 vs Day 1!", + ], + "target_keywords": ["first", "second", "third", "camera", "stories"], + }, +] + + +DAILY_WARMUPS: list[dict[str, Any]] = [ + { + "id": "warmup-breath-pace", + "title": "4-4-6 Diaphragmatic Pace Reset", + "category": "Breath & Calm", + "duration_sec": 60, + "steps": [ + "Sit or stand tall with your collarbone open and camera at eye height.", + "Inhale quietly through your nose for 4 seconds, expanding your lower ribs.", + "Hold gently for 4 seconds with zero jaw or throat tension.", + "Exhale on a steady 'ssss' hiss for 6 secondsβ€”repeat 4 cycles before hitting Record.", + ], + "read_aloud_text": "Steady breath creates steady authority. My pace gives every listener time to think.", + }, + { + "id": "warmup-tech-plosives", + "title": "Crisp Tech Consonant & Plosive Articulation", + "category": "Articulation", + "duration_sec": 75, + "steps": [ + "Loosen your jaw and exaggerate lip and tongue movement by 20%.", + "Read the technical articulation phrase below slowly once, then at brisk 145 WPM.", + "Keep word endings ('-ed', '-ts', '-ks') crisp rather than swallowed.", + ], + "read_aloud_text": ( + "Packet pipelines parse predictable payloads; distributed databases deduplicate durable commits; " + "clean caches cut cold-start compute costs." + ), + }, + { + "id": "warmup-pitch-staircase", + "title": "Vocal Variety & Pitch Staircase", + "category": "Expressiveness", + "duration_sec": 60, + "steps": [ + "Read sentence 1 in a low, grounded register (calm certainty).", + "Read sentence 2 with upward curiosity on the question.", + "Read sentence 3 punching the contrast numbers and pausing before the final word.", + ], + "read_aloud_text": ( + "Most systems fail quietly at the edges. What happens when ten thousand users reconnect at once? " + "Latency drops from twelve seconds... to eighty milliseconds." + ), + }, + { + "id": "warmup-silent-pause", + "title": "The 1-Second Power Pause Metronome", + "category": "Filler Control", + "duration_sec": 60, + "steps": [ + "Read each clause aloud while looking straight into the camera ring.", + "At every [PAUSE], close your lips completely and count 'one-thousand' silently.", + "Notice how calm silence sounds confident rather than awkward.", + ], + "read_aloud_text": ( + "Great presenters never rush the punchline. [PAUSE] First, they frame the tension. [PAUSE] " + "Second, they reveal the mechanism. [PAUSE] Finally, they let the takeaway breathe." + ), + }, +] + + +CAMERA_CHALLENGES: list[dict[str, Any]] = [ + { + "id": "challenge-lens-lock", + "title": "60s Lens-Lock Challenge", + "badge": "Camera Presence", + "mode": "Storytelling", + "goal": "Stronger eye contact", + "duration_sec": 60, + "target_metric": "eye_contact >= 75%", + "prompt": "Explain how HTTPS or public-key encryption keeps data safeβ€”without breaking eye contact with the camera lens for more than 2 seconds at a time.", + "outline_cues": [ + "Analogy: A padlock anyone can snap shut, but only one key opens", + "How browser + server agree on a secret session key", + "Why this protects every login and payment online", + ], + "target_keywords": ["key", "browser", "secure", "because"], + }, + { + "id": "challenge-zero-filler-sprint", + "title": "90s Zero-Filler Architecture Sprint", + "badge": "Articulation", + "mode": "Interview", + "goal": "Reduce fillers", + "duration_sec": 90, + "target_metric": "filler_rate <= 1.5/min", + "prompt": "Walk through what happens when you type a URL into a browser and press Enter. Replace every urge to say 'um', 'uh', or 'like' with a silent pause.", + "outline_cues": [ + "First: DNS lookup translates domain to IP", + "Second: TCP + TLS handshake establishes secure channel", + "Third: HTTP response streams HTML & renders pixels", + ], + "target_keywords": ["first", "second", "third", "browser", "response"], + }, + { + "id": "challenge-cold-open-hooks", + "title": "Triple Cold-Open Hook Lab", + "badge": "Engagement", + "mode": "Pitch", + "goal": "Stronger eye contact", + "duration_sec": 60, + "target_metric": "Expressive tone + Smile", + "prompt": "Deliver 3 different 15-second video hooks back-to-back for the same tech project: (1) Surprising Stat Hook, (2) Relatable Frustration Hook, (3) Bold Contrarian Hook.", + "outline_cues": [ + "Hook 1 (0:00–0:20): Start with a dramatic number or benchmark", + "Hook 2 (0:20–0:40): 'Have you ever spent three hours debugging...'", + "Hook 3 (0:40–1:00): 'Everyone thinks X solves Yβ€”however...'", + ], + "target_keywords": ["however", "because", "seconds"], + }, + { + "id": "challenge-explain-3-levels", + "title": "Explain It at 3 Levels (Non-Tech β†’ Dev β†’ CTO)", + "badge": "Clarity", + "mode": "Storytelling", + "goal": "Improve pace", + "duration_sec": 120, + "target_metric": "125–155 WPM + High Clarity", + "prompt": "Pick one concept (APIs, Containers, Vector Search, or Caching) and explain it in 35s to a smart friend, 40s to a junior developer, and 40s to a CTO focused on cost & reliability.", + "outline_cues": [ + "Level 1 (Friend): Zero jargon, everyday analogy", + "Level 2 (Developer): How you wire it in code & debug it", + "Level 3 (CTO): Latency, cost, scale, and operational risk", + ], + "target_keywords": ["first", "second", "latency", "cost", "reliability"], + }, + { + "id": "challenge-hot-seat-postmortem", + "title": "Impromptu Incident Briefing Hot-Seat", + "badge": "Executive Calm", + "mode": "Interview", + "goal": "Improve pace", + "duration_sec": 90, + "target_metric": "Steady 130–150 WPM", + "prompt": "Imagine an API outage was just mitigated 5 minutes ago. Deliver a calm, transparent 90-second video update to customers & teammates: Impact β†’ Root Cause β†’ Fix β†’ Prevention.", + "outline_cues": [ + "Status first: Service is restored and healthy", + "Impact window & who was affected", + "Plain-English root cause (no blame)", + "Two concrete guardrails shipping this week", + ], + "target_keywords": ["first", "because", "therefore", "restored"], + }, +] + + +def get_or_create_profile(db: Session, user_id: int) -> models.PresenterProfile: + profile = ( + db.query(models.PresenterProfile) + .filter(models.PresenterProfile.user_id == user_id) + .first() + ) + if profile is None: + profile = models.PresenterProfile( + user_id=user_id, + display_name="Tech Presenter", + primary_role="Software Engineer & Tech Presenter", + focus_track="tech_storytelling", + experience_level="intermediate", + target_wpm_min=125, + target_wpm_max=155, + max_filler_rate=2.0, + min_eye_contact=0.70, + daily_minutes_goal=15, + weaknesses=["filler_words", "eye_contact"], + speaking_goal_statement="Become a confident, articulate, engaging technology presenter through daily camera practice.", + curriculum_start_date=date.today(), + ) + db.add(profile) + db.commit() + db.refresh(profile) + return profile + + +def serialize_profile(profile: models.PresenterProfile) -> dict[str, Any]: + return { + "id": profile.id, + "display_name": profile.display_name, + "primary_role": profile.primary_role, + "focus_track": profile.focus_track, + "focus_track_label": FOCUS_TRACK_LABELS.get(profile.focus_track, profile.focus_track), + "experience_level": profile.experience_level, + "target_wpm_min": profile.target_wpm_min, + "target_wpm_max": profile.target_wpm_max, + "max_filler_rate": profile.max_filler_rate, + "min_eye_contact": profile.min_eye_contact, + "daily_minutes_goal": profile.daily_minutes_goal, + "weaknesses": profile.weaknesses if isinstance(profile.weaknesses, list) else [], + "speaking_goal_statement": profile.speaking_goal_statement, + "curriculum_start_date": profile.curriculum_start_date.isoformat(), + } + + +def _personalize_day_tip(day_entry: dict[str, Any], profile: models.PresenterProfile) -> str: + weaknesses = set(profile.weaknesses if isinstance(profile.weaknesses, list) else []) + tips: list[str] = [] + if "filler_words" in weaknesses: + tips.append(f"Keep fillers under {profile.max_filler_rate:.1f}/min by pausing silently at paragraph breaks.") + if "eye_contact" in weaknesses: + tips.append(f"Aim for {int(profile.min_eye_contact * 100)}%+ lens contact using top-of-frame teleprompter cues.") + if "rushing_pace" in weaknesses: + tips.append(f"Hold pace inside your {profile.target_wpm_min}–{profile.target_wpm_max} WPM target zone.") + if "monotone_delivery" in weaknesses: + tips.append("Emphasize contrast words ('however', 'suddenly', numbers) with deliberate pitch lift.") + if "rambling_explanations" in weaknesses: + tips.append("Land each bullet cue in 2–3 crisp sentences before moving to the next point.") + + track_note = { + "tech_storytelling": "Frame this take like a high-retention creator segment for curious builders.", + "keynote_clarity": "Project calm stage authority with clean architectural signposts.", + "camera_confidence": "Prioritize relaxed shoulders, warm facial expression, and unhurried breaths.", + "executive_pitch": "Lead with business & user impact in your opening sentence.", + }.get(profile.focus_track, "") + + combined = " ".join(([track_note] + tips[:2])).strip() + return combined or f"Target {profile.target_wpm_min}–{profile.target_wpm_max} WPM as a {profile.primary_role}." + + +def record_curriculum_completion( + db: Session, + user_id: int, + day_number: int, + session_id: int | None = None, + score: float | None = None, + reflection_notes: str | None = None, +) -> models.CurriculumDayCompletion: + if day_number < 1 or day_number > 30: + raise ValueError("day_number must be between 1 and 30") + + row = ( + db.query(models.CurriculumDayCompletion) + .filter( + models.CurriculumDayCompletion.user_id == user_id, + models.CurriculumDayCompletion.day_number == day_number, + ) + .first() + ) + numeric_score = round(float(score or 0), 1) + if row is None: + row = models.CurriculumDayCompletion( + user_id=user_id, + day_number=day_number, + completed_session_id=session_id, + best_score=numeric_score, + attempts_count=1, + reflection_notes=reflection_notes, + completed_at=datetime.utcnow(), + ) + db.add(row) + else: + row.attempts_count = (row.attempts_count or 1) + (1 if session_id else 0) + if session_id is not None: + row.completed_session_id = session_id + if score is not None and numeric_score >= (row.best_score or 0): + row.best_score = numeric_score + if reflection_notes is not None: + row.reflection_notes = reflection_notes + row.updated_at = datetime.utcnow() + + db.commit() + db.refresh(row) + return row + + +def get_curriculum_plan(db: Session, user_id: int) -> dict[str, Any]: + profile = get_or_create_profile(db, user_id) + completions = ( + db.query(models.CurriculumDayCompletion) + .filter(models.CurriculumDayCompletion.user_id == user_id) + .all() + ) + by_day = {row.day_number: row for row in completions} + + completed_count = len(by_day) + recommended_day = 1 + for d in range(1, 31): + if d not in by_day: + recommended_day = d + break + else: + recommended_day = 30 + + days_out: list[dict[str, Any]] = [] + for entry in CURRICULUM_DAYS: + day_num = entry["day"] + comp = by_day.get(day_num) + tip = _personalize_day_tip(entry, profile) + dur_sec = int(entry.get("duration_sec", 90)) + success_str = ( + f"{profile.target_wpm_min}–{profile.target_wpm_max} WPM Β· " + f"≀ {profile.max_filler_rate:.1f} fillers/min Β· " + f"β‰₯ {int(profile.min_eye_contact * 100)}% lens eye-contact" + ) + days_out.append({ + **entry, + "week_title": entry.get("week_theme", f"Week {entry['week']}"), + "focus_skill": entry.get("skill_focus", "Camera Presence"), + "duration_min": max(5, round(dur_sec / 6)), + "warmup": entry.get("warm_up", ""), + "camera_challenge": entry.get("challenge", ""), + "prompt": entry.get("challenge", ""), + "bullet_cues": entry.get("outline_cues", []), + "success_metric": success_str, + "personalized_tip": tip, + "completed": comp is not None, + "is_recommended_today": day_num == recommended_day, + "completed_session_id": comp.completed_session_id if comp else None, + "best_score": comp.best_score if comp else None, + "attempts_count": comp.attempts_count if comp else 0, + "reflection_notes": (comp.reflection_notes or "") if comp else "", + "completed_at": comp.completed_at.isoformat() if comp and comp.completed_at else None, + "personalized_coach_tip": tip, + "target_metrics": { + "wpm_min": profile.target_wpm_min, + "wpm_max": profile.target_wpm_max, + "max_filler_rate": profile.max_filler_rate, + "min_eye_contact_pct": int(profile.min_eye_contact * 100), + }, + }) + + avg_best_score = ( + round(sum(c.best_score for c in completions) / len(completions), 1) + if completions + else 0.0 + ) + + week_defs = [ + (1, "Camera Presence & Vocal Foundation", "Build relaxed camera eye-line, steady vocal pace, and silent pauses instead of filler words."), + (2, "Structuring Technical Ideas", "Turn complex architectures, APIs, and tradeoffs into crisp 3-beat explanations."), + (3, "Tech News & Builder Storytelling", "Synthesize live industry shifts into engaging 60s briefings, analogies, and hot takes."), + (4, "Executive Demos, Q&A & Keynote Polish", "Deliver high-stakes product demos, incident updates, and conference-grade talks."), + ] + weeks_out = [] + for wk_num, wk_title, wk_theme in week_defs: + wk_days = [d for d in days_out if d["week"] == wk_num] + weeks_out.append({ + "week": wk_num, + "title": wk_title, + "theme": wk_theme, + "completed_count": sum(1 for d in wk_days if d["completed"]), + "total_count": len(wk_days), + }) + + today_mission = next((d for d in days_out if d["day"] == recommended_day), days_out[0]) + + return { + "profile": serialize_profile(profile), + "summary": { + "total_days": 30, + "completed_days": completed_count, + "completion_pct": round((completed_count / 30) * 100, 1), + "recommended_day": recommended_day, + "current_week": today_mission["week"], + "avg_completed_score": avg_best_score, + }, + "weeks": weeks_out, + "today_mission": today_mission, + "days": days_out, + } + + +def get_personalized_exercises(db: Session, user_id: int) -> dict[str, Any]: + profile = get_or_create_profile(db, user_id) + recent_metrics = ( + db.query(models.SessionMetrics) + .filter(models.SessionMetrics.user_id == user_id) + .order_by(models.SessionMetrics.recorded_at.desc()) + .limit(8) + .all() + ) + + targeted: list[dict[str, Any]] = [] + weaknesses = set(profile.weaknesses if isinstance(profile.weaknesses, list) else []) + + avg_wpm = ( + round(sum(m.wpm for m in recent_metrics) / len(recent_metrics), 1) + if recent_metrics + else None + ) + avg_filler_rate = ( + round(sum(m.filler_rate for m in recent_metrics) / len(recent_metrics), 2) + if recent_metrics + else None + ) + eye_vals = [m.eye_contact_score for m in recent_metrics if m.eye_contact_score is not None] + avg_eye = round(sum(eye_vals) / len(eye_vals), 2) if eye_vals else None + avg_vocab = ( + round(sum(m.vocabulary_density for m in recent_metrics) / len(recent_metrics), 3) + if recent_metrics + else None + ) + + # Top filler words across recent sessions + filler_totals: dict[str, int] = {} + for m in recent_metrics: + if isinstance(m.filler_breakdown, dict): + for word, count in m.filler_breakdown.items(): + filler_totals[str(word)] = filler_totals.get(str(word), 0) + int(count or 0) + top_filler_words = [w for w, _ in sorted(filler_totals.items(), key=lambda x: x[1], reverse=True)[:3]] + + # 1. Filler-targeted drill + if (avg_filler_rate is not None and avg_filler_rate > profile.max_filler_rate) or "filler_words" in weaknesses: + reason = ( + f"Recent filler rate is {avg_filler_rate}/min (target ≀ {profile.max_filler_rate}/min)" + + (f" β€” top fillers: {', '.join(top_filler_words)}." if top_filler_words else ".") + if avg_filler_rate is not None + else "Selected in your presenter profile priority areas." + ) + targeted.append({ + "id": "targeted-filler-pause", + "priority": "high", + "focus_area": "Filler Elimination", + "title": "Silent Beat-Pause Replacement Drill", + "why_recommended": reason, + "duration_sec": 75, + "mode": "Interview", + "goal": "Reduce fillers", + "prompt": "Explain a technical tradeoff you made recently in 75 seconds. Every time a clause endsβ€”or you feel a filler word formingβ€”close your lips for 1 beat before continuing.", + "teleprompter_script": ( + "Today I am practicing clean transitions without filler words.\n\n" + "First, here is the engineering problem we needed to solve. [Pause 1s]\n\n" + "Second, here is the tradeoff we chose and what we deliberately left out. [Pause 1s]\n\n" + "Therefore, the system stayed simple to operate and fast to ship." + ), + "outline_cues": [ + "State the problem cleanly -> [1s silent breath]", + "State the chosen approach -> [1s silent breath]", + "State the measurable result -> hold lens contact", + ], + "target_keywords": ["first", "second", "therefore"], + }) + + # 2. Pace-targeted drill + if ( + (avg_wpm is not None and (avg_wpm > profile.target_wpm_max or (0 < avg_wpm < profile.target_wpm_min))) + or "rushing_pace" in weaknesses + ): + if avg_wpm is not None and avg_wpm > profile.target_wpm_max: + reason = f"Your recent pace averaged {avg_wpm} WPM (above your {profile.target_wpm_min}–{profile.target_wpm_max} WPM target zone)." + drill_title = "140 WPM Unhurried Keynote Pacing Drill" + elif avg_wpm is not None and 0 < avg_wpm < profile.target_wpm_min: + reason = f"Your recent pace averaged {avg_wpm} WPM (below your {profile.target_wpm_min}–{profile.target_wpm_max} WPM target zone)." + drill_title = "Crisp Momentum & Energy Cadence Drill" + else: + reason = "Selected in your presenter profile to lock in steady keynote cadence." + drill_title = "140 WPM Keynote Cadence Calibration" + + targeted.append({ + "id": "targeted-wpm-calibration", + "priority": "high", + "focus_area": "Speaking Pace", + "title": drill_title, + "why_recommended": reason, + "duration_sec": 60, + "mode": "Reading", + "goal": "Improve pace", + "prompt": f"Use Auto-Scroll Teleprompter at 1.0x speed to deliver this 140-word tech passage in ~60 seconds ({profile.target_wpm_min}–{profile.target_wpm_max} WPM).", + "teleprompter_script": ( + "Great technical presenters control time rather than letting nervous adrenaline dictate their speed.\n\n" + "When we rush through architecture diagrams or live demos, listeners miss the single insight that makes everything click.\n\n" + "Instead, anchor your voice at a steady conversational rhythm. First, state your headline clearly. Second, illustrate it with one concrete example. Third, pause before your takeaway.\n\n" + "Notice how much more authoritative your voice sounds when every sentence has room to land." + ), + "outline_cues": [ + "0:00–0:15 Steady opening headline", + "0:15–0:45 First, Second, Third points at even tempo", + "0:45–1:00 Unhurried closing takeaway", + ], + "target_keywords": ["first", "second", "third", "rhythm"], + }) + + # 3. Eye-contact targeted drill + if (avg_eye is not None and avg_eye < profile.min_eye_contact) or "eye_contact" in weaknesses: + reason = ( + f"Recent camera lens eye contact averaged {int(avg_eye * 100)}% (target β‰₯ {int(profile.min_eye_contact * 100)}%)." + if avg_eye is not None + else "Selected in your presenter profile to strengthen direct camera connection." + ) + targeted.append({ + "id": "targeted-lens-anchor", + "priority": "medium", + "focus_area": "Camera Eye Contact", + "title": "Top-of-Frame Cue-Card Lens Anchor", + "why_recommended": reason, + "duration_sec": 60, + "mode": "Storytelling", + "goal": "Stronger eye contact", + "prompt": "Speak from 3 short cue bullets in Cue-Card Teleprompter mode while keeping your gaze inside the top lens guide for at least 75% of the recording.", + "teleprompter_script": ( + "Speak naturally to the lens using 3 quick glances max:\n" + "β€’ Hook: Why developer experience makes or breaks adoption.\n" + "β€’ Story: A tool you loved within 5 minutes of installing.\n" + "β€’ Takeaway: One friction point you want to delete from your own project." + ), + "outline_cues": [ + "Why DX drives adoption (eyes locked on lens)", + "5-minute 'aha' story with a favorite tool", + "One friction point to delete this week", + ], + "target_keywords": ["developer", "experience", "adoption"], + }) + + # 4. Storytelling & structure drill (always ensure at least 2 targeted drills) + if len(targeted) < 2 or "rambling_explanations" in weaknesses or (avg_vocab is not None and avg_vocab < 0.55): + targeted.append({ + "id": "targeted-hook-mechanism-impact", + "priority": "medium", + "focus_area": "Concise Tech Storytelling", + "title": "Hook β†’ Mechanism β†’ Impact 90s Builder Drill", + "why_recommended": "Builds tight narrative discipline so complex technical ideas land in 90 seconds without rambling.", + "duration_sec": 90, + "mode": "Pitch", + "goal": "Improve pace", + "prompt": "Pick any feature or engineering concept and explain it in strict 30-second blocks: (1) Problem Hook, (2) How It Works Under the Hood, (3) Measurable Impact.", + "teleprompter_script": ( + "Here is the problem most teams hit as soon as their app grows past its first thousand users.\n\n" + "Under the hood, the bottleneck happens because every page reload repeats expensive queries.\n\n" + "By introducing a lightweight cache invalidation strategy, response times drop from seconds to millisecondsβ€”and infrastructure bills stay predictable." + ), + "outline_cues": [ + "0:00–0:30 Problem Hook: What breaks at scale", + "0:30–1:00 Mechanism: Why it happens & how the fix works", + "1:00–1:30 Impact: Speed + cost + user delight", + ], + "target_keywords": ["problem", "because", "strategy", "milliseconds"], + }) + + normalized_targeted = [ + { + **item, + "target_metric": item.get("focus_area", "Presenter Delivery"), + "duration_seconds": item.get("duration_sec", 75), + "bullet_cues": item.get("outline_cues", []), + } + for item in targeted + ] + normalized_warmups = [ + { + **wu, + "duration_seconds": wu.get("duration_sec", 60), + "instructions": " ".join(wu.get("steps", [])), + "script": wu.get("read_aloud_text", ""), + } + for wu in DAILY_WARMUPS + ] + normalized_challenges = [ + { + **ch, + "duration_seconds": ch.get("duration_sec", 90), + "difficulty": ch.get("badge", "Challenge"), + "focus": ch.get("target_metric", ch.get("goal", "Clarity")), + "teleprompter_script": "\n\n".join(ch.get("outline_cues", [])), + "bullet_cues": ch.get("outline_cues", []), + } + for ch in CAMERA_CHALLENGES + ] + + return { + "diagnostic_snapshot": { + "sessions_analyzed": len(recent_metrics), + "avg_wpm": avg_wpm, + "avg_filler_rate": avg_filler_rate, + "avg_eye_contact_pct": int(avg_eye * 100) if avg_eye is not None else None, + "top_filler_words": top_filler_words, + }, + "recent_averages": { + "session_count": len(recent_metrics), + "avg_wpm": avg_wpm or 0, + "avg_filler_rate": avg_filler_rate or 0, + "avg_eye_contact_pct": int(avg_eye * 100) if avg_eye is not None else 0, + "avg_delivery_score": round(sum(m.delivery_score for m in recent_metrics) / len(recent_metrics), 1) if recent_metrics else 0, + "avg_overall_score": round(sum(m.overall_score for m in recent_metrics) / len(recent_metrics), 1) if recent_metrics else 0, + }, + "targeted_exercises": normalized_targeted, + "daily_warmups": normalized_warmups, + "camera_challenges": normalized_challenges, + } diff --git a/apps/api/src/app/services/tech_news.py b/apps/api/src/app/services/tech_news.py new file mode 100644 index 0000000000000000000000000000000000000000..43b80b9b94dd1758369cd8ac3771eb13a3911de3 --- /dev/null +++ b/apps/api/src/app/services/tech_news.py @@ -0,0 +1,522 @@ +from __future__ import annotations + +import html +import ipaddress +import re +import socket +import time +import xml.etree.ElementTree as ET +from typing import Any +from urllib.parse import urlparse + +import httpx + +_FEED_CACHE: dict[str, Any] = {"timestamp": 0.0, "items": []} +CACHE_TTL_SECONDS = 600 + +RSS_SOURCES = [ + { + "name": "Ars Technica", + "url": "https://feeds.arstechnica.com/arstechnica/technology-lab", + "default_category": "Systems & Cloud", + }, + { + "name": "Hacker News", + "url": "https://hnrss.org/frontpage?points=120&count=12", + "default_category": "DevTools & Engineering", + }, + { + "name": "The Verge Tech", + "url": "https://www.theverge.com/rss/tech/index.xml", + "default_category": "Product & Startups", + }, +] + +CURATED_TECH_STORIES: list[dict[str, Any]] = [ + { + "id": "curated-ai-agent-evals", + "headline": "Why Engineering Teams Are Shifting From Prompt Tweaking to Deterministic Agent Evals", + "summary": "As coding and workflow agents move into production pipelines, teams are discovering that reliability comes from sandboxes, tool guardrails, and automated regression suites rather than longer system prompts.", + "source": "Tech Presenter Briefing", + "category": "AI & Models", + "published_label": "Featured Briefing", + "url": "", + "talking_points": [ + "Single-turn chat demos hide compounding multi-step tool errors.", + "Production teams treat agent trajectories like CI pipelines with pass/fail assertions.", + "Fast feedback loops and sandboxing beat brittle prompt engineering.", + ], + }, + { + "id": "curated-sqlite-edge-renaissance", + "headline": "The Modern SQLite & Single-Node Renaissance in High-Throughput Web Apps", + "summary": "With NVMe storage, WAL concurrency, and read-replica streaming tools, single-node and embedded databases now handle workloads that teams previously assumed required distributed clusters.", + "source": "Systems Architecture Desk", + "category": "Systems & Cloud", + "published_label": "Featured Briefing", + "url": "", + "talking_points": [ + "Eliminating network hops between app and data slashes tail latency to microseconds.", + "Operational simplicity means fewer 3 a.m. distributed consensus failures.", + "Know the exact write-contention threshold where horizontal Postgres sharding still wins.", + ], + }, + { + "id": "curated-on-device-speech-vision", + "headline": "Browser-Native ML: Running Vision & Audio Analytics Locally With WebAssembly and WebGPU", + "summary": "Modern browsers now run real-time face landmarking, audio signal analysis, and compact speech models directly on user devicesβ€”preserving privacy while cutting cloud inference bills to zero.", + "source": "Web Platform Digest", + "category": "DevTools & Engineering", + "published_label": "Featured Briefing", + "url": "", + "talking_points": [ + "Zero network round-trip enables 30–60 FPS interactive coaching feedback.", + "Sensitive camera and microphone streams stay private by default.", + "Hybrid architectures combine instant browser cues with optional deeper server models.", + ], + }, + { + "id": "curated-passkeys-security", + "headline": "Passkeys & Hardware-Bound Credentials Are Finally Replacing Phishable Passwords at Scale", + "summary": "Major consumer and developer platforms report dramatic drops in account takeovers and sign-in friction after adopting WebAuthn passkeys synced across desktop and mobile devices.", + "source": "Security Engineering Brief", + "category": "Security & Privacy", + "published_label": "Featured Briefing", + "url": "", + "talking_points": [ + "Cryptographic origin binding makes lookalike phishing domains useless.", + "Biometric sign-in cuts login time by more than half on mobile browsers.", + "Cross-ecosystem recovery UX remains the critical detail product teams must nail.", + ], + }, + { + "id": "curated-small-teams-leverage", + "headline": "How Tiny Product-Minded Engineering Teams Ship Like 50-Person Organizations", + "summary": "Armed with typed monorepos, automated preview environments, and AI-assisted testing, lean 4-to-8 engineer teams are shipping polished full-stack products at unprecedented velocity.", + "source": "Builder & Startup Desk", + "category": "Product & Startups", + "published_label": "Featured Briefing", + "url": "", + "talking_points": [ + "Communication overhead scales quadratically; small senior teams keep context unified.", + "Shared TypeScript/Python contracts eliminate integration drift.", + "Clear product taste and storytelling have become the true competitive moat.", + ], + }, + { + "id": "curated-inference-economics", + "headline": "The New Unit Economics of AI Features: Latency Budgets, Caching, and Model Routing", + "summary": "Smart AI products no longer route every user click to the largest frontier model; instead, they combine semantic caching, compact specialist models, and frontier escalation only when needed.", + "source": "AI Infrastructure Brief", + "category": "AI & Models", + "published_label": "Featured Briefing", + "url": "", + "talking_points": [ + "Users abandon interactive UI features when latency exceeds 2 seconds.", + "Routing 80% of routine tasks to compact models cuts cost by 5–10x.", + "Designing transparent progressive UI states turns wait time into perceived intelligence.", + ], + }, +] + +STORY_FRAMEWORKS: dict[str, dict[str, Any]] = { + "briefing_60s": { + "id": "briefing_60s", + "label": "60s Breaking Tech Briefing", + "duration_sec": 60, + "mode": "Storytelling", + "goal": "Improve pace", + "description": "Punchy creator/anchor update: Headline Hook β†’ What Changed β†’ Why Builders Care β†’ Sharp Closing Take.", + }, + "explainer_2m": { + "id": "explainer_2m", + "label": "2-Min Deep-Dive Explainer", + "duration_sec": 120, + "mode": "Storytelling", + "goal": "Stronger eye contact", + "description": "Educational breakdown: Relatable Problem β†’ Under-the-Hood Mechanism β†’ Visual Analogy β†’ Practical Impact.", + }, + "contrarian_take": { + "id": "contrarian_take", + "label": "90s Contrarian Tech Take", + "duration_sec": 90, + "mode": "Debate", + "goal": "Reduce fillers", + "description": "Opinionated commentary: Conventional Hype β†’ Where Reality Differs β†’ Concrete Evidence β†’ Nuanced Prediction.", + }, + "war_story": { + "id": "war_story", + "label": "2-Min Builder Lesson & Story", + "duration_sec": 120, + "mode": "Pitch", + "goal": "Stronger eye contact", + "description": "Narrative arc connecting news to real engineering: The Stakes β†’ The Tradeoff β†’ How to Apply It Monday Morning.", + }, +} + + +def _strip_html(raw: str) -> str: + no_tags = re.sub(r"<[^>]+>", " ", raw or "") + unescaped = html.unescape(no_tags) + return re.sub(r"\s+", " ", unescaped).strip() + + +def _infer_category(title: str, summary: str, fallback: str) -> str: + blob = f"{title} {summary}".lower() + if any(k in blob for k in ("ai", "llm", "model", "gpt", "claude", "gemini", "neural", "inference", "agent")): + return "AI & Models" + if any(k in blob for k in ("security", "vulnerability", "cve", "passkey", "encryption", "breach", "privacy", "zero-day")): + return "Security & Privacy" + if any(k in blob for k in ("cloud", "linux", "kernel", "postgres", "sqlite", "kubernetes", "server", "cpu", "gpu", "chip")): + return "Systems & Cloud" + if any(k in blob for k in ("rust", "typescript", "python", "compiler", "git", "open source", "api", "browser", "ide")): + return "DevTools & Engineering" + if any(k in blob for k in ("startup", "launch", "funding", "apple", "product", "design", "market")): + return "Product & Startups" + return fallback + + +def _parse_feed_xml(xml_text: str, source_name: str, default_category: str) -> list[dict[str, Any]]: + items: list[dict[str, Any]] = [] + try: + root = ET.fromstring(xml_text) + except ET.ParseError: + return items + + # RSS 2.0 + for idx, item in enumerate(root.findall(".//item")[:8]): + title = _strip_html(item.findtext("title") or "") + desc = _strip_html(item.findtext("description") or "") + link = (item.findtext("link") or "").strip() + if not title or len(title) < 12: + continue + if "Comments URL:" in desc: + desc = "Trending technical discussion on Hacker News β€” ideal for practicing concise synthesis and your own engineering point of view." + summary = desc[:340] + ("..." if len(desc) > 340 else "") + category = _infer_category(title, summary, default_category) + items.append({ + "id": f"live-{source_name.lower().replace(' ', '-')}-{idx}", + "headline": title, + "summary": summary or f"Fresh technology story from {source_name}. Frame what happened, why it matters, and your practical builder takeaway.", + "source": source_name, + "category": category, + "published_label": "Live Feed", + "url": link, + "talking_points": _extract_talking_points(title, summary), + }) + + # Atom + ns = {"atom": "http://www.w3.org/2005/Atom"} + for idx, entry in enumerate(root.findall(".//atom:entry", ns)[:8]): + title = _strip_html(entry.findtext("atom:title", default="", namespaces=ns)) + summary_raw = ( + entry.findtext("atom:summary", default="", namespaces=ns) + or entry.findtext("atom:content", default="", namespaces=ns) + ) + desc = _strip_html(summary_raw) + link_el = entry.find("atom:link", ns) + link = (link_el.attrib.get("href", "") if link_el is not None else "").strip() + if not title or len(title) < 12: + continue + summary = desc[:340] + ("..." if len(desc) > 340 else "") + category = _infer_category(title, summary, default_category) + items.append({ + "id": f"live-atom-{source_name.lower().replace(' ', '-')}-{idx}", + "headline": title, + "summary": summary or f"Latest technology report from {source_name}.", + "source": source_name, + "category": category, + "published_label": "Live Feed", + "url": link, + "talking_points": _extract_talking_points(title, summary), + }) + + return items + + +def _extract_talking_points(headline: str, summary: str) -> list[str]: + clean_summary = summary.strip() + sentences = [s.strip() for s in re.split(r"(?<=[.!?])\s+", clean_summary) if len(s.strip()) > 25] + points = [f"Core shift: {headline}"] + if sentences: + points.append(sentences[0][:160]) + points.append("Builder lens: How does this change architecture, latency, cost, or user trust?") + return points[:3] + + +def fetch_tech_news_feed(category: str | None = None, force_refresh: bool = False) -> dict[str, Any]: + now = time.time() + live_items: list[dict[str, Any]] = [] + + if not force_refresh and _FEED_CACHE["items"] and (now - _FEED_CACHE["timestamp"] < CACHE_TTL_SECONDS): + live_items = list(_FEED_CACHE["items"]) + else: + fetched: list[dict[str, Any]] = [] + try: + with httpx.Client(timeout=3.0, follow_redirects=True, headers={"User-Agent": "SpeechMachine-TechCoach/1.0"}) as client: + for src in RSS_SOURCES: + try: + resp = client.get(src["url"]) + if resp.status_code == 200 and resp.text: + fetched.extend(_parse_feed_xml(resp.text, src["name"], src["default_category"])) + except Exception: + continue + except Exception: + fetched = [] + + if fetched: + # Deduplicate by normalized headline + seen: set[str] = set() + deduped: list[dict[str, Any]] = [] + for entry in fetched: + norm = re.sub(r"\W+", "", entry["headline"].lower())[:60] + if norm not in seen: + seen.add(norm) + deduped.append(entry) + live_items = deduped[:15] + _FEED_CACHE["timestamp"] = now + _FEED_CACHE["items"] = live_items + + combined = live_items + CURATED_TECH_STORIES + if category and category.lower() not in {"all", ""}: + filtered = [item for item in combined if item["category"].lower() == category.lower()] + else: + filtered = combined + + categories = [ + "All", + "AI & Models", + "Systems & Cloud", + "DevTools & Engineering", + "Security & Privacy", + "Product & Startups", + ] + frameworks_out = [ + { + **fw, + "target_seconds": fw["duration_sec"], + "target_words": round((fw["duration_sec"] / 60) * 140), + } + for fw in STORY_FRAMEWORKS.values() + ] + stories_out = [ + { + **st, + "published_at": st.get("published_label", "Today"), + "is_live": str(st.get("id", "")).startswith("live-"), + } + for st in filtered[:24] + ] + return { + "live_connected": len(live_items) > 0, + "live_count": len(live_items), + "categories": categories, + "frameworks": frameworks_out, + "stories": stories_out, + } + + +def _is_safe_public_url(url: str) -> bool: + try: + parsed = urlparse(url) + if parsed.scheme not in {"http", "https"} or not parsed.hostname: + return False + hostname = parsed.hostname.lower() + if hostname in {"localhost", "127.0.0.1", "0.0.0.0", "::1"} or hostname.endswith(".local"): + return False + infos = socket.getaddrinfo(hostname, None) + for info in infos: + ip_obj = ipaddress.ip_address(info[4][0]) + if ip_obj.is_private or ip_obj.is_loopback or ip_obj.is_link_local or ip_obj.is_reserved: + return False + return True + except Exception: + return False + + +def _try_fetch_article_excerpt(url: str) -> tuple[str | None, str | None]: + if not url or not _is_safe_public_url(url): + return None, None + try: + with httpx.Client(timeout=3.5, follow_redirects=True, headers={"User-Agent": "SpeechMachine-TechCoach/1.0"}) as client: + resp = client.get(url) + if resp.status_code != 200: + return None, None + raw_html = resp.text[:120000] + title_match = re.search(r"]*>(.*?)", raw_html, flags=re.IGNORECASE | re.DOTALL) + page_title = _strip_html(title_match.group(1)) if title_match else None + paragraphs = [ + _strip_html(m) + for m in re.findall(r"]*>(.*?)

", raw_html, flags=re.IGNORECASE | re.DOTALL) + ] + substantive = [p for p in paragraphs if len(p) > 60][:4] + excerpt = " ".join(substantive)[:700] if substantive else None + return page_title, excerpt + except Exception: + return None, None + + +def _extract_keywords(headline: str, summary: str) -> list[str]: + stop = { + "the", "and", "for", "with", "that", "this", "from", "into", "your", "are", "why", + "how", "what", "when", "where", "over", "more", "than", "their", "they", "have", + "has", "been", "will", "just", "new", "modern", "teams", "using", "about", + } + words = re.findall(r"[a-zA-Z][a-zA-Z0-9\-]{3,}", f"{headline} {summary}".lower()) + picked: list[str] = [] + for w in words: + clean = w.strip("-") + if clean not in stop and clean not in picked and len(clean) >= 4: + picked.append(clean) + if len(picked) >= 4: + break + return picked + ["because", "however"] + + +def build_storyboard( + headline: str, + summary: str = "", + source: str | None = None, + url: str | None = None, + category: str | None = "AI & Models", + framework: str = "briefing_60s", + personal_angle: str | None = None, +) -> dict[str, Any]: + effective_headline = headline.strip() + effective_summary = summary.strip() + + if url and not effective_summary: + fetched_title, fetched_excerpt = _try_fetch_article_excerpt(url) + if fetched_title and len(effective_headline) < 10: + effective_headline = fetched_title + if fetched_excerpt: + effective_summary = fetched_excerpt + + if not effective_summary: + effective_summary = ( + f"This story explores {effective_headline} and how it reshapes engineering tradeoffs, " + "product velocity, and real-world user outcomes." + ) + + fw = STORY_FRAMEWORKS.get(framework, STORY_FRAMEWORKS["briefing_60s"]) + angle_line = ( + personal_angle.strip() + if personal_angle and personal_angle.strip() + else "For builders, the real signal is focusing on measurable reliability, latency, and user value over surface hype." + ) + keywords = _extract_keywords(effective_headline, effective_summary) + + if framework == "briefing_60s": + sections = [ + { + "label": "1. Cold-Open Hook (0:00–0:10)", + "text": f"Big shift in {category or 'tech'} today: {effective_headline}.", + }, + { + "label": "2. What Just Happened (0:10–0:28)", + "text": f"First, here is the core development: {effective_summary}", + }, + { + "label": "3. Why Builders Should Care (0:28–0:46)", + "text": "However, look past the headlineβ€”this directly changes how teams weigh speed, cost, and architecture decisions.", + }, + { + "label": "4. My Sharp Takeaway (0:46–1:00)", + "text": f"My take: {angle_line}", + }, + ] + elif framework == "explainer_2m": + sections = [ + { + "label": "1. Relatable Problem Hook (0:00–0:20)", + "text": f"You may have seen the headline '{effective_headline}'β€”let us unpack what is actually happening under the hood.", + }, + { + "label": "2. Under-the-Hood Mechanism (0:20–0:55)", + "text": f"First, the technical context: {effective_summary}", + }, + { + "label": "3. Plain-English Mental Model (0:55–1:30)", + "text": "Second, think of this like upgrading from manual city streets to an automated express laneβ€”throughput rises, however edge-case guardrails matter even more.", + }, + { + "label": "4. Practical Builder Takeaway (1:30–2:00)", + "text": f"Therefore, here is how I am thinking about applying this: {angle_line}", + }, + ] + elif framework == "contrarian_take": + sections = [ + { + "label": "1. Conventional Consensus (0:00–0:18)", + "text": f"Everyone is reacting to '{effective_headline}' as if it changes everything overnight.", + }, + { + "label": "2. Where Reality Differs (0:18–0:45)", + "text": f"However, when you inspect the detailsβ€”{effective_summary}β€”the real bottleneck is still integration and human workflow.", + }, + { + "label": "3. Concrete Evidence (0:45–1:10)", + "text": "First, tools evolve fast, yet production reliability and debugging discipline still determine who ships sustainably.", + }, + { + "label": "4. Nuanced Prediction (1:10–1:30)", + "text": f"So my prediction is simple: {angle_line}", + }, + ] + else: # war_story + sections = [ + { + "label": "1. The Stakes (0:00–0:20)", + "text": f"Reading '{effective_headline}' reminded me of a critical engineering lesson every builder runs into eventually.", + }, + { + "label": "2. The Core Tension (0:20–0:55)", + "text": f"First, look at what happened here: {effective_summary}", + }, + { + "label": "3. The Tradeoff Under Pressure (0:55–1:30)", + "text": "However, in real production systems, every optimization introduces a tradeoff between simplicity, latency, and observability.", + }, + { + "label": "4. Monday-Morning Principle (1:30–2:00)", + "text": f"Therefore, the principle worth carrying into your own stack is this: {angle_line}", + }, + ] + + teleprompter_script = "\n\n".join(sec["text"] for sec in sections) + bullet_cues = [f"{sec['label']}: {sec['text'][:115]}..." if len(sec["text"]) > 118 else f"{sec['label']}: {sec['text']}" for sec in sections] + delivery_tips = [ + "Lock your eyes onto the camera lens for the entire 1st section hook.", + "Pause silently for 1 full beat right before 'However' and 'My take / Therefore'.", + "Keep speaking cadence between 130–155 WPM so technical nouns land crisply.", + ] + sections_normalized = [ + { + "label": sec["label"], + "text": sec["text"], + "beat": sec["label"].split("(")[0].strip(), + "timing": "(" + sec["label"].split("(")[1] if "(" in sec["label"] else "", + "guidance": fw["description"], + "script": sec["text"], + } + for sec in sections + ] + + return { + "headline": effective_headline, + "summary": effective_summary, + "source": source or "Custom Tech Story", + "url": url or "", + "category": category or "AI & Models", + "framework": fw["id"], + "framework_info": fw, + "framework_label": fw["label"], + "target_seconds": fw["duration_sec"], + "target_words": round((fw["duration_sec"] / 60) * 140), + "sections": sections_normalized, + "teleprompter_script": teleprompter_script, + "bullet_cues": bullet_cues, + "target_keywords": keywords[:6], + "delivery_tips": delivery_tips, + "camera_tips": delivery_tips, + } diff --git a/apps/api/src/app/storage.py b/apps/api/src/app/storage.py new file mode 100644 index 0000000000000000000000000000000000000000..8976cfade292c902c71ef5b565f9082ad5e38103 --- /dev/null +++ b/apps/api/src/app/storage.py @@ -0,0 +1,82 @@ +import os +import shutil +from pathlib import Path +from typing import BinaryIO + +import boto3 + +from app.config import settings + + +class StorageClient: + def __init__(self) -> None: + self.use_s3 = settings.storage_backend == "s3" or all( + [ + settings.s3_endpoint, + settings.s3_access_key, + settings.s3_secret_key, + settings.s3_bucket, + ] + ) + Path(settings.storage_dir).mkdir(parents=True, exist_ok=True) + if self.use_s3: + missing = [ + name + for name, value in { + "S3_ENDPOINT": settings.s3_endpoint, + "S3_ACCESS_KEY": settings.s3_access_key, + "S3_SECRET_KEY": settings.s3_secret_key, + "S3_BUCKET": settings.s3_bucket, + }.items() + if not value + ] + if missing: + raise RuntimeError(f"S3 storage is enabled but missing: {', '.join(missing)}") + self.s3 = boto3.client( + "s3", + endpoint_url=settings.s3_endpoint, + aws_access_key_id=settings.s3_access_key, + aws_secret_access_key=settings.s3_secret_key, + region_name=settings.s3_region or "auto", + ) + self.bucket = settings.s3_bucket + else: + self.s3 = None + self.bucket = None + + def save_file(self, key: str, file_obj: BinaryIO) -> str: + if self.use_s3 and self.s3 and self.bucket: + self.s3.upload_fileobj(file_obj, self.bucket, key) + return f"s3://{self.bucket}/{key}" + path = Path(settings.storage_dir) / key + path.parent.mkdir(parents=True, exist_ok=True) + with open(path, "wb") as out: + shutil.copyfileobj(file_obj, out) + return str(path) + + def delete_file(self, key_or_path: str) -> None: + if key_or_path.startswith("s3://") and self.use_s3 and self.s3 and self.bucket: + key = key_or_path.replace(f"s3://{self.bucket}/", "", 1) + self.s3.delete_object(Bucket=self.bucket, Key=key) + cache_path = Path(settings.storage_dir) / "_s3_cache" / key + if cache_path.exists(): + cache_path.unlink() + return + if os.path.exists(key_or_path): + os.remove(key_or_path) + + def get_local_path(self, key_or_path: str) -> str: + if key_or_path.startswith("s3://"): + if not (self.use_s3 and self.s3 and self.bucket): + raise RuntimeError("S3 file requires configured S3 storage") + prefix = f"s3://{self.bucket}/" + key = key_or_path[len(prefix):] if key_or_path.startswith(prefix) else key_or_path.split("/", 3)[-1] + cache_path = Path(settings.storage_dir) / "_s3_cache" / key + if not cache_path.exists(): + cache_path.parent.mkdir(parents=True, exist_ok=True) + self.s3.download_file(self.bucket, key, str(cache_path)) + return str(cache_path) + return key_or_path + + +storage_client = StorageClient() diff --git a/apps/api/src/app/transcription.py b/apps/api/src/app/transcription.py new file mode 100644 index 0000000000000000000000000000000000000000..8027e11d0ff1965a2d4080d6ea4ec5b8ada73fcd --- /dev/null +++ b/apps/api/src/app/transcription.py @@ -0,0 +1,322 @@ +from collections.abc import Iterable +import os +import platform +import subprocess +import tempfile +from functools import lru_cache +from typing import Any, List + +from faster_whisper import WhisperModel + +from app import hf_models +from app.config import settings + +SERVER_BACKEND = "server" +MLX_AUDIO_BACKEND = "mlx_audio" +TRANSCRIPTION_BACKENDS = (SERVER_BACKEND, MLX_AUDIO_BACKEND) + + +def extract_audio(video_path: str) -> str: + tmp_dir = tempfile.mkdtemp() + audio_path = os.path.join(tmp_dir, "audio.wav") + cmd = [ + "ffmpeg", + "-y", + "-i", + video_path, + "-vn", + "-acodec", + "pcm_s16le", + "-ar", + "16000", + "-ac", + "1", + audio_path, + ] + subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + return audio_path + + +def _normalize_segment(start_seconds: float, end_seconds: float, text: str) -> dict: + return { + "start_ms": int(max(start_seconds, 0) * 1000), + "end_ms": int(max(end_seconds, 0) * 1000), + "text": text.strip(), + } + + +def _get_item_value(item: Any, *keys: str) -> Any: + if isinstance(item, dict): + for key in keys: + value = item.get(key) + if value is not None: + return value + return None + + for key in keys: + value = getattr(item, key, None) + if value is not None: + return value + return None + + +def _coerce_seconds(value: Any, fallback: float = 0.0) -> float: + try: + return float(value) + except (TypeError, ValueError): + return fallback + + +def _is_apple_silicon_mac() -> bool: + return platform.system() == "Darwin" and platform.machine() in {"arm64", "aarch64"} + + +@lru_cache(maxsize=1) +def _mlx_audio_import_ready() -> tuple[bool, str]: + if not _is_apple_silicon_mac(): + return False, "MLX Audio requires macOS on Apple Silicon." + + try: + import mlx_audio # noqa: F401 + except ImportError: + return False, "MLX Audio is not installed in this desktop runtime." + + return True, "MLX Audio is available locally. Download supported models into app storage before transcribing." + + +def get_default_transcription_backend() -> str: + preferred = (settings.transcription_backend or SERVER_BACKEND).strip().lower() + if preferred == MLX_AUDIO_BACKEND: + if get_transcription_backend(MLX_AUDIO_BACKEND)["supported"]: + return MLX_AUDIO_BACKEND + return SERVER_BACKEND + if preferred in TRANSCRIPTION_BACKENDS: + return preferred + return SERVER_BACKEND + + +def get_default_model_for_backend(backend_id: str) -> str: + if backend_id == MLX_AUDIO_BACKEND: + try: + return hf_models.resolve_mlx_audio_model_entry(settings.mlx_audio_model)["id"] + except ValueError: + return hf_models.get_mlx_audio_model_ids()[0] + + try: + return hf_models.resolve_server_model_entry(settings.whisper_model)["id"] + except ValueError: + return hf_models.get_server_model_ids()[0] + + +def get_transcription_backend(backend_id: str) -> dict: + if backend_id == SERVER_BACKEND: + return { + "id": SERVER_BACKEND, + "label": "Built-in Server", + "supported": True, + "live": True, + "detail": "Runs inside the API process using faster-whisper after you download a supported Whisper model.", + "models": hf_models.get_server_model_catalog(), + "default_model": get_default_model_for_backend(SERVER_BACKEND), + } + + if backend_id == MLX_AUDIO_BACKEND: + ready, detail = _mlx_audio_import_ready() + return { + "id": MLX_AUDIO_BACKEND, + "label": "MLX Audio", + "supported": ready, + "live": ready, + "detail": detail, + "models": hf_models.get_mlx_audio_model_catalog(), + "default_model": get_default_model_for_backend(MLX_AUDIO_BACKEND), + } + + raise ValueError(f"Unsupported transcription backend '{backend_id}'.") + + +def get_transcription_backends() -> list[dict]: + return [ + get_transcription_backend(SERVER_BACKEND), + get_transcription_backend(MLX_AUDIO_BACKEND), + ] + + +def infer_backend_from_model(model_name: str | None) -> str | None: + if not model_name: + return None + if model_name in hf_models.get_mlx_audio_model_keys(): + return MLX_AUDIO_BACKEND + if model_name in hf_models.get_server_model_keys(): + return SERVER_BACKEND + return None + + +def resolve_transcription_backend( + backend_id: str | None = None, + model_name: str | None = None, +) -> str: + selected_backend = (backend_id or infer_backend_from_model(model_name) or get_default_transcription_backend()).strip().lower() + if selected_backend not in TRANSCRIPTION_BACKENDS: + raise ValueError( + f"Unsupported transcription backend '{selected_backend}'. " + f"Choose one of: {', '.join(TRANSCRIPTION_BACKENDS)}" + ) + + status = get_transcription_backend(selected_backend) + if not status["supported"]: + raise ValueError(status["detail"]) + return selected_backend + + +def resolve_server_model(model_name: str | None = None) -> str: + return hf_models.resolve_server_model_entry(model_name or get_default_model_for_backend(SERVER_BACKEND))["id"] + + +def resolve_whisper_runtime_model(model_name: str | None = None) -> str: + return hf_models.resolve_server_model_entry(model_name or get_default_model_for_backend(SERVER_BACKEND))[ + "runtime_model_id" + ] + + +def resolve_mlx_audio_model(model_name: str | None = None) -> str: + return hf_models.resolve_mlx_audio_model_entry(model_name or get_default_model_for_backend(MLX_AUDIO_BACKEND))["id"] + + +def resolve_transcription_model(model_name: str | None = None, backend_id: str | None = None) -> str: + selected_backend = resolve_transcription_backend(backend_id, model_name) + if selected_backend == MLX_AUDIO_BACKEND: + return resolve_mlx_audio_model(model_name) + return resolve_server_model(model_name) + + +@lru_cache(maxsize=8) +def _load_whisper_model(model_ref: str, device: str) -> WhisperModel: + return WhisperModel(model_ref, device=device, compute_type="int8") + + +@lru_cache(maxsize=4) +def _load_mlx_audio_model(model_ref: str): + try: + from mlx_audio.stt.utils import load as load_mlx_audio_model + except ImportError as exc: + raise RuntimeError( + "MLX Audio is not installed. Install mlx-audio in the desktop runtime to use MLX ASR models." + ) from exc + + return load_mlx_audio_model(model_ref) + + +def ensure_server_model_downloaded(model_name: str) -> dict: + model = hf_models.resolve_server_model_entry(model_name) + if not model["available"] or not model["local_path"]: + raise ValueError( + f"Whisper model '{model['name']}' is not downloaded yet. " + "Download it from Settings before transcribing." + ) + return model + + +def ensure_mlx_audio_model_downloaded(model_name: str) -> dict: + model = hf_models.resolve_mlx_audio_model_entry(model_name) + if not model["available"] or not model["local_path"]: + raise ValueError( + f"MLX Audio model '{model['name']}' is not downloaded yet. " + "Download it from Settings before transcribing." + ) + return model + + +def ensure_transcription_model_ready(backend_id: str, model_name: str) -> None: + if backend_id == SERVER_BACKEND: + ensure_server_model_downloaded(model_name) + return + if backend_id == MLX_AUDIO_BACKEND: + ensure_mlx_audio_model_downloaded(model_name) + + +def download_transcription_model(backend_id: str, model_name: str) -> dict: + selected_backend = resolve_transcription_backend(backend_id, model_name) + if selected_backend == SERVER_BACKEND: + return hf_models.download_server_model(model_name) + if selected_backend == MLX_AUDIO_BACKEND: + return hf_models.download_mlx_audio_model(model_name) + raise ValueError(f"Unsupported transcription backend '{selected_backend}'.") + + +def _transcribe_with_whisper(audio_path: str, model_name: str) -> List[dict]: + model_info = ensure_server_model_downloaded(model_name) + model = _load_whisper_model(model_info["local_path"], settings.whisper_device) + segments, _info = model.transcribe(audio_path) + return [ + { + "start_ms": int(segment.start * 1000), + "end_ms": int(segment.end * 1000), + "text": segment.text.strip(), + } + for segment in segments + ] + + +def _normalize_segments_from_items(items: Iterable[Any]) -> List[dict]: + results = [] + for item in items: + text = _get_item_value(item, "text", "sentence", "segment", "content") + if not isinstance(text, str) or not text.strip(): + continue + + start_seconds = _coerce_seconds( + _get_item_value(item, "start", "start_time", "start_seconds", "begin"), + 0.0, + ) + end_seconds = _coerce_seconds( + _get_item_value(item, "end", "end_time", "end_seconds", "stop"), + start_seconds, + ) + results.append(_normalize_segment(start_seconds, end_seconds, text)) + return results + + +def _extract_mlx_audio_segments(result: Any) -> List[dict]: + for field_name in ("sentences", "segments"): + values = _get_item_value(result, field_name) + if isinstance(values, Iterable) and not isinstance(values, (str, bytes, dict)): + segments = _normalize_segments_from_items(values) + if segments: + return segments + + if isinstance(result, Iterable) and not isinstance(result, (str, bytes, dict)): + segments = _normalize_segments_from_items(result) + if segments: + return segments + + text = _get_item_value(result, "text", "transcript", "content") + if isinstance(text, str) and text.strip(): + return [_normalize_segment(0, 0, text)] + + if isinstance(result, str) and result.strip(): + return [_normalize_segment(0, 0, result)] + + return [] + + +def _transcribe_with_mlx_audio(audio_path: str, model_name: str) -> List[dict]: + model_info = ensure_mlx_audio_model_downloaded(model_name) + model = _load_mlx_audio_model(model_info["local_path"]) + if not hasattr(model, "generate"): + raise RuntimeError("Loaded MLX Audio model does not expose a generate() method.") + result = model.generate(audio_path) + return _extract_mlx_audio_segments(result) + + +def transcribe_audio( + audio_path: str, + model_name: str | None = None, + backend_id: str | None = None, +) -> List[dict]: + selected_backend = resolve_transcription_backend(backend_id, model_name) + selected_model = resolve_transcription_model(model_name, selected_backend) + + if selected_backend == MLX_AUDIO_BACKEND: + return _transcribe_with_mlx_audio(audio_path, selected_model) + return _transcribe_with_whisper(audio_path, selected_model) diff --git a/apps/api/src/app/worker/__init__.py b/apps/api/src/app/worker/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d4dcfc173fca8a93e9fea86d65bf4435990d7d2b --- /dev/null +++ b/apps/api/src/app/worker/__init__.py @@ -0,0 +1 @@ +"""arq worker package for Speech-Machine background jobs.""" diff --git a/apps/api/src/app/worker/enqueue.py b/apps/api/src/app/worker/enqueue.py new file mode 100644 index 0000000000000000000000000000000000000000..b363aaf1cc3b790524644e7d5ea4807cb71dd601 --- /dev/null +++ b/apps/api/src/app/worker/enqueue.py @@ -0,0 +1,122 @@ +from __future__ import annotations + +import asyncio +import threading +import uuid +from typing import Any + +from app.config import settings + +_pool = None +_local_jobs: dict[str, dict[str, Any]] = {} +_jobs_lock = threading.Lock() + + +def _set_local_job(job_id: str, status: str, result: Any = None, error: str | None = None) -> None: + with _jobs_lock: + _local_jobs[job_id] = { + "status": status, + "result": result, + "error": error, + } + # Bound memory to most recent 500 local jobs + if len(_local_jobs) > 500: + oldest_key = next(iter(_local_jobs)) + _local_jobs.pop(oldest_key, None) + + +def _run_coro_in_thread(job_id: str, coro_fn, *args: Any) -> None: + def runner() -> None: + _set_local_job(job_id, "in_progress") + try: + res = asyncio.run(coro_fn({}, *args)) + if isinstance(res, dict) and res.get("status") == "failed": + _set_local_job(job_id, "failed", result=None, error=str(res.get("error") or "Job failed")) + else: + _set_local_job(job_id, "complete", result=res, error=None) + except Exception as exc: # pragma: no cover - defensive thread wrapper + _set_local_job(job_id, "failed", result=None, error=str(exc)) + + thread = threading.Thread(target=runner, daemon=True) + thread.start() + + +async def get_arq_pool(): + global _pool + if _pool is None: + from arq.connections import RedisSettings, create_pool + + _pool = await create_pool( + RedisSettings(host=settings.redis_host, port=settings.redis_port, conn_timeout=1, conn_retries=0) + ) + return _pool + + +async def enqueue_transcription( + session_id: int, + user_id: int, + model_name: str | None, + backend_id: str | None, +) -> str: + try: + pool = await get_arq_pool() + job = await pool.enqueue_job( + "transcribe_recording", + session_id, + user_id, + model_name, + backend_id, + ) + if job is not None: + return job.job_id + except Exception: + if not settings.use_local_worker_fallback: + raise + + from app.worker.jobs import transcribe_recording + + job_id = f"local-transcribe-{uuid.uuid4().hex[:12]}" + _set_local_job(job_id, "pending") + _run_coro_in_thread(job_id, transcribe_recording, session_id, user_id, model_name, backend_id) + return job_id + + +async def enqueue_coaching(session_id: int, user_id: int) -> str: + try: + pool = await get_arq_pool() + job = await pool.enqueue_job("generate_session_coaching", session_id, user_id) + if job is not None: + return job.job_id + except Exception: + if not settings.use_local_worker_fallback: + raise + + from app.worker.jobs import generate_session_coaching + + job_id = f"local-coaching-{uuid.uuid4().hex[:12]}" + _set_local_job(job_id, "pending") + _run_coro_in_thread(job_id, generate_session_coaching, session_id, user_id) + return job_id + + +async def get_job_status(job_id: str) -> dict: + with _jobs_lock: + if job_id in _local_jobs: + return dict(_local_jobs[job_id]) + + try: + pool = await get_arq_pool() + from arq.jobs import Job + + job = Job(job_id, pool) + info = await job.info() + if info is None: + return {"status": "pending", "result": None, "error": None} + status_value = info.status.value if info.status else "unknown" + return { + "status": status_value, + "result": info.result if info.success else None, + "error": str(info.result) if not info.success and info.result else None, + } + except Exception: + return {"status": "not_found", "result": None, "error": None} diff --git a/apps/api/src/app/worker/jobs.py b/apps/api/src/app/worker/jobs.py new file mode 100644 index 0000000000000000000000000000000000000000..75f8cca2e24ba276e40dbe6644e94417eaf1ff76 --- /dev/null +++ b/apps/api/src/app/worker/jobs.py @@ -0,0 +1,177 @@ +import json +from typing import Any + +from app import models +from app.config import settings +from app.db import SessionLocal +from app.routes.sessions import _run_transcription +from app.services.analytics import compute_session_metrics, get_active_goal_context, get_session_history +from app.services.coaching import generate_coaching_feedback + + +async def transcribe_recording( + ctx: dict[str, Any], + session_id: int, + user_id: int, + model_name: str | None = None, + backend_id: str | None = None, +) -> dict: + _run_transcription(session_id, user_id, model_name, backend_id) + should_generate_inline = False + with SessionLocal() as db: + session = ( + db.query(models.Session) + .filter(models.Session.id == session_id, models.Session.user_id == user_id) + .first() + ) + if session and session.transcription_status == "complete": + compute_session_metrics(db, session_id) + if settings.coaching_enabled: + feedback = _upsert_coaching_status(db, session_id, user_id, "pending") + pool = ctx.get("redis") + if pool is not None: + job = await pool.enqueue_job("generate_session_coaching", session_id, user_id) + feedback.trend_context = {"job_id": job.job_id if job else None} + db.commit() + else: + should_generate_inline = True + if should_generate_inline: + await generate_session_coaching(ctx, session_id, user_id) + return {"session_id": session_id, "status": "complete"} + + +async def generate_session_coaching( + ctx: dict[str, Any], + session_id: int, + user_id: int, +) -> dict: + with SessionLocal() as db: + session = ( + db.query(models.Session) + .filter(models.Session.id == session_id, models.Session.user_id == user_id) + .first() + ) + if session is None: + return {"session_id": session_id, "status": "failed", "error": "Session not found"} + + feedback = _upsert_coaching_status(db, session_id, user_id, "generating") + snapshot = compute_session_metrics(db, session_id) + metrics = _snapshot_to_dict(snapshot, session) + transcript = _transcript_text(session) + history = get_session_history(db, user_id, limit=10) + active_goals = get_active_goal_context(db, user_id) + previous = ( + db.query(models.CoachingFeedback) + .filter( + models.CoachingFeedback.user_id == user_id, + models.CoachingFeedback.session_id != session_id, + models.CoachingFeedback.status == "completed", + ) + .order_by(models.CoachingFeedback.created_at.desc()) + .first() + ) + previous_feedback = None + if previous: + previous_feedback = { + "summary": previous.summary, + "improvements": previous.improvements or [], + "practice_plan": previous.practice_plan or {}, + } + + try: + result = generate_coaching_feedback(transcript, metrics, history, previous_feedback, active_goals) + except Exception as exc: + with SessionLocal() as db: + feedback = _upsert_coaching_status(db, session_id, user_id, "failed") + feedback.error_message = str(exc)[:1000] + db.commit() + return {"session_id": session_id, "status": "failed", "error": str(exc)} + + with SessionLocal() as db: + feedback = _upsert_coaching_status(db, session_id, user_id, "completed") + feedback.summary = result["summary"] + feedback.strengths = result["strengths"] + feedback.improvements = result["improvements"] + feedback.drills = result["drills"] + feedback.practice_plan = result["practice_plan"] + feedback.raw_response = result["raw_response"] + feedback.model_used = result["model_used"] + feedback.generation_time_ms = result["generation_time_ms"] + feedback.error_message = None + feedback.trend_context = { + "sessions_analyzed": len(history), + "progress_note": result.get("progress_note", ""), + } + db.commit() + return {"session_id": session_id, "status": "completed", "feedback_id": feedback.id} + + +def _upsert_coaching_status( + db, + session_id: int, + user_id: int, + status: str, +) -> models.CoachingFeedback: + feedback = ( + db.query(models.CoachingFeedback) + .filter(models.CoachingFeedback.session_id == session_id) + .first() + ) + if feedback is None: + feedback = models.CoachingFeedback(session_id=session_id, user_id=user_id, status=status) + db.add(feedback) + feedback.user_id = user_id + feedback.status = status + if status in {"pending", "generating"}: + feedback.error_message = None + db.commit() + db.refresh(feedback) + return feedback + + +def _transcript_text(session: models.Session) -> str: + return "\n".join( + f"[{segment.start_ms / 1000:.1f}s] {segment.text}" + for segment in session.transcript_segments + ) + + +def _parse_keywords(raw: str | None) -> list[str]: + if not raw: + return [] + try: + parsed = json.loads(raw) + if isinstance(parsed, list): + return [str(k) for k in parsed if str(k).strip()] + except json.JSONDecodeError: + pass + return [] + + +def _snapshot_to_dict(snapshot: models.SessionMetrics, session: models.Session | None = None) -> dict[str, Any]: + payload = { + "duration_seconds": snapshot.duration_seconds, + "word_count": snapshot.word_count, + "wpm": snapshot.wpm, + "filler_count": snapshot.filler_count, + "filler_rate": snapshot.filler_rate, + "pause_count": snapshot.pause_count, + "vocabulary_density": snapshot.vocabulary_density, + "speech_score": snapshot.speech_score, + "delivery_score": snapshot.delivery_score, + "overall_score": snapshot.overall_score, + "eye_contact_score": snapshot.eye_contact_score, + "smile_score": snapshot.smile_score, + "head_stability_score": snapshot.head_stability_score, + "loudness_score": snapshot.loudness_score, + "filler_breakdown": snapshot.filler_breakdown or {}, + } + if session is not None: + payload["session_title"] = session.title or "" + payload["session_mode"] = session.mode or "Practice" + payload["session_prompt"] = session.prompt or "" + payload["session_goal"] = session.goal or "" + payload["curriculum_day"] = session.curriculum_day + payload["story_framework"] = session.story_framework + payload["target_keywords"] = _parse_keywords(session.target_keywords_json) + return payload diff --git a/apps/api/src/app/worker/settings.py b/apps/api/src/app/worker/settings.py new file mode 100644 index 0000000000000000000000000000000000000000..bd6d52a57b4c786ee6dfe2a8554ce63e2b3b416e --- /dev/null +++ b/apps/api/src/app/worker/settings.py @@ -0,0 +1,15 @@ +from arq.connections import RedisSettings + +from app.config import settings +from app.worker.jobs import generate_session_coaching, transcribe_recording + + +class WorkerSettings: + functions = [transcribe_recording, generate_session_coaching] + redis_settings = RedisSettings(host=settings.redis_host, port=settings.redis_port) + max_jobs = 2 + job_timeout = 1800 + keep_result = 3600 + retry_jobs = True + max_tries = 3 + health_check_interval = 30 diff --git a/apps/api/tests/test_api.py b/apps/api/tests/test_api.py new file mode 100644 index 0000000000000000000000000000000000000000..04675397e13483711d175cfc41d11880a3159a38 --- /dev/null +++ b/apps/api/tests/test_api.py @@ -0,0 +1,764 @@ +import json +import os +import tempfile +import zipfile +import asyncio +from io import BytesIO +from uuid import uuid4 + +test_id = uuid4().hex +os.environ["DATABASE_URL"] = f"sqlite:///./test_{test_id}.db" +os.environ["LOCAL_ONLY_MODE"] = "true" +os.environ["STORAGE_DIR"] = os.path.join(tempfile.gettempdir(), f"speech_machine_test_{test_id}") + +from fastapi.testclient import TestClient +from sqlalchemy import create_engine, inspect + +from app import models, schema_upgrades +from app.db import Base, SessionLocal, engine +from app.main import app +from app.routes import settings as settings_routes +from app.routes import coaching as coaching_routes +from app.services.coaching import build_coaching_prompt, parse_coaching_response +from app.worker import jobs as worker_jobs + +client = TestClient(app) +Base.metadata.create_all(bind=engine) +_admin_headers: dict[str, str] | None = None + + +def get_admin_headers() -> dict[str, str]: + global _admin_headers + if _admin_headers is not None: + return _admin_headers + status_response = client.get("/setup/status") + if status_response.json()["setup_required"]: + setup_response = client.post( + "/setup", + json={"email": "admin@example.com", "password": "secret123"}, + ) + assert setup_response.status_code == 201 + response = client.post("/auth/login", json={"email": "admin@example.com", "password": "secret123"}) + assert response.status_code == 200 + _admin_headers = {"Authorization": f"Bearer {response.json()['access_token']}"} + return _admin_headers + + +def create_transcription_model(model_id: str, name: str, available: bool = True, local_path: str | None = None): + return { + "id": model_id, + "name": name, + "repo_id": model_id, + "description": f"{name} model", + "languages": "Multiple languages", + "available": available, + "local_path": local_path, + } + + +def test_auth_flow(): + response = client.get("/setup/status") + if response.json()["setup_required"]: + response = client.post("/setup", json={"email": "admin@example.com", "password": "secret123"}) + assert response.status_code == 201 + + response = client.post("/auth/signup", json={"email": "test@example.com", "password": "secret123"}) + assert response.status_code == 200 + token = response.json()["access_token"] + assert token + + response = client.post("/auth/login", json={"email": "test@example.com", "password": "secret123"}) + assert response.status_code == 200 + + +def test_refresh_rotation_and_logout(): + status_response = client.get("/setup/status") + if status_response.json()["setup_required"]: + client.post("/setup", json={"email": "admin@example.com", "password": "secret123"}) + + login_response = client.post( + "/auth/login", + json={"email": "admin@example.com", "password": "secret123"}, + ) + assert login_response.status_code == 200 + + first_refresh_cookie = login_response.cookies.get("refresh_token") + refresh_response = client.post("/auth/refresh", cookies={"refresh_token": first_refresh_cookie}) + assert refresh_response.status_code == 200 + assert refresh_response.cookies.get("refresh_token") + assert refresh_response.cookies.get("refresh_token") != first_refresh_cookie + + reused_response = client.post("/auth/refresh", cookies={"refresh_token": first_refresh_cookie}) + assert reused_response.status_code == 401 + + logout_response = client.post("/auth/logout", cookies={"refresh_token": refresh_response.cookies.get("refresh_token")}) + assert logout_response.status_code == 200 + after_logout = client.post("/auth/refresh", cookies={"refresh_token": refresh_response.cookies.get("refresh_token")}) + assert after_logout.status_code == 401 + + +def test_health_endpoints(): + assert client.get("/health").status_code == 200 + response = client.get("/health/ready") + assert response.status_code == 200 + assert "database" in response.json()["checks"] + + +def test_transcription_settings_endpoint(monkeypatch): + monkeypatch.setattr(settings_routes.transcription, "get_default_transcription_backend", lambda: "mlx_audio") + monkeypatch.setattr( + settings_routes.transcription, + "get_transcription_backends", + lambda: [ + { + "id": "server", + "label": "Built-in Server", + "supported": True, + "live": True, + "detail": "Server path", + "models": [ + create_transcription_model("openai/whisper-small", "Whisper Small", available=False), + create_transcription_model("openai/whisper-medium", "Whisper Medium", available=True), + ], + "default_model": "openai/whisper-small", + }, + { + "id": "mlx_audio", + "label": "MLX Audio", + "supported": True, + "live": True, + "detail": "Apple Silicon local ASR.", + "models": [ + create_transcription_model( + "mlx-community/parakeet-tdt-0.6b-v3", + "Parakeet", + local_path="/tmp/models/parakeet", + ) + ], + "default_model": "mlx-community/parakeet-tdt-0.6b-v3", + }, + ], + ) + monkeypatch.setattr( + settings_routes, + "get_all_engine_statuses", + lambda: [ + { + "id": "ollama", + "label": "Ollama", + "base_url": "http://127.0.0.1:11434", + "live": True, + "detail": "Connected", + "available_models": ["qwen3-asr"], + } + ], + ) + headers = get_admin_headers() + + response = client.get("/settings/transcription", headers=headers) + assert response.status_code == 200 + assert response.json()["default_backend"] == "mlx_audio" + assert response.json()["backends"][1]["id"] == "mlx_audio" + assert response.json()["backends"][1]["models"][0]["id"] == "mlx-community/parakeet-tdt-0.6b-v3" + assert response.json()["backends"][1]["models"][0]["available"] is True + assert response.json()["text_inference_engines"][0]["id"] == "ollama" + + +def test_transcription_settings_endpoint_marks_unsupported_mlx_audio(monkeypatch): + monkeypatch.setattr(settings_routes.transcription, "get_default_transcription_backend", lambda: "server") + monkeypatch.setattr( + settings_routes.transcription, + "get_transcription_backends", + lambda: [ + { + "id": "server", + "label": "Built-in Server", + "supported": True, + "live": True, + "detail": "Server path", + "models": [create_transcription_model("openai/whisper-small", "Whisper Small", available=False)], + "default_model": "openai/whisper-small", + }, + { + "id": "mlx_audio", + "label": "MLX Audio", + "supported": False, + "live": False, + "detail": "MLX Audio requires macOS on Apple Silicon.", + "models": [create_transcription_model("mlx-community/parakeet-tdt-0.6b-v3", "Parakeet", available=False)], + "default_model": None, + }, + ], + ) + monkeypatch.setattr(settings_routes, "get_all_engine_statuses", lambda: []) + headers = get_admin_headers() + + response = client.get("/settings/transcription", headers=headers) + assert response.status_code == 200 + assert response.json()["default_backend"] == "server" + assert response.json()["backends"][1]["supported"] is False + assert response.json()["backends"][1]["models"][0]["available"] is False + + +def test_transcription_model_download_endpoint(monkeypatch): + monkeypatch.setattr( + settings_routes.transcription, + "download_transcription_model", + lambda backend_id, model_id: create_transcription_model( + model_id, + "Parakeet", + available=True, + local_path="/tmp/models/parakeet", + ), + ) + headers = get_admin_headers() + + response = client.post( + "/settings/transcription/models/download", + json={"backend": "mlx_audio", "model": "mlx-community/parakeet-tdt-0.6b-v3"}, + headers=headers, + ) + assert response.status_code == 200 + assert response.json()["id"] == "mlx-community/parakeet-tdt-0.6b-v3" + assert response.json()["available"] is True + assert response.json()["local_path"] == "/tmp/models/parakeet" + + +def test_local_inference_check_endpoint(monkeypatch): + monkeypatch.setattr( + settings_routes, + "check_engine", + lambda engine_id, base_url=None: { + "id": engine_id, + "label": "LM Studio", + "base_url": base_url or "http://127.0.0.1:1234/v1", + "live": True, + "detail": "Connected", + "available_models": ["mistralai/Voxtral-Mini-4B-Realtime-2602"], + }, + ) + headers = get_admin_headers() + + response = client.post( + "/settings/local-inference/check", + json={"engine_id": "lmstudio", "base_url": "http://localhost:1234/v1"}, + headers=headers, + ) + assert response.status_code == 200 + assert response.json()["live"] is True + assert response.json()["available_models"] == ["mistralai/Voxtral-Mini-4B-Realtime-2602"] + + +def test_session_create_and_upload(): + headers = get_admin_headers() + + payload = { + "title": "Test", + "mode": "Interview", + "duration_ms": 1000, + "wpm_avg": 120, + "filler_count": 0, + "eye_contact_pct": 0.8, + "tags": ["demo"], + "transcript_segments": [{"start_ms": 0, "end_ms": 1000, "text": "Hello"}], + "metrics": [{"t": 0, "wpm": 120, "rms": 50, "eye_contact": 0.8, "smile": 0.3, "yaw": 0.1, "pitch": 0.2, "roll": 0.1, "filler_count": 0, "pause_ms": 800}], + } + response = client.post("/sessions", json=payload, headers=headers) + assert response.status_code == 200 + session_id = response.json()["id"] + + video_data = BytesIO(b"\x1a\x45\xdf\xa3webm-data") + files = {"file": ("test.webm", video_data, "video/webm")} + response = client.post(f"/sessions/{session_id}/upload", files=files, headers=headers) + assert response.status_code == 200 + assert response.json()["status"] == "complete" + + response = client.get("/sessions", headers=headers) + assert response.status_code == 200 + assert len(response.json()) >= 1 + + +def test_export_includes_recording_file(): + headers = get_admin_headers() + + payload = { + "title": "Export", + "mode": "Interview", + "duration_ms": 1000, + "transcript_segments": [{"start_ms": 0, "end_ms": 1000, "text": "Hello"}], + "metrics": [], + } + response = client.post("/sessions", json=payload, headers=headers) + session_id = response.json()["id"] + + files = {"file": ("test.webm", BytesIO(b"\x1a\x45\xdf\xa3webm-data"), "video/webm")} + upload_response = client.post(f"/sessions/{session_id}/upload", files=files, headers=headers) + assert upload_response.status_code == 200 + + export_response = client.get(f"/sessions/{session_id}/export", headers=headers) + assert export_response.status_code == 200 + with zipfile.ZipFile(BytesIO(export_response.content)) as archive: + assert "recording.webm" in archive.namelist() + assert "transcript.txt" in archive.namelist() + assert "transcript.pdf" in archive.namelist() + + +def test_transcript_download_formats(): + headers = get_admin_headers() + + payload = { + "title": "Transcript Download", + "mode": "Upload", + "duration_ms": 61000, + "transcript_segments": [ + {"start_ms": 0, "end_ms": 1000, "text": "Hello world"}, + {"start_ms": 1000, "end_ms": 2000, "text": "This is a transcript"}, + ], + "metrics": [], + } + response = client.post("/sessions", json=payload, headers=headers) + session_id = response.json()["id"] + + db_response = client.get(f"/sessions/{session_id}", headers=headers) + assert db_response.status_code == 200 + + txt_response = client.get(f"/sessions/{session_id}/transcript.txt", headers=headers) + assert txt_response.status_code == 200 + assert txt_response.headers["content-type"].startswith("text/plain") + assert "Hello world" in txt_response.text + + pdf_response = client.get(f"/sessions/{session_id}/transcript.pdf", headers=headers) + assert pdf_response.status_code == 200 + assert pdf_response.headers["content-type"] == "application/pdf" + assert pdf_response.content.startswith(b"%PDF") + + +def test_delete_video_keeps_session_transcript(): + headers = get_admin_headers() + + payload = { + "title": "Delete Video", + "mode": "Upload", + "duration_ms": 5000, + "transcript_segments": [{"start_ms": 0, "end_ms": 1000, "text": "Keep transcript"}], + "metrics": [], + } + response = client.post("/sessions", json=payload, headers=headers) + session_id = response.json()["id"] + + files = {"file": ("test.webm", BytesIO(b"\x1a\x45\xdf\xa3webm-data"), "video/webm")} + upload_response = client.post(f"/sessions/{session_id}/upload", files=files, headers=headers) + assert upload_response.status_code == 200 + + delete_response = client.delete(f"/sessions/{session_id}/video", headers=headers) + assert delete_response.status_code == 200 + assert delete_response.json()["status"] == "deleted" + assert delete_response.json()["transcription_status"] == "complete" + + session_response = client.get(f"/sessions/{session_id}", headers=headers) + assert session_response.status_code == 200 + assert session_response.json()["video_path"] is None + + video_response = client.get(f"/sessions/{session_id}/video", headers=headers) + assert video_response.status_code == 404 + + txt_response = client.get(f"/sessions/{session_id}/transcript.txt", headers=headers) + assert txt_response.status_code == 200 + assert "Keep transcript" in txt_response.text + + +def test_video_upload_rejects_invalid_magic_bytes(): + headers = get_admin_headers() + + payload = { + "title": "Invalid Upload", + "mode": "Upload", + "duration_ms": 1000, + "transcript_segments": [], + "metrics": [], + } + response = client.post("/sessions", json=payload, headers=headers) + session_id = response.json()["id"] + + upload_response = client.post( + f"/sessions/{session_id}/upload", + files={"file": ("bad.webm", BytesIO(b"not-a-webm"), "video/webm")}, + headers=headers, + ) + assert upload_response.status_code == 415 + + +def test_session_document_upload_round_trip_and_full_delete(): + headers = get_admin_headers() + + payload = { + "title": "Document Session", + "mode": "Interview", + "duration_ms": 5000, + "transcript_segments": [], + "metrics": [], + } + response = client.post("/sessions", json=payload, headers=headers) + session_id = response.json()["id"] + + document_blocks = [{"index": 0, "text": "Read this paragraph aloud."}] + upload_response = client.post( + f"/sessions/{session_id}/document", + files={"file": ("script.txt", BytesIO(b"Read this paragraph aloud."), "text/plain")}, + data={"blocks_json": json.dumps(document_blocks)}, + headers=headers, + ) + assert upload_response.status_code == 200 + assert upload_response.json()["name"] == "script.txt" + assert upload_response.json()["mime_type"] == "text/plain" + + session_response = client.get(f"/sessions/{session_id}", headers=headers) + assert session_response.status_code == 200 + assert session_response.json()["document"]["name"] == "script.txt" + assert session_response.json()["document"]["blocks"][0]["text"] == "Read this paragraph aloud." + + with SessionLocal() as db: + stored = db.query(models.Session).filter(models.Session.id == session_id).first() + assert stored is not None + assert stored.document_path is not None + document_path = stored.document_path + assert os.path.exists(document_path) + + delete_response = client.delete(f"/sessions/{session_id}", headers=headers) + assert delete_response.status_code == 200 + assert delete_response.json()["status"] == "deleted" + assert not os.path.exists(document_path) + + missing_response = client.get(f"/sessions/{session_id}", headers=headers) + assert missing_response.status_code == 404 + + +def test_document_upload_rejects_unsupported_types(): + headers = get_admin_headers() + + payload = { + "title": "Unsupported Doc", + "mode": "Interview", + "duration_ms": 1000, + "transcript_segments": [], + "metrics": [], + } + response = client.post("/sessions", json=payload, headers=headers) + session_id = response.json()["id"] + + upload_response = client.post( + f"/sessions/{session_id}/document", + files={ + "file": ( + "script.docx", + BytesIO(b"fake-docx"), + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + ) + }, + data={"blocks_json": json.dumps([{"index": 0, "text": "Unsupported"}])}, + headers=headers, + ) + assert upload_response.status_code == 400 + assert "Unsupported document type" in upload_response.json()["detail"] + + +def test_dashboard_endpoints_populate_metrics_and_aggregates(): + headers = get_admin_headers() + + first = client.post( + "/sessions", + json={ + "title": "Analytics One", + "mode": "Practice", + "duration_ms": 60000, + "wpm_avg": 120, + "filler_count": 2, + "eye_contact_pct": 0.75, + "transcript_segments": [{"start_ms": 0, "end_ms": 60000, "text": "Um hello world like today"}], + "metrics": [ + {"t": 0, "wpm": 120, "rms": 50, "eye_contact": 0.75, "smile": 0.4, "yaw": 0.1, "pitch": 0.2, "roll": 0.1, "filler_count": 1, "pause_ms": 900} + ], + }, + headers=headers, + ) + assert first.status_code == 200 + second = client.post( + "/sessions", + json={ + "title": "Analytics Two", + "mode": "Practice", + "duration_ms": 60000, + "wpm_avg": 140, + "filler_count": 0, + "eye_contact_pct": 0.8, + "transcript_segments": [{"start_ms": 0, "end_ms": 60000, "text": "First we improve outcomes"}], + "metrics": [ + {"t": 0, "wpm": 140, "rms": 60, "eye_contact": 0.8, "smile": 0.5, "yaw": 0.1, "pitch": 0.1, "roll": 0.1, "filler_count": 0, "pause_ms": 700} + ], + }, + headers=headers, + ) + assert second.status_code == 200 + + overview = client.get("/dashboard/overview", headers=headers) + assert overview.status_code == 200 + assert overview.json()["total_sessions"] >= 2 + assert overview.json()["recent_30d"]["avg_overall_score"] >= 0 + + trends = client.get("/dashboard/trends/avg_overall_score", headers=headers) + assert trends.status_code == 200 + assert len(trends.json()) >= 1 + + sessions_response = client.get("/dashboard/sessions", headers=headers) + assert sessions_response.status_code == 200 + assert any(item["session_id"] == second.json()["id"] for item in sessions_response.json()) + + heatmap = client.get("/dashboard/heatmap", headers=headers) + assert heatmap.status_code == 200 + assert heatmap.json()[-1]["count"] >= 1 + + fillers = client.get("/dashboard/fillers", headers=headers) + assert fillers.status_code == 200 + assert fillers.json()["um"] >= 1 + + compare = client.get( + f"/dashboard/compare?session_a={first.json()['id']}&session_b={second.json()['id']}", + headers=headers, + ) + assert compare.status_code == 200 + assert "overall_score" in compare.json()["comparison"] + + +def test_dashboard_is_user_scoped(): + headers = get_admin_headers() + signup = client.post("/auth/signup", json={"email": "scoped@example.com", "password": "secret123"}) + assert signup.status_code == 200 + other_headers = {"Authorization": f"Bearer {signup.json()['access_token']}"} + + response = client.post( + "/sessions", + json={ + "title": "Private", + "mode": "Practice", + "duration_ms": 1000, + "transcript_segments": [{"start_ms": 0, "end_ms": 1000, "text": "Private session"}], + "metrics": [], + }, + headers=headers, + ) + assert response.status_code == 200 + + other_sessions = client.get("/dashboard/sessions", headers=other_headers) + assert other_sessions.status_code == 200 + assert all(item["session_id"] != response.json()["id"] for item in other_sessions.json()) + + +def test_goal_crud_progress_and_user_scoping(): + headers = get_admin_headers() + session_response = client.post( + "/sessions", + json={ + "title": "Goal baseline", + "mode": "Practice", + "duration_ms": 60000, + "wpm_avg": 130, + "transcript_segments": [{"start_ms": 0, "end_ms": 60000, "text": "A focused baseline session"}], + "metrics": [], + }, + headers=headers, + ) + assert session_response.status_code == 200 + assert client.get("/dashboard/overview", headers=headers).status_code == 200 + create_response = client.post( + "/dashboard/goals", + json={ + "metric_name": "wpm", + "comparison": "between", + "target_value": 100, + "target_upper": 160, + "is_active": True, + }, + headers=headers, + ) + assert create_response.status_code == 201 + goal = create_response.json() + assert goal["metric_name"] == "wpm" + assert goal["current_value"] is not None + assert goal["achieved"] is True + + duplicate = client.post( + "/dashboard/goals", + json={"metric_name": "wpm", "comparison": "gte", "target_value": 120}, + headers=headers, + ) + assert duplicate.status_code == 409 + + invalid_range = client.post( + "/dashboard/goals", + json={ + "metric_name": "overall_score", + "comparison": "between", + "target_value": 80, + "target_upper": 70, + }, + headers=headers, + ) + assert invalid_range.status_code == 422 + + update_response = client.patch( + f"/dashboard/goals/{goal['id']}", + json={"comparison": "gte", "target_value": 200, "is_active": False}, + headers=headers, + ) + assert update_response.status_code == 200 + assert update_response.json()["target_upper"] is None + assert update_response.json()["achieved"] is False + assert update_response.json()["is_active"] is False + + signup = client.post("/auth/signup", json={"email": "goal-scoped@example.com", "password": "secret123"}) + assert signup.status_code == 200 + other_headers = {"Authorization": f"Bearer {signup.json()['access_token']}"} + assert client.patch(f"/dashboard/goals/{goal['id']}", json={"is_active": True}, headers=other_headers).status_code == 404 + assert client.delete(f"/dashboard/goals/{goal['id']}", headers=other_headers).status_code == 404 + assert client.get("/dashboard/goals", headers=other_headers).json() == [] + + delete_response = client.delete(f"/dashboard/goals/{goal['id']}", headers=headers) + assert delete_response.status_code == 200 + assert delete_response.json()["status"] == "deleted" + + +def test_coaching_prompt_truncation_and_json_parsing(): + long_transcript = "word " * 5000 + prompt = build_coaching_prompt( + long_transcript, + {"wpm": 120, "overall_score": 70, "filler_breakdown": {"um": 2}}, + [], + active_goals=[ + { + "metric_name": "filler_rate", + "comparison": "lte", + "target_value": 1, + "current_value": 2, + "achieved": False, + } + ], + ) + assert "filler_rate: target lte 1" in prompt + assert "[transcript truncated]" in prompt + + parsed = parse_coaching_response( + json.dumps( + { + "summary": "Good session.", + "strengths": [{"area": "pace", "detail": "Steady"}], + "improvements": [{"area": "fillers", "detail": "Pause more", "priority": "medium"}], + "drills": [{"name": "Pause drill", "description": "Pause", "duration_minutes": 5}], + "practice_plan": {"focus_areas": ["fillers"], "next_session_goal": "Use fewer fillers"}, + } + ) + ) + assert parsed["summary"] == "Good session." + + +def test_coaching_worker_success_and_failure(monkeypatch): + headers = get_admin_headers() + response = client.post( + "/sessions", + json={ + "title": "Coach Me", + "mode": "Practice", + "duration_ms": 60000, + "transcript_segments": [{"start_ms": 0, "end_ms": 60000, "text": "Hello coach"}], + "metrics": [], + }, + headers=headers, + ) + session_id = response.json()["id"] + + def fake_generate(transcript, metrics, history, previous_feedback=None, active_goals=None): + return { + "summary": "Local feedback ready.", + "strengths": [{"area": "clarity", "detail": "Clear opening"}], + "improvements": [{"area": "pace", "detail": "Keep it steady", "priority": "low"}], + "drills": [{"name": "Pace drill", "description": "Read for one minute"}], + "practice_plan": {"focus_areas": ["pace"], "next_session_goal": "Stay near 140 WPM"}, + "raw_response": "{}", + "model_used": "llama3.2:3b", + "generation_time_ms": 10, + } + + monkeypatch.setattr(worker_jobs, "generate_coaching_feedback", fake_generate) + result = asyncio.run(worker_jobs.generate_session_coaching({}, session_id, 1)) + assert result["status"] == "completed" + + coaching_response = client.get(f"/coaching/session/{session_id}", headers=headers) + assert coaching_response.status_code == 200 + assert coaching_response.json()["summary"] == "Local feedback ready." + + def fake_failure(transcript, metrics, history, previous_feedback=None, active_goals=None): + raise RuntimeError("Ollama unavailable") + + monkeypatch.setattr(worker_jobs, "generate_coaching_feedback", fake_failure) + failed = asyncio.run(worker_jobs.generate_session_coaching({}, session_id, 1)) + assert failed["status"] == "failed" + failed_response = client.get(f"/coaching/session/{session_id}", headers=headers) + assert failed_response.json()["status"] == "failed" + assert "Ollama unavailable" in failed_response.json()["error_message"] + + +def test_coaching_retry_endpoint_queues_job(monkeypatch): + headers = get_admin_headers() + response = client.post( + "/sessions", + json={ + "title": "Retry Coach", + "mode": "Practice", + "duration_ms": 1000, + "transcript_segments": [{"start_ms": 0, "end_ms": 1000, "text": "Retry coaching"}], + "metrics": [], + }, + headers=headers, + ) + session_id = response.json()["id"] + + async def fake_enqueue(session_id_arg, user_id_arg): + assert session_id_arg == session_id + assert user_id_arg == 1 + return "job-coaching-1" + + monkeypatch.setattr(coaching_routes, "enqueue_coaching", fake_enqueue) + retry = client.post(f"/coaching/session/{session_id}/retry", headers=headers) + assert retry.status_code == 200 + assert retry.json() == {"status": "queued", "job_id": "job-coaching-1"} + + +def test_schema_upgrade_adds_document_columns(tmp_path, monkeypatch): + engine = create_engine(f"sqlite:///{tmp_path / 'legacy.db'}") + with engine.begin() as connection: + connection.exec_driver_sql( + """ + CREATE TABLE sessions ( + id INTEGER PRIMARY KEY, + user_id INTEGER NOT NULL, + title VARCHAR(255), + mode VARCHAR(80), + prompt TEXT, + goal VARCHAR(255), + started_at DATETIME, + ended_at DATETIME, + duration_ms INTEGER, + wpm_avg FLOAT, + filler_count INTEGER, + eye_contact_pct FLOAT, + video_path VARCHAR(1024), + transcription_status VARCHAR(32), + created_at DATETIME + ) + """ + ) + + monkeypatch.setattr(schema_upgrades, "engine", engine) + schema_upgrades.apply_startup_schema_upgrades() + + columns = {column["name"] for column in inspect(engine).get_columns("sessions")} + assert {"document_path", "document_name", "document_mime_type", "document_blocks_json"}.issubset(columns) diff --git a/apps/api/tests/test_presenter_coach.py b/apps/api/tests/test_presenter_coach.py new file mode 100644 index 0000000000000000000000000000000000000000..a5bb199fb5bcf18b3b0d836267822fa91b11f45f --- /dev/null +++ b/apps/api/tests/test_presenter_coach.py @@ -0,0 +1,253 @@ +import os +import tempfile +import time +from uuid import uuid4 + +test_id = uuid4().hex +os.environ["DATABASE_URL"] = f"sqlite:///./test_{test_id}.db" +os.environ["LOCAL_ONLY_MODE"] = "true" +os.environ["STORAGE_DIR"] = os.path.join(tempfile.gettempdir(), f"speech_machine_test_{test_id}") + +from fastapi.testclient import TestClient + +from app.db import Base, engine +from app.main import app + +client = TestClient(app) +Base.metadata.create_all(bind=engine) +_headers: dict[str, str] | None = None + + +def get_headers() -> dict[str, str]: + global _headers + if _headers is not None: + return _headers + status = client.get("/setup/status").json() + if status["setup_required"]: + res = client.post("/setup", json={"email": "presenter@example.com", "password": "secret123"}) + assert res.status_code == 201 + else: + client.post("/auth/signup", json={"email": "presenter@example.com", "password": "secret123"}) + login = client.post("/auth/login", json={"email": "presenter@example.com", "password": "secret123"}) + assert login.status_code == 200 + _headers = {"Authorization": f"Bearer {login.json()['access_token']}"} + return _headers + + +def test_curriculum_profile_and_30_day_plan_auto_completion(): + headers = get_headers() + + profile_res = client.get("/curriculum/profile", headers=headers) + assert profile_res.status_code == 200 + assert profile_res.json()["target_wpm_min"] == 125 + + update_res = client.put( + "/curriculum/profile", + json={ + "display_name": "Ada Presenter", + "primary_role": "Staff Systems Engineer", + "focus_track": "keynote_clarity", + "target_wpm_min": 130, + "target_wpm_max": 155, + "daily_minutes_goal": 20, + "weaknesses": ["filler_words", "eye_contact", "rushing_pace"], + }, + headers=headers, + ) + assert update_res.status_code == 200 + assert update_res.json()["display_name"] == "Ada Presenter" + assert update_res.json()["focus_track"] == "keynote_clarity" + + plan_res = client.get("/curriculum/plan", headers=headers) + assert plan_res.status_code == 200 + plan = plan_res.json() + assert plan["summary"]["total_days"] == 30 + assert plan["summary"]["recommended_day"] == 1 + assert len(plan["days"]) == 30 + + # Save a Day 1 session and verify Day 1 auto-completes and Day 2 becomes recommended + sess_res = client.post( + "/sessions", + json={ + "title": "Day 1: Baseline 60-Second Tech Intro", + "mode": "Pitch", + "curriculum_day": 1, + "target_keywords": ["building", "technology", "problem", "matters"], + "duration_ms": 60000, + "eye_contact_pct": 0.55, + "transcript_segments": [ + { + "start_ms": 0, + "end_ms": 30000, + "text": "Hi, um I am building a technology product because solving this problem really matters.", + }, + { + "start_ms": 30000, + "end_ms": 60000, + "text": "First we measure speaking pace, um like second we track camera eye contact, therefore every rep improves.", + }, + ], + "metrics": [ + {"t": 1, "wpm": 138, "rms": 55, "eye_contact": 0.55, "smile": 0.4, "yaw": 0.2, "pitch": 0.1, "roll": 0.1, "filler_count": 3, "pause_ms": 900} + ], + }, + headers=headers, + ) + assert sess_res.status_code == 200 + day1_session_id = sess_res.json()["id"] + assert sess_res.json()["curriculum_day"] == 1 + assert sess_res.json()["transcription_status"] == "complete" + + updated_plan = client.get("/curriculum/plan", headers=headers).json() + assert updated_plan["summary"]["completed_days"] >= 1 + assert updated_plan["summary"]["recommended_day"] == 2 + assert updated_plan["days"][0]["completed"] is True + assert updated_plan["days"][0]["completed_session_id"] == day1_session_id + + note_res = client.post( + "/curriculum/days/1/complete", + json={"reflection_notes": "Felt much calmer speaking right to the lens."}, + headers=headers, + ) + assert note_res.status_code == 200 + assert "calmer" in note_res.json()["reflection_notes"] + + +def test_targeted_exercises_and_tech_news_storyboard(monkeypatch): + headers = get_headers() + + exercises_res = client.get("/curriculum/exercises", headers=headers) + assert exercises_res.status_code == 200 + ex = exercises_res.json() + assert len(ex["daily_warmups"]) >= 4 + assert len(ex["camera_challenges"]) >= 5 + assert len(ex["targeted_exercises"]) >= 2 + + # Avoid network dependency in unit test for news feed + from app.services import tech_news + + tech_news._FEED_CACHE["timestamp"] = time.time() + tech_news._FEED_CACHE["items"] = [] + + feed_res = client.get("/news/feed", headers=headers) + assert feed_res.status_code == 200 + feed = feed_res.json() + assert len(feed["stories"]) >= 6 + assert len(feed["frameworks"]) == 4 + + for fw in ("briefing_60s", "explainer_2m", "contrarian_take", "war_story"): + sb_res = client.post( + "/news/storyboard", + json={ + "headline": "SQLite WAL Mode Scales Single-Node Web Apps", + "summary": "Modern NVMe disks let single-node SQLite handle tens of thousands of reads per second.", + "category": "Systems & Cloud", + "framework": fw, + "personal_angle": "Start simple before adopting distributed microservices.", + }, + headers=headers, + ) + assert sb_res.status_code == 200 + sb = sb_res.json() + assert len(sb["sections"]) == 4 + assert "SQLite" in sb["teleprompter_script"] + assert len(sb["target_keywords"]) >= 3 + + +def test_auto_builtin_coaching_transcript_patch_weekly_review_and_compare(): + headers = get_headers() + + s1 = client.post( + "/sessions", + json={ + "title": "Take 1 β€” Rushed & Filler-Heavy", + "mode": "Storytelling", + "duration_ms": 60000, + "eye_contact_pct": 0.45, + "transcript_segments": [ + { + "start_ms": 0, + "end_ms": 60000, + "text": "Um like basically you know we built an API um like actually very fast um yeah.", + } + ], + "metrics": [ + {"t": 1, "wpm": 175, "rms": 45, "eye_contact": 0.45, "smile": 0.2, "yaw": 0.5, "pitch": 0.4, "roll": 0.2, "filler_count": 6, "pause_ms": 0} + ], + }, + headers=headers, + ).json() + + # Auto coaching generates immediately using built-in presenter coach + coach_res = client.get(f"/coaching/session/{s1['id']}?auto=true", headers=headers) + assert coach_res.status_code == 200 + coach = coach_res.json() + assert coach["status"] == "completed" + assert len(coach["strengths"]) >= 2 + assert len(coach["improvements"]) >= 2 + assert len(coach["drills"]) >= 1 + + # Patch transcript with cleaner take and verify score + metrics recompute + s2 = client.post( + "/sessions", + json={ + "title": "Take 2 β€” Crisp Keynote Structure", + "mode": "Pitch", + "duration_ms": 60000, + "eye_contact_pct": 0.82, + "transcript_segments": [], + "metrics": [ + {"t": 1, "wpm": 142, "rms": 62, "eye_contact": 0.82, "smile": 0.55, "yaw": 0.1, "pitch": 0.1, "roll": 0.1, "filler_count": 0, "pause_ms": 850} + ], + }, + headers=headers, + ).json() + + patch_res = client.patch( + f"/sessions/{s2['id']}/transcript", + json={ + "transcript_segments": [ + { + "start_ms": 0, + "end_ms": 30000, + "text": "First, our engineering team simplified database queries because tail latency hurt mobile users.", + }, + { + "start_ms": 30000, + "end_ms": 60000, + "text": "Second, response times dropped by seventy percent. Therefore, every page now loads under one second.", + }, + ] + }, + headers=headers, + ) + assert patch_res.status_code == 200 + assert patch_res.json()["transcription_status"] == "complete" + assert patch_res.json()["score"]["total"] > s1["score"]["total"] + + # Compare Take 1 vs Take 2 + cmp_res = client.get(f"/dashboard/compare?session_a={s1['id']}&session_b={s2['id']}", headers=headers) + assert cmp_res.status_code == 200 + cmp_data = cmp_res.json() + assert cmp_data["comparison"]["overall_score"]["delta"] > 0 + assert len(cmp_data["insights"]) >= 1 + assert cmp_data["session_b"]["title"] == "Take 2 β€” Crisp Keynote Structure" + + # Weekly review + reflection note + weekly_res = client.get("/dashboard/weekly-review", headers=headers) + assert weekly_res.status_code == 200 + weekly = weekly_res.json() + assert weekly["current_week"]["session_count"] >= 2 + assert len(weekly["wins"]) >= 1 + + note_save = client.put( + "/dashboard/weekly-review/note", + json={ + "week_start_date": weekly["week_start_date"], + "wins_note": "Cut fillers dramatically on Take 2!", + "focus_next_week": "Practice 2-minute Tech News explainers from bullet cues.", + }, + headers=headers, + ) + assert note_save.status_code == 200 + assert "Cut fillers" in note_save.json()["wins_note"] diff --git a/apps/api/tests/test_transcription.py b/apps/api/tests/test_transcription.py new file mode 100644 index 0000000000000000000000000000000000000000..06e69d41804889923537c29cd0cffbb2fe7e3494 --- /dev/null +++ b/apps/api/tests/test_transcription.py @@ -0,0 +1,435 @@ +import os +from pathlib import Path +import sys +import tempfile +import types +from uuid import uuid4 + +test_id = uuid4().hex +os.environ["DATABASE_URL"] = f"sqlite:///./test_{test_id}.db" +os.environ["LOCAL_ONLY_MODE"] = "true" +os.environ["STORAGE_DIR"] = os.path.join(tempfile.gettempdir(), f"speech_machine_test_{test_id}") + +from fastapi.testclient import TestClient + +from app.main import app +from app.db import Base, SessionLocal, engine +from app import models +from app import transcription +from app import hf_models +from app.routes import sessions as session_routes +from app.storage import storage_client + +client = TestClient(app) +Base.metadata.create_all(bind=engine) +_admin_headers: dict[str, str] | None = None + + +def get_admin_headers() -> dict[str, str]: + global _admin_headers + if _admin_headers is not None: + return _admin_headers + status_response = client.get("/setup/status") + if status_response.json()["setup_required"]: + setup_response = client.post( + "/setup", + json={"email": "admin@example.com", "password": "secret123"}, + ) + assert setup_response.status_code == 201 + response = client.post("/auth/login", json={"email": "admin@example.com", "password": "secret123"}) + assert response.status_code == 200 + _admin_headers = {"Authorization": f"Bearer {response.json()['access_token']}"} + return _admin_headers + + +def create_transcription_model(model_id: str, name: str, available: bool = True, local_path: str | None = None): + return { + "id": model_id, + "name": name, + "repo_id": model_id, + "description": f"{name} model", + "languages": "Multiple languages", + "available": available, + "local_path": local_path, + } + + +def test_transcription_endpoint(monkeypatch, tmp_path: Path): + headers = get_admin_headers() + + payload = { + "title": "Transcript", + "mode": "Interview", + "duration_ms": 1000, + "transcript_segments": [], + "metrics": [], + } + response = client.post("/sessions", json=payload, headers=headers) + session_id = response.json()["id"] + + db = SessionLocal() + session = db.query(models.Session).filter(models.Session.id == session_id).first() + video_path = tmp_path / "recording.webm" + video_path.write_bytes(b"test") + session.video_path = str(video_path) + db.commit() + db.close() + + def fake_extract_audio(path: str) -> str: + audio_path = tmp_path / "audio.wav" + audio_path.write_bytes(b"audio") + return str(audio_path) + + selected = {} + + original_get_backend = transcription.get_transcription_backend + + def fake_get_backend(backend_id: str): + if backend_id == transcription.MLX_AUDIO_BACKEND: + return { + "id": transcription.MLX_AUDIO_BACKEND, + "label": "MLX Audio", + "supported": True, + "live": True, + "detail": "Available", + "models": [create_transcription_model("mlx-community/parakeet-tdt-0.6b-v3", "Parakeet")], + "default_model": "mlx-community/parakeet-tdt-0.6b-v3", + } + return original_get_backend(backend_id) + + def fake_transcribe_audio( + path: str, + model_name: str | None = None, + backend_id: str | None = None, + ): + selected["model"] = model_name + selected["backend"] = backend_id + return [{"start_ms": 0, "end_ms": 500, "text": "hello"}] + + monkeypatch.setattr(transcription, "get_transcription_backend", fake_get_backend) + monkeypatch.setattr(transcription, "ensure_transcription_model_ready", lambda backend_id, model_name: None) + monkeypatch.setattr(transcription, "extract_audio", fake_extract_audio) + monkeypatch.setattr(transcription, "transcribe_audio", fake_transcribe_audio) + monkeypatch.setattr(storage_client, "get_local_path", lambda path: str(video_path)) + + async def fake_enqueue(session_id_arg, user_id_arg, model_name, backend_id): + assert session_id_arg == session_id + assert model_name == "mlx-community/parakeet-tdt-0.6b-v3" + assert backend_id == "mlx_audio" + return "job-test" + + monkeypatch.setattr(session_routes, "enqueue_transcription", fake_enqueue) + + response = client.post( + f"/sessions/{session_id}/transcribe", + json={ + "backend": "mlx_audio", + "model": "mlx-community/parakeet-tdt-0.6b-v3", + }, + headers=headers, + ) + assert response.status_code == 200 + assert response.json()["job_id"] == "job-test" + + # Run background task synchronously + db = SessionLocal() + user = db.query(models.User).filter(models.User.email == "admin@example.com").first() + user_id = user.id if user else 1 + db.close() + session_routes._run_transcription( + session_id, + user_id, + "mlx-community/parakeet-tdt-0.6b-v3", + "mlx_audio", + ) + + db = SessionLocal() + session = db.query(models.Session).filter(models.Session.id == session_id).first() + assert session.transcription_status == "complete" + assert len(session.transcript_segments) == 1 + db.close() + assert selected["model"] == "mlx-community/parakeet-tdt-0.6b-v3" + assert selected["backend"] == "mlx_audio" + + +def test_transcription_endpoint_rejects_unsupported_mlx_audio(monkeypatch, tmp_path: Path): + headers = get_admin_headers() + + payload = { + "title": "Transcript Failure", + "mode": "Interview", + "duration_ms": 1000, + "transcript_segments": [], + "metrics": [], + } + response = client.post("/sessions", json=payload, headers=headers) + session_id = response.json()["id"] + + db = SessionLocal() + session = db.query(models.Session).filter(models.Session.id == session_id).first() + video_path = tmp_path / "recording.webm" + video_path.write_bytes(b"test") + session.video_path = str(video_path) + db.commit() + db.close() + + original_get_backend = transcription.get_transcription_backend + + def fake_get_backend(backend_id: str): + if backend_id == transcription.MLX_AUDIO_BACKEND: + return { + "id": transcription.MLX_AUDIO_BACKEND, + "label": "MLX Audio", + "supported": False, + "live": False, + "detail": "MLX Audio requires macOS on Apple Silicon.", + "models": [], + "default_model": None, + } + return original_get_backend(backend_id) + + monkeypatch.setattr(transcription, "get_transcription_backend", fake_get_backend) + + response = client.post( + f"/sessions/{session_id}/transcribe", + json={ + "backend": "mlx_audio", + "model": "mlx-community/parakeet-tdt-0.6b-v3", + }, + headers=headers, + ) + assert response.status_code == 400 + assert response.json()["detail"] == "MLX Audio requires macOS on Apple Silicon." + + +def test_transcribe_audio_uses_downloaded_whisper_model(monkeypatch, tmp_path: Path): + audio_path = tmp_path / "audio.wav" + audio_path.write_bytes(b"audio") + + class Segment: + def __init__(self, start: float, end: float, text: str): + self.start = start + self.end = end + self.text = text + + class FakeWhisperModel: + def transcribe(self, path: str): + assert path == str(audio_path) + return iter([Segment(0.0, 0.8, "hello"), Segment(0.8, 1.4, "world")]), None + + monkeypatch.setattr( + transcription, + "ensure_server_model_downloaded", + lambda model_name: { + "id": model_name, + "name": "Whisper Small", + "repo_id": model_name, + "description": "Whisper Small", + "languages": "99+ languages", + "available": True, + "local_path": "/tmp/models/whisper-small", + }, + ) + + def fake_load_whisper_model(model_ref: str, device: str): + assert model_ref == "/tmp/models/whisper-small" + assert device == transcription.settings.whisper_device + return FakeWhisperModel() + + monkeypatch.setattr(transcription, "_load_whisper_model", fake_load_whisper_model) + + result = transcription.transcribe_audio(str(audio_path), "openai/whisper-small") + + assert result == [ + {"start_ms": 0, "end_ms": 800, "text": "hello"}, + {"start_ms": 800, "end_ms": 1400, "text": "world"}, + ] + + +def test_transcribe_audio_uses_mlx_audio_model(monkeypatch, tmp_path: Path): + audio_path = tmp_path / "audio.wav" + audio_path.write_bytes(b"audio") + + original_get_backend = transcription.get_transcription_backend + + class Sentence: + def __init__(self, start: float, end: float, text: str): + self.start = start + self.end = end + self.text = text + + class Result: + sentences = [ + Sentence(0.0, 0.5, "hello"), + Sentence(0.5, 1.0, "mlx"), + ] + + class FakeMlxAudioModel: + def generate(self, path: str): + assert path == str(audio_path) + return Result() + + def fake_get_backend(backend_id: str): + if backend_id == transcription.MLX_AUDIO_BACKEND: + return { + "id": transcription.MLX_AUDIO_BACKEND, + "label": "MLX Audio", + "supported": True, + "live": True, + "detail": "Available", + "models": [create_transcription_model("mlx-community/parakeet-tdt-0.6b-v3", "Parakeet")], + "default_model": "mlx-community/parakeet-tdt-0.6b-v3", + } + return original_get_backend(backend_id) + + monkeypatch.setattr(transcription, "get_transcription_backend", fake_get_backend) + monkeypatch.setattr( + transcription, + "ensure_mlx_audio_model_downloaded", + lambda model_name: { + "id": model_name, + "name": "Parakeet", + "repo_id": model_name, + "description": "Parakeet model", + "languages": "Multiple languages", + "available": True, + "local_path": "/tmp/models/parakeet", + }, + ) + + def fake_load_mlx_audio_model(model_ref: str): + assert model_ref == "/tmp/models/parakeet" + return FakeMlxAudioModel() + + monkeypatch.setattr(transcription, "_load_mlx_audio_model", fake_load_mlx_audio_model) + + result = transcription.transcribe_audio( + str(audio_path), + "mlx-community/parakeet-tdt-0.6b-v3", + "mlx_audio", + ) + + assert result == [ + {"start_ms": 0, "end_ms": 500, "text": "hello"}, + {"start_ms": 500, "end_ms": 1000, "text": "mlx"}, + ] + + +def test_transcribe_audio_rejects_missing_mlx_audio_download(monkeypatch, tmp_path: Path): + audio_path = tmp_path / "audio.wav" + audio_path.write_bytes(b"audio") + + original_get_backend = transcription.get_transcription_backend + + def fake_get_backend(backend_id: str): + if backend_id == transcription.MLX_AUDIO_BACKEND: + return { + "id": transcription.MLX_AUDIO_BACKEND, + "label": "MLX Audio", + "supported": True, + "live": True, + "detail": "Available", + "models": [create_transcription_model("mlx-community/parakeet-tdt-0.6b-v3", "Parakeet", available=False)], + "default_model": "mlx-community/parakeet-tdt-0.6b-v3", + } + return original_get_backend(backend_id) + + monkeypatch.setattr(transcription, "get_transcription_backend", fake_get_backend) + + try: + transcription.transcribe_audio( + str(audio_path), + "mlx-community/parakeet-tdt-0.6b-v3", + "mlx_audio", + ) + except ValueError as exc: + assert "not downloaded yet" in str(exc) + else: # pragma: no cover - assertion guard + raise AssertionError("Expected MLX Audio transcription to require a downloaded local model.") + + +def test_download_mlx_audio_model_uses_app_managed_storage(monkeypatch, tmp_path: Path): + models_root = tmp_path / "models" / "hf" + download_call: dict[str, str] = {} + + def fake_snapshot_download(repo_id: str, local_dir: str): + target_dir = Path(local_dir) + target_dir.mkdir(parents=True, exist_ok=True) + (target_dir / "config.json").write_text("{}", encoding="utf-8") + download_call["repo_id"] = repo_id + download_call["local_dir"] = local_dir + + monkeypatch.setattr(hf_models, "get_models_root", lambda: models_root) + monkeypatch.setitem(sys.modules, "huggingface_hub", types.SimpleNamespace(snapshot_download=fake_snapshot_download)) + + model = hf_models.download_mlx_audio_model("mlx-community/parakeet-tdt-0.6b-v3") + + expected_dir = models_root / "mlx-community-parakeet-tdt-0.6b-v3" + assert download_call["repo_id"] == "mlx-community/parakeet-tdt-0.6b-v3" + assert download_call["local_dir"] == str(expected_dir) + assert model["available"] is True + assert model["local_path"] == str(expected_dir) + assert (expected_dir / hf_models.DOWNLOAD_MARKER).exists() + + +def test_download_server_model_uses_app_managed_storage(monkeypatch, tmp_path: Path): + models_root = tmp_path / "models" / "hf" + download_call: dict[str, str] = {} + + def fake_download_model(model_size_or_path: str, output_dir: str): + target_dir = Path(output_dir) + target_dir.mkdir(parents=True, exist_ok=True) + (target_dir / "model.bin").write_text("ok", encoding="utf-8") + download_call["model_size_or_path"] = model_size_or_path + download_call["output_dir"] = output_dir + + monkeypatch.setattr(hf_models, "get_models_root", lambda: models_root) + fake_utils_module = types.SimpleNamespace(download_model=fake_download_model, _MODELS={"small": object()}) + monkeypatch.setitem( + sys.modules, + "faster_whisper", + types.SimpleNamespace(utils=fake_utils_module, __version__="1.1.0"), + ) + monkeypatch.setitem( + sys.modules, + "faster_whisper.utils", + types.SimpleNamespace(download_model=fake_download_model, _MODELS={"small": object()}), + ) + + model = hf_models.download_server_model("openai/whisper-small") + + expected_dir = models_root / "openai-whisper-small" + assert download_call["model_size_or_path"] == "small" + assert download_call["output_dir"] == str(expected_dir) + assert model["available"] is True + assert model["local_path"] == str(expected_dir) + assert (expected_dir / hf_models.DOWNLOAD_MARKER).exists() + + +def test_download_server_model_rejects_unsupported_runtime_model(monkeypatch, tmp_path: Path): + models_root = tmp_path / "models" / "hf" + + def fake_download_model(_model_size_or_path: str, output_dir: str): + raise AssertionError("download_model should not be called for unsupported runtime ids") + + monkeypatch.setattr(hf_models, "get_models_root", lambda: models_root) + fake_utils_module = types.SimpleNamespace(download_model=fake_download_model, _MODELS={"large-v3": object()}) + monkeypatch.setitem( + sys.modules, + "faster_whisper", + types.SimpleNamespace(utils=fake_utils_module, __version__="1.0.2"), + ) + monkeypatch.setitem( + sys.modules, + "faster_whisper.utils", + fake_utils_module, + ) + + try: + hf_models.download_server_model("openai/whisper-large-v3-turbo") + except ValueError as exc: + assert "Whisper Large v3 Turbo" in str(exc) + assert "faster-whisper 1.0.2" in str(exc) + assert "turbo" in str(exc) + else: # pragma: no cover - assertion guard + raise AssertionError("Expected unsupported runtime model ids to raise a ValueError.") diff --git a/apps/web/.eslintrc.json b/apps/web/.eslintrc.json new file mode 100644 index 0000000000000000000000000000000000000000..ae8e31efa1b829dac7bcd55ff4893b4b6cfb8cbf --- /dev/null +++ b/apps/web/.eslintrc.json @@ -0,0 +1,9 @@ +{ + "extends": ["next/core-web-vitals"], + "rules": { + "no-console": ["warn", { "allow": ["warn", "error"] }], + "react-hooks/exhaustive-deps": "error", + "react-hooks/rules-of-hooks": "error" + }, + "ignorePatterns": [".next/", "node_modules/", "out/"] +} diff --git a/apps/web/Dockerfile b/apps/web/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..48e19da041da1edf6852d7413640b4d92e643450 --- /dev/null +++ b/apps/web/Dockerfile @@ -0,0 +1,29 @@ +FROM node:20-alpine + +WORKDIR /app + +COPY package.json ./package.json +COPY package-lock.json ./package-lock.json +COPY tsconfig.base.json ./tsconfig.base.json +COPY apps/web/package.json ./apps/web/package.json +COPY packages/shared/package.json ./packages/shared/package.json + +WORKDIR /app +RUN npm ci + +WORKDIR /app +COPY apps/web ./apps/web +COPY packages/shared ./packages/shared + +WORKDIR /app/apps/web +RUN npm run build +RUN mkdir -p .next/standalone/apps/web/.next \ + && cp -R .next/static .next/standalone/apps/web/.next/static \ + && cp -R public .next/standalone/apps/web/public + +EXPOSE 3000 + +ENV HOSTNAME=0.0.0.0 +ENV PORT=3000 + +CMD ["node", ".next/standalone/apps/web/server.js"] diff --git a/apps/web/middleware.ts b/apps/web/middleware.ts new file mode 100644 index 0000000000000000000000000000000000000000..a6a53ff414aa11ce5cae0563fd77a93120e532a5 --- /dev/null +++ b/apps/web/middleware.ts @@ -0,0 +1,46 @@ +import { NextResponse, type NextRequest } from "next/server"; + +const isProduction = process.env.NODE_ENV === "production" && process.env.ALLOW_FRAME_EMBEDDING !== "true"; +const configuredApiUrl = process.env.NEXT_PUBLIC_API_URL || ""; +const externalApiOrigin = configuredApiUrl.startsWith("http") + ? configuredApiUrl.replace(/\/+$/, "") + : ""; + +export function middleware(_request: NextRequest) { + const response = NextResponse.next(); + const scriptSrc = isProduction + ? "script-src 'self' 'unsafe-inline' 'wasm-unsafe-eval'" + : "script-src 'self' 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval'"; + const connectSources = ["'self'", "ws:", "wss:", "blob:", "data:"]; + if (externalApiOrigin) { + connectSources.push(externalApiOrigin); + } + + const directives = [ + "default-src 'self'", + scriptSrc, + "style-src 'self' 'unsafe-inline'", + "img-src 'self' blob: data: https:", + "media-src 'self' blob: data:", + "font-src 'self' data:", + `connect-src ${connectSources.join(" ")}`, + "worker-src 'self' blob:", + isProduction ? "frame-ancestors 'none'" : "frame-ancestors *", + ]; + + response.headers.set("Content-Security-Policy", directives.join("; ")); + response.headers.set("X-Content-Type-Options", "nosniff"); + response.headers.set("Referrer-Policy", "strict-origin-when-cross-origin"); + response.headers.set( + "Permissions-Policy", + isProduction ? "camera=(self), microphone=(self), geolocation=()" : "camera=*, microphone=*, geolocation=()" + ); + if (isProduction) { + response.headers.set("X-Frame-Options", "DENY"); + } + return response; +} + +export const config = { + matcher: ["/((?!api/backend|_next/static|_next/image|favicon.ico).*)"], +}; diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts new file mode 100644 index 0000000000000000000000000000000000000000..4f11a03dc6cc37f2b5105c08f2e7b24c603ab2f4 --- /dev/null +++ b/apps/web/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/basic-features/typescript for more information. diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs new file mode 100644 index 0000000000000000000000000000000000000000..62eb6a00704cad41874932334227899a7493929c --- /dev/null +++ b/apps/web/next.config.mjs @@ -0,0 +1,30 @@ +import path from "path"; + +const internalApiUrl = (process.env.INTERNAL_API_URL || "http://127.0.0.1:8000").replace(/\/+$/, ""); + +/** @type {import('next').NextConfig} */ +const nextConfig = { + ...(process.env.NEXT_STANDALONE === "true" ? { output: "standalone" } : {}), + reactStrictMode: true, + transpilePackages: ["@video/shared"], + images: { + remotePatterns: [], + }, + webpack: (config) => { + config.resolve.alias["@mediapipe/tasks-vision"] = path.resolve( + process.cwd(), + "../../node_modules/@mediapipe/tasks-vision/vision_bundle.mjs" + ); + return config; + }, + async rewrites() { + return [ + { + source: "/api/backend/:path*", + destination: `${internalApiUrl}/:path*`, + }, + ]; + }, +}; + +export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json new file mode 100644 index 0000000000000000000000000000000000000000..fd0640d8c1c208214609059871e67f24279af31f --- /dev/null +++ b/apps/web/package.json @@ -0,0 +1,44 @@ +{ + "name": "video-coach-web", + "version": "0.1.0", + "private": true, + "scripts": { + "sync:pdf-worker": "node scripts/sync-pdf-worker.mjs", + "dev": "npm run sync:pdf-worker && next dev", + "build": "npm run sync:pdf-worker && next build", + "start": "next start", + "lint": "next lint", + "format": "prettier --write .", + "test": "vitest", + "test:e2e": "playwright test" + }, + "dependencies": { + "@mediapipe/tasks-vision": "^0.10.14", + "@video/shared": "*", + "clsx": "^2.1.1", + "idb": "^8.0.0", + "lucide-react": "^0.395.0", + "next": "14.2.4", + "pdfjs-dist": "4.9.155", + "react": "18.3.1", + "react-dom": "18.3.1", + "recharts": "^2.12.7", + "tailwind-merge": "^2.3.0", + "zustand": "^4.5.2" + }, + "devDependencies": { + "@playwright/test": "^1.44.1", + "@types/node": "^20.12.12", + "@types/react": "^18.3.2", + "@types/react-dom": "^18.3.0", + "autoprefixer": "^10.4.19", + "eslint": "^8.57.0", + "eslint-config-next": "^14.2.4", + "jsdom": "^24.0.0", + "postcss": "^8.4.38", + "prettier": "^3.2.5", + "tailwindcss": "^3.4.4", + "typescript": "^5.4.5", + "vitest": "^1.6.0" + } +} diff --git a/apps/web/playwright.config.ts b/apps/web/playwright.config.ts new file mode 100644 index 0000000000000000000000000000000000000000..37e65533df80778586b020172d7a0a7bab786653 --- /dev/null +++ b/apps/web/playwright.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: "./tests/e2e", + use: { + baseURL: "http://localhost:3000", + trace: "retain-on-failure", + }, +}); diff --git a/apps/web/postcss.config.js b/apps/web/postcss.config.js new file mode 100644 index 0000000000000000000000000000000000000000..12a703d900da8159c30e75acbd2c4d87ae177f62 --- /dev/null +++ b/apps/web/postcss.config.js @@ -0,0 +1,6 @@ +module.exports = { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +}; diff --git a/apps/web/public/icon.svg b/apps/web/public/icon.svg new file mode 100644 index 0000000000000000000000000000000000000000..d5456ffaa3eab6a0c2da4fad4a626482eb5dfd40 --- /dev/null +++ b/apps/web/public/icon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/apps/web/public/manifest.json b/apps/web/public/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..f25bc68b7af09f99a56b0f08a87205aa61f8ce9d --- /dev/null +++ b/apps/web/public/manifest.json @@ -0,0 +1,18 @@ +{ + "name": "Tech Presenter Coach", + "short_name": "Presenter", + "description": "Daily camera studio, lens teleprompter, 30-day tech presenter curriculum, and AI delivery coach.", + "start_url": "/", + "display": "standalone", + "background_color": "#f8f4ec", + "theme_color": "#141820", + "orientation": "any", + "icons": [ + { + "src": "/icon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/apps/web/scripts/sync-pdf-worker.mjs b/apps/web/scripts/sync-pdf-worker.mjs new file mode 100644 index 0000000000000000000000000000000000000000..a7b5a90536b728492b14c3f1c964bc5b369b6d8d --- /dev/null +++ b/apps/web/scripts/sync-pdf-worker.mjs @@ -0,0 +1,17 @@ +import { copyFile, mkdir } from "node:fs/promises"; +import { createRequire } from "node:module"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const appRoot = path.resolve(__dirname, ".."); +const require = createRequire(import.meta.url); +const source = require.resolve("pdfjs-dist/legacy/build/pdf.worker.min.mjs", { + paths: [appRoot], +}); +const destination = path.resolve(appRoot, "public", "pdf.worker.min.mjs"); + +await mkdir(path.dirname(destination), { recursive: true }); +await copyFile(source, destination); +console.log(`Synced pdf worker to ${destination}`); diff --git a/apps/web/src/app/curriculum/page.tsx b/apps/web/src/app/curriculum/page.tsx new file mode 100644 index 0000000000000000000000000000000000000000..03119868762a707401d297b1352259d3ab114b33 --- /dev/null +++ b/apps/web/src/app/curriculum/page.tsx @@ -0,0 +1,609 @@ +"use client"; + +import { useEffect, useMemo, useState, type FormEvent } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { AppShell } from "@/components/app-shell"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { Input } from "@/components/ui/input"; +import { Progress } from "@/components/ui/progress"; +import { Select } from "@/components/ui/select"; +import { useRequireAuth } from "@/lib/hooks/useRequireAuth"; +import { + curriculumApi, + saveStudioLaunchContext, + type CurriculumDay, + type CurriculumPlanResponse, + type PresenterProfile, +} from "@/lib/presenter"; +import { cn } from "@/lib/utils"; + +const WEAKNESS_OPTIONS = [ + { id: "filler_words", label: "Filler words (um, uh, like)" }, + { id: "rushing_pace", label: "Speaking too fast / rushing" }, + { id: "eye_contact", label: "Looking away from camera lens" }, + { id: "rambling_structure", label: "Rambling technical explanations" }, + { id: "monotone_energy", label: "Flat / monotone vocal energy" }, + { id: "camera_anxiety", label: "On-camera stiffness or nerves" }, +]; + +const TRACK_LABELS: Record = { + tech_storytelling: "Engaging Tech Storytelling & Creator Presence", + keynote_clarity: "Conference Keynote & Architecture Clarity", + camera_confidence: "On-Camera Confidence, Pace & Zero Fillers", + executive_pitch: "Founder / Product Demo & Executive Pitching", +}; + +export default function CurriculumPage() { + const router = useRouter(); + const { ready } = useRequireAuth(); + const [plan, setPlan] = useState(null); + const [selectedWeek, setSelectedWeek] = useState(0); // 0 = All weeks + const [selectedDayNum, setSelectedDayNum] = useState(1); + const [showProfileEditor, setShowProfileEditor] = useState(false); + const [profileForm, setProfileForm] = useState(null); + const [savingProfile, setSavingProfile] = useState(false); + const [reflectionDraft, setReflectionDraft] = useState(""); + const [savingDay, setSavingDay] = useState(false); + const [statusMsg, setStatusMsg] = useState(null); + + const loadPlan = async (keepSelection = false) => { + const data = await curriculumApi.getPlan(); + setPlan(data); + setProfileForm(data.profile); + if (!keepSelection) { + setSelectedDayNum(data.summary.recommended_day); + const dayObj = data.days.find((d) => d.day === data.summary.recommended_day); + setReflectionDraft(dayObj?.reflection_notes || ""); + } else { + const dayObj = data.days.find((d) => d.day === selectedDayNum); + if (dayObj) setReflectionDraft(dayObj.reflection_notes || ""); + } + }; + + useEffect(() => { + if (!ready) return; + loadPlan().catch(() => {}); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [ready]); + + const selectedDay: CurriculumDay | null = useMemo(() => { + if (!plan) return null; + return plan.days.find((d) => d.day === selectedDayNum) || plan.today_mission; + }, [plan, selectedDayNum]); + + const filteredDays = useMemo(() => { + if (!plan) return []; + if (selectedWeek === 0) return plan.days; + return plan.days.filter((d) => d.week === selectedWeek); + }, [plan, selectedWeek]); + + const handleSelectDay = (day: CurriculumDay) => { + setSelectedDayNum(day.day); + setReflectionDraft(day.reflection_notes || ""); + setStatusMsg(null); + }; + + const toggleWeakness = (id: string) => { + if (!profileForm) return; + const exists = profileForm.weaknesses.includes(id); + const next = exists + ? profileForm.weaknesses.filter((w) => w !== id) + : [...profileForm.weaknesses, id]; + setProfileForm({ ...profileForm, weaknesses: next }); + }; + + const handleSaveProfile = async (event: FormEvent) => { + event.preventDefault(); + if (!profileForm) return; + setSavingProfile(true); + setStatusMsg(null); + try { + await curriculumApi.updateProfile(profileForm); + await loadPlan(true); + setShowProfileEditor(false); + setStatusMsg("Presenter profile saved β€” coaching tips across your 30-day plan are now tailored to you."); + } finally { + setSavingProfile(false); + } + }; + + const handleMarkDayComplete = async () => { + if (!selectedDay) return; + setSavingDay(true); + setStatusMsg(null); + try { + await curriculumApi.completeDay(selectedDay.day, { + reflection_notes: reflectionDraft, + }); + await loadPlan(true); + setStatusMsg(`Day ${selectedDay.day} saved & marked complete.`); + } finally { + setSavingDay(false); + } + }; + + const launchDayInStudio = (day: CurriculumDay, mode: "scroll" | "cues" = "scroll") => { + saveStudioLaunchContext({ + title: `Day ${day.day}: ${day.title}`, + mode: day.mode, + prompt: day.prompt, + goal: day.focus_skill, + curriculumDay: day.day, + targetKeywords: day.target_keywords, + teleprompterScript: day.teleprompter_script, + bulletCues: day.bullet_cues, + teleprompterMode: mode, + targetSeconds: Math.min(240, Math.max(60, Math.round(day.duration_min * 6))), + }); + router.push("/record"); + }; + + if (!ready || !plan) { + return ( + + + +

Loading your personalized 30-day presenter plan…

+
+
+
+ ); + } + + return ( + + + {selectedDay && ( + + )} + + } + > +
+ {statusMsg && ( +
+ {statusMsg} +
+ )} + + {/* Personalization Profile Drawer */} + {showProfileEditor && profileForm && ( + + + Personalize Your Presenter Profile +

+ Your role, speaking track, target WPM band, and focus areas customize daily tips, teleprompter scroll speed, and targeted drills. +

+
+ +
+
+ + + + +
+ +
+ + + +
+ +
+

+ Habits You Want to Eliminate or Strengthen +

+
+ {WEAKNESS_OPTIONS.map((opt) => { + const active = profileForm.weaknesses.includes(opt.id); + return ( + + ); + })} +
+
+ +
+ + +
+
+
+
+ )} + + {/* 4-Week Milestone Overview */} +
+ {plan.weeks.map((wk) => { + const pct = Math.round((wk.completed_count / Math.max(1, wk.total_count)) * 100); + const active = selectedWeek === wk.week; + return ( + + ); + })} +
+ + {/* Master-Detail: Day Grid + Active Day Briefing */} +
+ + +
+ + {selectedWeek === 0 ? "All 30 Training Days" : `Week ${selectedWeek} Sessions`} + +

+ {plan.summary.completed_days}/30 completed Β· Recommended next: Day{" "} + {plan.summary.recommended_day} +

+
+ {selectedWeek !== 0 && ( + + )} +
+ + {filteredDays.map((day) => { + const isSelected = selectedDay?.day === day.day; + const isRecommended = plan.summary.recommended_day === day.day; + return ( + + ); + })} + +
+ + {selectedDay && ( + + +
+
+ + Day {selectedDay.day} Β· Week {selectedDay.week} + + {selectedDay.mode} + {selectedDay.duration_min} min + {selectedDay.completed && ( + Completed βœ“ + )} +
+ {selectedDay.completed_session_id && ( + + Open Recorded Session β†’ + + )} +
+ {selectedDay.title} +

{selectedDay.prompt}

+
+ + + {selectedDay.personalized_tip && ( +
+ + Personalized Focus:{" "} + + {selectedDay.personalized_tip} +
+ )} + +
+
+

+ 1. Vocal & Presence Warm-Up +

+

{selectedDay.warmup}

+
+
+

+ 2. Camera Challenge +

+

+ {selectedDay.camera_challenge} +

+
+
+ +
+
+

+ Ready-to-Read Teleprompter Script +

+ Editable in Studio +
+

+ {selectedDay.teleprompter_script} +

+
+ +
+
+

+ Glanceable Bullet Cues +

+
    + {selectedDay.bullet_cues.map((cue, idx) => ( +
  • + {idx + 1}. {cue} +
  • + ))} +
+
+
+

+ Target Keywords & Success Metric +

+
+ {selectedDay.target_keywords.map((kw) => ( + + {kw} + + ))} +
+

+ Goal: {selectedDay.success_metric} +

+
+
+ +
+ + +
+ + {/* Day Reflection & Manual Check-Off */} +
+ +