deploy: sync backend to Space root (learn-lesson HF cache fix)
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .env.example +31 -16
- .env.production.example +36 -30
- .gitignore +1 -0
- Dockerfile +2 -1
- app/core/auth.py +46 -0
- app/core/config.py +40 -7
- app/core/database.py +93 -0
- app/core/rate_limiter.py +6 -1
- app/main.py +26 -0
- app/models/chat_session.py +22 -1
- app/models/learning_state.py +34 -1
- {backend/app → app}/models/password_reset_token.py +0 -0
- app/models/user.py +2 -1
- app/routes/ask.py +6 -0
- app/routes/auth.py +318 -2
- app/routes/billing.py +907 -210
- app/routes/chat.py +130 -18
- app/routes/chat_history.py +76 -13
- {backend/app → app}/routes/chemistry_video.py +0 -0
- app/routes/documents.py +23 -3
- app/routes/learning_engine.py +17 -0
- app/routes/previous_papers.py +4 -11
- {backend/app → app}/routes/social_science_video.py +0 -0
- app/routes/sources.py +2 -6
- app/routes/sync.py +278 -52
- app/routes/users.py +41 -2
- app/schemas/ask.py +3 -0
- app/schemas/chat.py +5 -0
- app/schemas/chat_history.py +57 -5
- app/schemas/learning_state.py +144 -1
- app/schemas/student_workspace.py +10 -1
- app/schemas/user.py +41 -1
- {backend/app → app}/services/academic_state.py +0 -0
- {backend/app → app}/services/account_deletion.py +0 -0
- app/services/ai_provider.py +122 -50
- {backend/app → app}/services/chemistry_curriculum_repository.py +0 -0
- {backend/app → app}/services/document_deletion.py +0 -0
- {backend/app → app}/services/email_service.py +0 -0
- app/services/file_storage.py +3 -7
- app/services/learn_lesson_builder.py +661 -81
- app/services/learning_state_service.py +312 -52
- app/services/physics_curriculum_repository.py +37 -32
- {backend/app → app}/services/social_science_curriculum_repository.py +0 -0
- app/services/tts_provider.py +1 -1
- {backend/app → app}/services/workspace_context.py +0 -0
- backend/.dockerignore +0 -16
- backend/.env.example +0 -178
- backend/.env.huggingface.example +0 -39
- backend/.env.production.example +0 -148
- backend/.gitignore +0 -17
.env.example
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
APP_NAME="AI Exam Success API"
|
| 2 |
ENVIRONMENT="development"
|
| 3 |
|
| 4 |
# PostgreSQL target for production/local DB work.
|
|
@@ -50,6 +50,16 @@ OPENROUTER_MODEL_LLAMA="qwen/qwen3-next-80b-a3b-instruct"
|
|
| 50 |
AI_MAX_RETRIES="2"
|
| 51 |
AI_TIMEOUT_SECONDS="120"
|
| 52 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 53 |
# TTS provider settings.
|
| 54 |
# AI4Bharat Indic Parler is the local Indian-English + Malayalam teacher voice.
|
| 55 |
# Accept its free Hugging Face access terms once, then set HUGGINGFACE_API_KEY.
|
|
@@ -115,6 +125,17 @@ ACCESS_TOKEN_EXPIRE_MINUTES="10080"
|
|
| 115 |
SUPABASE_URL=""
|
| 116 |
SUPABASE_JWT_SECRET=""
|
| 117 |
SUPABASE_ANON_KEY=""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 118 |
|
| 119 |
# Optional Google sign-in.
|
| 120 |
# Create OAuth credentials in Google Cloud and add this redirect URI:
|
|
@@ -136,28 +157,22 @@ BETA_INVITE_CODE=""
|
|
| 136 |
RATE_LIMIT_ENABLED="false"
|
| 137 |
RATE_LIMIT_AI_REQUESTS_PER_DAY="50"
|
| 138 |
|
| 139 |
-
# --- Stripe Billing (
|
| 140 |
-
#
|
| 141 |
-
#
|
| 142 |
-
#
|
| 143 |
-
#
|
| 144 |
-
# One-time exam pack keys use payment checkout:
|
| 145 |
-
# chapter_rescue_29, last_night_pack_49, derivation_pack_49,
|
| 146 |
-
# chemistry_numericals_pack_49, maths_proof_pack_49, kerala_physics_chapter_99
|
| 147 |
-
# Legacy keys remain accepted for existing users:
|
| 148 |
-
# starter_199, popular_299, premium_599
|
| 149 |
# Example:
|
| 150 |
-
# STRIPE_PRICE_IDS=
|
| 151 |
-
#
|
| 152 |
-
# For webhook testing: use `stripe listen --forward-to http://127.0.0.1:8000/billing/webhook`
|
| 153 |
# (this prints a local whsec_... you paste here; in prod use the dashboard endpoint secret).
|
| 154 |
STRIPE_SECRET_KEY=""
|
| 155 |
STRIPE_WEBHOOK_SECRET=""
|
| 156 |
STRIPE_PRICE_IDS=""
|
| 157 |
|
| 158 |
-
#
|
| 159 |
# Shared rate-limit + prompt cache across replicas (e.g. Upstash free tier):
|
| 160 |
REDIS_URL=
|
| 161 |
-
# Error tracking
|
| 162 |
SENTRY_DSN=
|
| 163 |
SENTRY_TRACES_SAMPLE_RATE=0.1
|
|
|
|
| 1 |
+
APP_NAME="AI Exam Success API"
|
| 2 |
ENVIRONMENT="development"
|
| 3 |
|
| 4 |
# PostgreSQL target for production/local DB work.
|
|
|
|
| 50 |
AI_MAX_RETRIES="2"
|
| 51 |
AI_TIMEOUT_SECONDS="120"
|
| 52 |
|
| 53 |
+
# Learn Anything lesson authoring (OpenAI-compatible chat via Groq).
|
| 54 |
+
# Server-side only — never put this in NEXT_PUBLIC_*.
|
| 55 |
+
GROQ_API_KEY=""
|
| 56 |
+
|
| 57 |
+
# Scale knobs for ~1k concurrent students (raise in production Postgres deploys).
|
| 58 |
+
DATABASE_POOL_SIZE="10"
|
| 59 |
+
DATABASE_MAX_OVERFLOW="20"
|
| 60 |
+
RATE_LIMIT_ENABLED="true"
|
| 61 |
+
RATE_LIMIT_AI_REQUESTS_PER_DAY="80"
|
| 62 |
+
|
| 63 |
# TTS provider settings.
|
| 64 |
# AI4Bharat Indic Parler is the local Indian-English + Malayalam teacher voice.
|
| 65 |
# Accept its free Hugging Face access terms once, then set HUGGINGFACE_API_KEY.
|
|
|
|
| 125 |
SUPABASE_URL=""
|
| 126 |
SUPABASE_JWT_SECRET=""
|
| 127 |
SUPABASE_ANON_KEY=""
|
| 128 |
+
# Backend only. Required to fully remove Supabase Auth identities when
|
| 129 |
+
# AUTH_PROVIDER="supabase". Never copy this into a NEXT_PUBLIC_* variable.
|
| 130 |
+
SUPABASE_SERVICE_ROLE_KEY=""
|
| 131 |
+
|
| 132 |
+
# Transactional account email. Required for password recovery when
|
| 133 |
+
# AUTH_PROVIDER="jwt" in production.
|
| 134 |
+
EMAIL_PROVIDER="disabled" # disabled | resend
|
| 135 |
+
RESEND_API_KEY=""
|
| 136 |
+
EMAIL_FROM="DocDoe <support@docdoe.in>"
|
| 137 |
+
PASSWORD_RESET_TOKEN_MINUTES="30"
|
| 138 |
+
PASSWORD_RESET_REQUEST_COOLDOWN_SECONDS="60"
|
| 139 |
|
| 140 |
# Optional Google sign-in.
|
| 141 |
# Create OAuth credentials in Google Cloud and add this redirect URI:
|
|
|
|
| 157 |
RATE_LIMIT_ENABLED="false"
|
| 158 |
RATE_LIMIT_AI_REQUESTS_PER_DAY="50"
|
| 159 |
|
| 160 |
+
# --- Stripe Billing (Popular 299 + Premium 599 subscriptions) ---
|
| 161 |
+
# Prefer a restricted TEST key (rk_test_...) with only the Checkout, Customers,
|
| 162 |
+
# Subscriptions, and Billing Portal permissions this backend needs.
|
| 163 |
+
# Create recurring test Prices for both active plans. Checkout stays disabled
|
| 164 |
+
# until the key, webhook secret, and requested Price are all present.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
# Example:
|
| 166 |
+
# STRIPE_PRICE_IDS=popular_299=price_sub_abc,premium_599=price_sub_def
|
| 167 |
+
# For webhook testing: `stripe listen --forward-to http://127.0.0.1:8000/billing/webhook`
|
|
|
|
| 168 |
# (this prints a local whsec_... you paste here; in prod use the dashboard endpoint secret).
|
| 169 |
STRIPE_SECRET_KEY=""
|
| 170 |
STRIPE_WEBHOOK_SECRET=""
|
| 171 |
STRIPE_PRICE_IDS=""
|
| 172 |
|
| 173 |
+
# ── Observability + scaling (optional; A+ when set) ───────────────────────────
|
| 174 |
# Shared rate-limit + prompt cache across replicas (e.g. Upstash free tier):
|
| 175 |
REDIS_URL=
|
| 176 |
+
# Error tracking — paste a Sentry project DSN to capture backend + frontend errors:
|
| 177 |
SENTRY_DSN=
|
| 178 |
SENTRY_TRACES_SAMPLE_RATE=0.1
|
.env.production.example
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
# DocDoe backend production template.
|
| 2 |
# Copy into your hosting provider's secret/env dashboard. Do not commit real values.
|
| 3 |
# Closed beta must use PostgreSQL for DATABASE_URL. Do not use SQLite for real students.
|
| 4 |
|
|
@@ -6,7 +6,7 @@ ENVIRONMENT=production
|
|
| 6 |
DATABASE_URL=
|
| 7 |
CORS_ORIGINS=
|
| 8 |
|
| 9 |
-
# â
|
| 10 |
# Use "managed" when Supabase or another provider owns the schedule and
|
| 11 |
# retention. Use "pg_dump" for backend/scripts/database_backup.py.
|
| 12 |
DATABASE_BACKUP_STRATEGY=managed
|
|
@@ -16,7 +16,7 @@ DATABASE_RESTORE_TESTED_AT=
|
|
| 16 |
# Required only for pg_dump strategy. This should be durable off-host storage.
|
| 17 |
DATABASE_BACKUP_DIR=
|
| 18 |
|
| 19 |
-
#
|
| 20 |
# AUTH_PROVIDER=supabase is the recommended production setup (managed auth + a
|
| 21 |
# real Postgres for DATABASE_URL). AUTH_PROVIDER=jwt is the self-hosted option.
|
| 22 |
# The tuition beta is login-less regardless (localStorage), so students still
|
|
@@ -26,18 +26,18 @@ AUTH_ENABLED=true
|
|
| 26 |
AUTH_PROVIDER=supabase
|
| 27 |
FRONTEND_BASE_URL=
|
| 28 |
|
| 29 |
-
# Supabase (when AUTH_PROVIDER=supabase). Dashboard
|
| 30 |
-
# SUPABASE_URL
|
| 31 |
-
# SUPABASE_ANON_KEY
|
| 32 |
-
# SUPABASE_JWT_SECRET
|
| 33 |
-
# DATABASE_URL (above)
|
| 34 |
# use the pooled connstring for serverless hosts,
|
| 35 |
# prefix with postgresql:// (not postgres://).
|
| 36 |
SUPABASE_URL=
|
| 37 |
SUPABASE_ANON_KEY=
|
| 38 |
SUPABASE_JWT_SECRET=
|
| 39 |
|
| 40 |
-
# JWT / Google OAuth (only when AUTH_PROVIDER=jwt)
|
| 41 |
JWT_SECRET_KEY=
|
| 42 |
GOOGLE_CLIENT_ID=
|
| 43 |
GOOGLE_CLIENT_SECRET=
|
|
@@ -49,34 +49,41 @@ BETA_INVITE_CODE=
|
|
| 49 |
RATE_LIMIT_ENABLED=true
|
| 50 |
RATE_LIMIT_AI_REQUESTS_PER_DAY=50
|
| 51 |
|
| 52 |
-
#
|
| 53 |
-
# Your main app provider. Keep whatever you already use (e.g. sarvam)
|
| 54 |
# tuition brain does NOT ride on this, it has its own provider below.
|
| 55 |
AI_PROVIDER=sarvam
|
| 56 |
AI_FALLBACK_TO_MOCK=false
|
| 57 |
|
| 58 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 59 |
# Runs on its own cheap text adapter so it never forces the app off AI_PROVIDER.
|
| 60 |
# Must be a text adapter: cloudflare_workers_ai | openrouter | nvidia_nim.
|
| 61 |
# Defaults to cloudflare_workers_ai even if unset.
|
| 62 |
TUITION_BRAIN_PROVIDER=cloudflare_workers_ai
|
| 63 |
|
| 64 |
# Cloudflare Workers AI (when AI_PROVIDER=cloudflare_workers_ai).
|
| 65 |
-
# CLOUDFLARE_ACCOUNT_ID
|
| 66 |
-
# CLOUDFLARE_API_TOKEN
|
| 67 |
-
# template "Workers AI" (permission: Account · Workers AI · Read/Run)
|
| 68 |
CLOUDFLARE_ACCOUNT_ID=
|
| 69 |
CLOUDFLARE_API_TOKEN=
|
| 70 |
# Optional model override (defaults to @cf/meta/llama-3.1-8b-instruct).
|
| 71 |
CLOUDFLARE_WORKERS_AI_TEXT_MODEL=@cf/meta/llama-3.1-8b-instruct
|
| 72 |
|
| 73 |
-
# Daily AI budget guard (USD). MUST be > 0 for the brain LLM to run
|
| 74 |
# Workers AI is near-free but its estimated cost is > 0, so a 0 budget denies it
|
| 75 |
# and everything falls back to the deterministic brain. Start small.
|
| 76 |
AI_ROUTER_DAILY_BUDGET_USD=5
|
| 77 |
AI_ROUTER_ALLOW_FREE_PROVIDERS=true
|
| 78 |
|
| 79 |
-
# Alternative paid provider (only when AI_PROVIDER=sarvam)
|
| 80 |
SARVAM_API_KEY=
|
| 81 |
|
| 82 |
# Study-video voice defaults to local AI4Bharat Indic Parler for Indian English
|
|
@@ -100,19 +107,19 @@ VIDEO_BETA_MAX_DURATION_SECONDS=90
|
|
| 100 |
VIDEO_SCENE_TEXT_MAX_CHARS=700
|
| 101 |
VIDEO_MAX_CONCURRENT_RENDER_JOBS_PER_USER=1
|
| 102 |
|
| 103 |
-
#
|
| 104 |
# Current production target: Cloudinary (free tier, generous transformations).
|
| 105 |
# Alternatives: cloudflare R2, AWS S3 (see backend/STORAGE_SETUP.md).
|
| 106 |
STORAGE_PROVIDER=cloudinary
|
| 107 |
|
| 108 |
# Cloudinary credentials (when STORAGE_PROVIDER=cloudinary).
|
| 109 |
-
# Find under: cloudinary.com Dashboard
|
| 110 |
# All three required.
|
| 111 |
CLOUDINARY_CLOUD_NAME=
|
| 112 |
CLOUDINARY_API_KEY=
|
| 113 |
CLOUDINARY_API_SECRET=
|
| 114 |
|
| 115 |
-
# R2 / S3 (when STORAGE_PROVIDER=r2 or s3)
|
| 116 |
STORAGE_BUCKET=
|
| 117 |
STORAGE_ENDPOINT_URL=
|
| 118 |
STORAGE_ACCESS_KEY_ID=
|
|
@@ -124,17 +131,16 @@ STORAGE_REGION=
|
|
| 124 |
# Values: huggingface | railway | flyio | docker | local
|
| 125 |
DEPLOY_TARGET=huggingface
|
| 126 |
|
| 127 |
-
#
|
| 128 |
-
#
|
| 129 |
-
#
|
| 130 |
-
#
|
| 131 |
-
#
|
| 132 |
-
#
|
| 133 |
-
#
|
| 134 |
-
# STRIPE_PRICE_IDS=subject_month_99=price_...,parent_tuition_299=price_...,chapter_rescue_29=price_...
|
| 135 |
-
# UPI/Razorpay is the recommended next integration for India-friendly one-time packs; do not promise it live until wired.
|
| 136 |
# WEBHOOK: In Stripe dashboard create endpoint https://yourdomain.com/billing/webhook
|
| 137 |
-
# select
|
|
|
|
| 138 |
# IMPORTANT: After setting, restart/redeploy backend so pydantic-settings picks up.
|
| 139 |
# Never commit keys. Rotate secrets if leaked. See backend/app/routes/billing.py for impl.
|
| 140 |
STRIPE_SECRET_KEY=
|
|
|
|
| 1 |
+
# DocDoe backend production template.
|
| 2 |
# Copy into your hosting provider's secret/env dashboard. Do not commit real values.
|
| 3 |
# Closed beta must use PostgreSQL for DATABASE_URL. Do not use SQLite for real students.
|
| 4 |
|
|
|
|
| 6 |
DATABASE_URL=
|
| 7 |
CORS_ORIGINS=
|
| 8 |
|
| 9 |
+
# ââ€â‚¬Ã¢â€â‚¬ Database backup and recovery ââ€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬
|
| 10 |
# Use "managed" when Supabase or another provider owns the schedule and
|
| 11 |
# retention. Use "pg_dump" for backend/scripts/database_backup.py.
|
| 12 |
DATABASE_BACKUP_STRATEGY=managed
|
|
|
|
| 16 |
# Required only for pg_dump strategy. This should be durable off-host storage.
|
| 17 |
DATABASE_BACKUP_DIR=
|
| 18 |
|
| 19 |
+
# ── Auth ──────────────────────────────────────────────────────────────────
|
| 20 |
# AUTH_PROVIDER=supabase is the recommended production setup (managed auth + a
|
| 21 |
# real Postgres for DATABASE_URL). AUTH_PROVIDER=jwt is the self-hosted option.
|
| 22 |
# The tuition beta is login-less regardless (localStorage), so students still
|
|
|
|
| 26 |
AUTH_PROVIDER=supabase
|
| 27 |
FRONTEND_BASE_URL=
|
| 28 |
|
| 29 |
+
# Supabase (when AUTH_PROVIDER=supabase). Dashboard → Project Settings.
|
| 30 |
+
# SUPABASE_URL → Settings → API → Project URL
|
| 31 |
+
# SUPABASE_ANON_KEY → Settings → API → Project API keys → anon public
|
| 32 |
+
# SUPABASE_JWT_SECRET → Settings → API → JWT Settings → JWT Secret
|
| 33 |
+
# DATABASE_URL (above) → Settings → Database → Connection string (URI),
|
| 34 |
# use the pooled connstring for serverless hosts,
|
| 35 |
# prefix with postgresql:// (not postgres://).
|
| 36 |
SUPABASE_URL=
|
| 37 |
SUPABASE_ANON_KEY=
|
| 38 |
SUPABASE_JWT_SECRET=
|
| 39 |
|
| 40 |
+
# JWT / Google OAuth (only when AUTH_PROVIDER=jwt) — leave blank for supabase.
|
| 41 |
JWT_SECRET_KEY=
|
| 42 |
GOOGLE_CLIENT_ID=
|
| 43 |
GOOGLE_CLIENT_SECRET=
|
|
|
|
| 49 |
RATE_LIMIT_ENABLED=true
|
| 50 |
RATE_LIMIT_AI_REQUESTS_PER_DAY=50
|
| 51 |
|
| 52 |
+
# ── Text AI provider (study-chat, notes, quizzes, video scripts) ─────────────
|
| 53 |
+
# Your main app provider. Keep whatever you already use (e.g. sarvam) — the
|
| 54 |
# tuition brain does NOT ride on this, it has its own provider below.
|
| 55 |
AI_PROVIDER=sarvam
|
| 56 |
AI_FALLBACK_TO_MOCK=false
|
| 57 |
|
| 58 |
+
# Learn Anything lesson authoring (Groq). Server-only.
|
| 59 |
+
GROQ_API_KEY=
|
| 60 |
+
|
| 61 |
+
# Postgres pool for ~1k concurrent students (tune against your plan limits).
|
| 62 |
+
DATABASE_POOL_SIZE=15
|
| 63 |
+
DATABASE_MAX_OVERFLOW=30
|
| 64 |
+
|
| 65 |
+
# ── Tuition brain provider (the real LLM that restates + plans today's class) ─
|
| 66 |
# Runs on its own cheap text adapter so it never forces the app off AI_PROVIDER.
|
| 67 |
# Must be a text adapter: cloudflare_workers_ai | openrouter | nvidia_nim.
|
| 68 |
# Defaults to cloudflare_workers_ai even if unset.
|
| 69 |
TUITION_BRAIN_PROVIDER=cloudflare_workers_ai
|
| 70 |
|
| 71 |
# Cloudflare Workers AI (when AI_PROVIDER=cloudflare_workers_ai).
|
| 72 |
+
# CLOUDFLARE_ACCOUNT_ID → dash.cloudflare.com → account id (right sidebar / URL)
|
| 73 |
+
# CLOUDFLARE_API_TOKEN → My Profile → API Tokens → Create Token →
|
| 74 |
+
# template "Workers AI" (permission: Account · Workers AI · Read/Run)
|
| 75 |
CLOUDFLARE_ACCOUNT_ID=
|
| 76 |
CLOUDFLARE_API_TOKEN=
|
| 77 |
# Optional model override (defaults to @cf/meta/llama-3.1-8b-instruct).
|
| 78 |
CLOUDFLARE_WORKERS_AI_TEXT_MODEL=@cf/meta/llama-3.1-8b-instruct
|
| 79 |
|
| 80 |
+
# Daily AI budget guard (USD). MUST be > 0 for the brain LLM to run — Cloudflare
|
| 81 |
# Workers AI is near-free but its estimated cost is > 0, so a 0 budget denies it
|
| 82 |
# and everything falls back to the deterministic brain. Start small.
|
| 83 |
AI_ROUTER_DAILY_BUDGET_USD=5
|
| 84 |
AI_ROUTER_ALLOW_FREE_PROVIDERS=true
|
| 85 |
|
| 86 |
+
# Alternative paid provider (only when AI_PROVIDER=sarvam) — leave blank otherwise.
|
| 87 |
SARVAM_API_KEY=
|
| 88 |
|
| 89 |
# Study-video voice defaults to local AI4Bharat Indic Parler for Indian English
|
|
|
|
| 107 |
VIDEO_SCENE_TEXT_MAX_CHARS=700
|
| 108 |
VIDEO_MAX_CONCURRENT_RENDER_JOBS_PER_USER=1
|
| 109 |
|
| 110 |
+
# ── Cloud storage (REQUIRED in production for video URLs to be playable) ─────
|
| 111 |
# Current production target: Cloudinary (free tier, generous transformations).
|
| 112 |
# Alternatives: cloudflare R2, AWS S3 (see backend/STORAGE_SETUP.md).
|
| 113 |
STORAGE_PROVIDER=cloudinary
|
| 114 |
|
| 115 |
# Cloudinary credentials (when STORAGE_PROVIDER=cloudinary).
|
| 116 |
+
# Find under: cloudinary.com Dashboard → Settings → API Keys.
|
| 117 |
# All three required.
|
| 118 |
CLOUDINARY_CLOUD_NAME=
|
| 119 |
CLOUDINARY_API_KEY=
|
| 120 |
CLOUDINARY_API_SECRET=
|
| 121 |
|
| 122 |
+
# R2 / S3 (when STORAGE_PROVIDER=r2 or s3) — leave blank if using Cloudinary.
|
| 123 |
STORAGE_BUCKET=
|
| 124 |
STORAGE_ENDPOINT_URL=
|
| 125 |
STORAGE_ACCESS_KEY_ID=
|
|
|
|
| 131 |
# Values: huggingface | railway | flyio | docker | local
|
| 132 |
DEPLOY_TARGET=huggingface
|
| 133 |
|
| 134 |
+
# ── Stripe Billing (MANDATORY for paid plans in production) ───────────────────
|
| 135 |
+
# Prefer a restricted LIVE key (rk_live_...) scoped to Checkout, Customers,
|
| 136 |
+
# Subscriptions, and Billing Portal. Store it only in the backend secret store.
|
| 137 |
+
# Create recurring LIVE Prices for the two plans currently shown to students.
|
| 138 |
+
# Checkout refuses to take payment unless the webhook secret and selected Price
|
| 139 |
+
# are both configured.
|
| 140 |
+
# STRIPE_PRICE_IDS=popular_299=price_...,premium_599=price_...
|
|
|
|
|
|
|
| 141 |
# WEBHOOK: In Stripe dashboard create endpoint https://yourdomain.com/billing/webhook
|
| 142 |
+
# select checkout.session.completed, customer.subscription.created/updated/deleted,
|
| 143 |
+
# invoice.paid, and invoice.payment_failed. Copy the signing secret here.
|
| 144 |
# IMPORTANT: After setting, restart/redeploy backend so pydantic-settings picks up.
|
| 145 |
# Never commit keys. Rotate secrets if leaked. See backend/app/routes/billing.py for impl.
|
| 146 |
STRIPE_SECRET_KEY=
|
.gitignore
CHANGED
|
@@ -12,5 +12,6 @@ uploads/*
|
|
| 12 |
!uploads/.gitkeep
|
| 13 |
generated-video-jobs/
|
| 14 |
generated-videos/
|
|
|
|
| 15 |
uvicorn_log.txt
|
| 16 |
*.log
|
|
|
|
| 12 |
!uploads/.gitkeep
|
| 13 |
generated-video-jobs/
|
| 14 |
generated-videos/
|
| 15 |
+
generated/
|
| 16 |
uvicorn_log.txt
|
| 17 |
*.log
|
Dockerfile
CHANGED
|
@@ -5,7 +5,8 @@ ENV PYTHONDONTWRITEBYTECODE=1 \
|
|
| 5 |
UPLOAD_DIR=/app/uploads \
|
| 6 |
TTS_OUTPUT_DIR=/app/generated/audio \
|
| 7 |
GENERATED_VIDEO_JOBS_DIR=/app/generated-video-jobs \
|
| 8 |
-
GENERATED_VIDEO_OUTPUT_DIR=/app/generated-videos
|
|
|
|
| 9 |
|
| 10 |
WORKDIR /app
|
| 11 |
|
|
|
|
| 5 |
UPLOAD_DIR=/app/uploads \
|
| 6 |
TTS_OUTPUT_DIR=/app/generated/audio \
|
| 7 |
GENERATED_VIDEO_JOBS_DIR=/app/generated-video-jobs \
|
| 8 |
+
GENERATED_VIDEO_OUTPUT_DIR=/app/generated-videos \
|
| 9 |
+
LEARN_LESSON_CACHE_DIR=/app/generated/learn-anything
|
| 10 |
|
| 11 |
WORKDIR /app
|
| 12 |
|
app/core/auth.py
CHANGED
|
@@ -128,6 +128,7 @@ def create_access_token(user: User) -> tuple[str, int]:
|
|
| 128 |
"sub": user.id,
|
| 129 |
"email": user.email,
|
| 130 |
"role": user.role,
|
|
|
|
| 131 |
"exp": expires_at,
|
| 132 |
"iat": datetime.now(timezone.utc),
|
| 133 |
}
|
|
@@ -135,6 +136,45 @@ def create_access_token(user: User) -> tuple[str, int]:
|
|
| 135 |
return token, expires_in_seconds
|
| 136 |
|
| 137 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 138 |
def _user_from_local_jwt(db: Session, token: str) -> User | None:
|
| 139 |
settings = get_settings()
|
| 140 |
try:
|
|
@@ -153,6 +193,12 @@ def _user_from_local_jwt(db: Session, token: str) -> User | None:
|
|
| 153 |
user = db.get(User, user_id)
|
| 154 |
if user is None:
|
| 155 |
raise _auth_error()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 156 |
return user
|
| 157 |
|
| 158 |
|
|
|
|
| 128 |
"sub": user.id,
|
| 129 |
"email": user.email,
|
| 130 |
"role": user.role,
|
| 131 |
+
"ver": user.auth_version,
|
| 132 |
"exp": expires_at,
|
| 133 |
"iat": datetime.now(timezone.utc),
|
| 134 |
}
|
|
|
|
| 136 |
return token, expires_in_seconds
|
| 137 |
|
| 138 |
|
| 139 |
+
def get_verified_auth_subject(token: str) -> str:
|
| 140 |
+
"""Return the verified identity-provider subject for an access token.
|
| 141 |
+
|
| 142 |
+
Most application routes only need the hydrated local ``User``. Destructive
|
| 143 |
+
identity operations also need the original provider subject so a legacy
|
| 144 |
+
email-matched local row cannot accidentally be used as a Supabase user ID.
|
| 145 |
+
"""
|
| 146 |
+
settings = get_settings()
|
| 147 |
+
auth_provider = (settings.auth_provider or "jwt").strip().lower()
|
| 148 |
+
if auth_provider == "supabase":
|
| 149 |
+
if not settings.supabase_jwt_secret:
|
| 150 |
+
raise HTTPException(
|
| 151 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 152 |
+
detail="Supabase auth is enabled but SUPABASE_JWT_SECRET is not configured.",
|
| 153 |
+
)
|
| 154 |
+
secret = settings.supabase_jwt_secret
|
| 155 |
+
algorithms = ["HS256"]
|
| 156 |
+
elif auth_provider == "jwt":
|
| 157 |
+
secret = settings.jwt_secret_key
|
| 158 |
+
algorithms = [settings.jwt_algorithm]
|
| 159 |
+
else:
|
| 160 |
+
raise _auth_error()
|
| 161 |
+
|
| 162 |
+
try:
|
| 163 |
+
payload = jwt.decode(
|
| 164 |
+
token,
|
| 165 |
+
secret,
|
| 166 |
+
algorithms=algorithms,
|
| 167 |
+
options={"verify_aud": False},
|
| 168 |
+
)
|
| 169 |
+
except jwt.PyJWTError as exc:
|
| 170 |
+
raise _auth_error() from exc
|
| 171 |
+
|
| 172 |
+
subject = str(payload.get("sub") or "")
|
| 173 |
+
if not subject:
|
| 174 |
+
raise _auth_error()
|
| 175 |
+
return subject
|
| 176 |
+
|
| 177 |
+
|
| 178 |
def _user_from_local_jwt(db: Session, token: str) -> User | None:
|
| 179 |
settings = get_settings()
|
| 180 |
try:
|
|
|
|
| 193 |
user = db.get(User, user_id)
|
| 194 |
if user is None:
|
| 195 |
raise _auth_error()
|
| 196 |
+
try:
|
| 197 |
+
token_version = int(payload.get("ver", 1))
|
| 198 |
+
except (TypeError, ValueError) as exc:
|
| 199 |
+
raise _auth_error() from exc
|
| 200 |
+
if token_version != user.auth_version:
|
| 201 |
+
raise _auth_error()
|
| 202 |
return user
|
| 203 |
|
| 204 |
|
app/core/config.py
CHANGED
|
@@ -14,10 +14,23 @@ class Settings(BaseSettings):
|
|
| 14 |
app_version: str = "0.1.0"
|
| 15 |
environment: str = "development"
|
| 16 |
database_url: str = f"sqlite:///{BACKEND_DIR / 'exam_success_dev.db'}"
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
cors_origins: str = "http://localhost:3000,http://127.0.0.1:3000,http://localhost:3001,http://127.0.0.1:3001,http://localhost:3003,http://127.0.0.1:3003"
|
| 22 |
upload_dir: str = "uploads"
|
| 23 |
ai_provider: str = "openai"
|
|
@@ -159,6 +172,15 @@ class Settings(BaseSettings):
|
|
| 159 |
redis_url: str | None = None
|
| 160 |
sentry_dsn: str | None = None
|
| 161 |
sentry_traces_sample_rate: float = 0.1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 162 |
generated_video_jobs_dir: str = "generated-video-jobs"
|
| 163 |
generated_video_output_dir: str = "generated-videos"
|
| 164 |
video_render_timeout_seconds: int = 3600
|
|
@@ -192,6 +214,13 @@ class Settings(BaseSettings):
|
|
| 192 |
supabase_url: str | None = None
|
| 193 |
supabase_jwt_secret: str | None = None
|
| 194 |
supabase_anon_key: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
jwt_secret_key: str = "change-this-local-dev-secret"
|
| 196 |
jwt_algorithm: str = "HS256"
|
| 197 |
access_token_expire_minutes: int = 10080 # 7 days — keeps users logged in across sessions
|
|
@@ -235,9 +264,13 @@ class Settings(BaseSettings):
|
|
| 235 |
stripe_webhook_secret: str | None = Field(
|
| 236 |
default=None, validation_alias=AliasChoices("STRIPE_WEBHOOK_SECRET")
|
| 237 |
)
|
| 238 |
-
|
| 239 |
-
#
|
| 240 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 241 |
|
| 242 |
model_config = SettingsConfigDict(
|
| 243 |
env_file=BACKEND_DIR / ".env",
|
|
|
|
| 14 |
app_version: str = "0.1.0"
|
| 15 |
environment: str = "development"
|
| 16 |
database_url: str = f"sqlite:///{BACKEND_DIR / 'exam_success_dev.db'}"
|
| 17 |
+
# Dev defaults stay small; production .env should raise these for 1k+ concurrent students.
|
| 18 |
+
database_pool_size: int = Field(
|
| 19 |
+
default=2,
|
| 20 |
+
validation_alias=AliasChoices("DATABASE_POOL_SIZE"),
|
| 21 |
+
)
|
| 22 |
+
database_max_overflow: int = Field(
|
| 23 |
+
default=3,
|
| 24 |
+
validation_alias=AliasChoices("DATABASE_MAX_OVERFLOW"),
|
| 25 |
+
)
|
| 26 |
+
database_pool_timeout_seconds: int = Field(
|
| 27 |
+
default=10,
|
| 28 |
+
validation_alias=AliasChoices("DATABASE_POOL_TIMEOUT_SECONDS"),
|
| 29 |
+
)
|
| 30 |
+
database_pool_recycle_seconds: int = Field(
|
| 31 |
+
default=1800,
|
| 32 |
+
validation_alias=AliasChoices("DATABASE_POOL_RECYCLE_SECONDS"),
|
| 33 |
+
)
|
| 34 |
cors_origins: str = "http://localhost:3000,http://127.0.0.1:3000,http://localhost:3001,http://127.0.0.1:3001,http://localhost:3003,http://127.0.0.1:3003"
|
| 35 |
upload_dir: str = "uploads"
|
| 36 |
ai_provider: str = "openai"
|
|
|
|
| 172 |
redis_url: str | None = None
|
| 173 |
sentry_dsn: str | None = None
|
| 174 |
sentry_traces_sample_rate: float = 0.1
|
| 175 |
+
email_provider: str = "disabled"
|
| 176 |
+
resend_api_key: str | None = Field(
|
| 177 |
+
default=None,
|
| 178 |
+
validation_alias=AliasChoices("RESEND_API_KEY"),
|
| 179 |
+
)
|
| 180 |
+
resend_base_url: str = "https://api.resend.com"
|
| 181 |
+
email_from: str = "DocDoe <support@docdoe.in>"
|
| 182 |
+
password_reset_token_minutes: int = 30
|
| 183 |
+
password_reset_request_cooldown_seconds: int = 60
|
| 184 |
generated_video_jobs_dir: str = "generated-video-jobs"
|
| 185 |
generated_video_output_dir: str = "generated-videos"
|
| 186 |
video_render_timeout_seconds: int = 3600
|
|
|
|
| 214 |
supabase_url: str | None = None
|
| 215 |
supabase_jwt_secret: str | None = None
|
| 216 |
supabase_anon_key: str | None = None
|
| 217 |
+
# Backend-only key used for destructive Supabase Auth administration, such
|
| 218 |
+
# as honoring an authenticated account-deletion request. Never expose this
|
| 219 |
+
# value through a NEXT_PUBLIC_* variable or a frontend response.
|
| 220 |
+
supabase_service_role_key: str | None = Field(
|
| 221 |
+
default=None,
|
| 222 |
+
validation_alias=AliasChoices("SUPABASE_SERVICE_ROLE_KEY"),
|
| 223 |
+
)
|
| 224 |
jwt_secret_key: str = "change-this-local-dev-secret"
|
| 225 |
jwt_algorithm: str = "HS256"
|
| 226 |
access_token_expire_minutes: int = 10080 # 7 days — keeps users logged in across sessions
|
|
|
|
| 264 |
stripe_webhook_secret: str | None = Field(
|
| 265 |
default=None, validation_alias=AliasChoices("STRIPE_WEBHOOK_SECRET")
|
| 266 |
)
|
| 267 |
+
stripe_api_version: str = "2026-06-24.dahlia"
|
| 268 |
+
# Stable integration label registered for this checkout flow. The suffix
|
| 269 |
+
# was generated once (rather than per request) so Stripe idempotency keys
|
| 270 |
+
# always see identical request parameters.
|
| 271 |
+
stripe_checkout_integration_identifier: str = "docdoe_web_psuqtgkx"
|
| 272 |
+
# Map the two student-facing subscription keys to recurring Stripe Prices.
|
| 273 |
+
stripe_price_ids: str = "" # e.g. popular_299=price_xxx,premium_599=price_yyy
|
| 274 |
|
| 275 |
model_config = SettingsConfigDict(
|
| 276 |
env_file=BACKEND_DIR / ".env",
|
app/core/database.py
CHANGED
|
@@ -107,6 +107,7 @@ def init_db() -> None:
|
|
| 107 |
tuition_profile,
|
| 108 |
class_session_progress,
|
| 109 |
phase3_activity,
|
|
|
|
| 110 |
learn_anything_roadmap,
|
| 111 |
learning_state,
|
| 112 |
support_submission,
|
|
@@ -135,13 +136,16 @@ def init_db() -> None:
|
|
| 135 |
_ensure_document_chunk_columns()
|
| 136 |
_ensure_previous_paper_columns()
|
| 137 |
_ensure_ai_result_columns()
|
|
|
|
| 138 |
_ensure_chat_message_columns()
|
| 139 |
_ensure_video_render_job_columns()
|
| 140 |
_ensure_generation_columns()
|
| 141 |
_ensure_user_columns()
|
| 142 |
_ensure_user_plan_columns()
|
|
|
|
| 143 |
_ensure_previous_question_t2_columns()
|
| 144 |
_ensure_student_profile_exam_date_nullable()
|
|
|
|
| 145 |
|
| 146 |
with SessionLocal() as db:
|
| 147 |
demo_user = db.get(User, "usr_demo_student")
|
|
@@ -284,6 +288,19 @@ def _ensure_ai_result_columns() -> None:
|
|
| 284 |
)
|
| 285 |
|
| 286 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 287 |
def _ensure_chat_message_columns() -> None:
|
| 288 |
inspector = inspect(engine)
|
| 289 |
if "chat_messages" not in inspector.get_table_names():
|
|
@@ -293,6 +310,21 @@ def _ensure_chat_message_columns() -> None:
|
|
| 293 |
with engine.begin() as connection:
|
| 294 |
if "evidence_label" not in columns:
|
| 295 |
connection.execute(text("ALTER TABLE chat_messages ADD COLUMN evidence_label TEXT"))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 296 |
|
| 297 |
|
| 298 |
def _ensure_previous_paper_columns() -> None:
|
|
@@ -439,6 +471,27 @@ def _ensure_student_profile_exam_date_nullable() -> None:
|
|
| 439 |
pass
|
| 440 |
|
| 441 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 442 |
def _ensure_user_columns() -> None:
|
| 443 |
inspector = inspect(engine)
|
| 444 |
if "users" not in inspector.get_table_names():
|
|
@@ -448,6 +501,10 @@ def _ensure_user_columns() -> None:
|
|
| 448 |
with engine.begin() as connection:
|
| 449 |
if "password_hash" not in columns:
|
| 450 |
connection.execute(text("ALTER TABLE users ADD COLUMN password_hash TEXT"))
|
|
|
|
|
|
|
|
|
|
|
|
|
| 451 |
|
| 452 |
|
| 453 |
def _ensure_document_chunk_columns() -> None:
|
|
@@ -475,6 +532,42 @@ def _ensure_user_plan_columns() -> None:
|
|
| 475 |
)
|
| 476 |
|
| 477 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 478 |
def _ensure_generation_columns() -> None:
|
| 479 |
inspector = inspect(engine)
|
| 480 |
if "generations" not in inspector.get_table_names():
|
|
|
|
| 107 |
tuition_profile,
|
| 108 |
class_session_progress,
|
| 109 |
phase3_activity,
|
| 110 |
+
password_reset_token,
|
| 111 |
learn_anything_roadmap,
|
| 112 |
learning_state,
|
| 113 |
support_submission,
|
|
|
|
| 136 |
_ensure_document_chunk_columns()
|
| 137 |
_ensure_previous_paper_columns()
|
| 138 |
_ensure_ai_result_columns()
|
| 139 |
+
_ensure_chat_session_columns()
|
| 140 |
_ensure_chat_message_columns()
|
| 141 |
_ensure_video_render_job_columns()
|
| 142 |
_ensure_generation_columns()
|
| 143 |
_ensure_user_columns()
|
| 144 |
_ensure_user_plan_columns()
|
| 145 |
+
_ensure_subscription_columns()
|
| 146 |
_ensure_previous_question_t2_columns()
|
| 147 |
_ensure_student_profile_exam_date_nullable()
|
| 148 |
+
_ensure_quiz_attempt_columns()
|
| 149 |
|
| 150 |
with SessionLocal() as db:
|
| 151 |
demo_user = db.get(User, "usr_demo_student")
|
|
|
|
| 288 |
)
|
| 289 |
|
| 290 |
|
| 291 |
+
def _ensure_chat_session_columns() -> None:
|
| 292 |
+
inspector = inspect(engine)
|
| 293 |
+
if "chat_sessions" not in inspector.get_table_names():
|
| 294 |
+
return
|
| 295 |
+
|
| 296 |
+
columns = {column["name"] for column in inspector.get_columns("chat_sessions")}
|
| 297 |
+
if "context_data" not in columns:
|
| 298 |
+
with engine.begin() as connection:
|
| 299 |
+
connection.execute(
|
| 300 |
+
text("ALTER TABLE chat_sessions ADD COLUMN context_data JSON NOT NULL DEFAULT '{}'")
|
| 301 |
+
)
|
| 302 |
+
|
| 303 |
+
|
| 304 |
def _ensure_chat_message_columns() -> None:
|
| 305 |
inspector = inspect(engine)
|
| 306 |
if "chat_messages" not in inspector.get_table_names():
|
|
|
|
| 310 |
with engine.begin() as connection:
|
| 311 |
if "evidence_label" not in columns:
|
| 312 |
connection.execute(text("ALTER TABLE chat_messages ADD COLUMN evidence_label TEXT"))
|
| 313 |
+
if "web_sources" not in columns:
|
| 314 |
+
connection.execute(
|
| 315 |
+
text("ALTER TABLE chat_messages ADD COLUMN web_sources JSON NOT NULL DEFAULT '[]'"),
|
| 316 |
+
)
|
| 317 |
+
if "client_turn_id" not in columns:
|
| 318 |
+
connection.execute(
|
| 319 |
+
text("ALTER TABLE chat_messages ADD COLUMN client_turn_id TEXT"),
|
| 320 |
+
)
|
| 321 |
+
connection.execute(
|
| 322 |
+
text(
|
| 323 |
+
"CREATE UNIQUE INDEX IF NOT EXISTS uq_chat_messages_session_turn_role "
|
| 324 |
+
"ON chat_messages(session_id, client_turn_id, role) "
|
| 325 |
+
"WHERE client_turn_id IS NOT NULL",
|
| 326 |
+
),
|
| 327 |
+
)
|
| 328 |
|
| 329 |
|
| 330 |
def _ensure_previous_paper_columns() -> None:
|
|
|
|
| 471 |
pass
|
| 472 |
|
| 473 |
|
| 474 |
+
def _ensure_quiz_attempt_columns() -> None:
|
| 475 |
+
"""Backfill assessment idempotency on existing development databases."""
|
| 476 |
+
|
| 477 |
+
inspector = inspect(engine)
|
| 478 |
+
if "quiz_attempts" not in inspector.get_table_names():
|
| 479 |
+
return
|
| 480 |
+
|
| 481 |
+
columns = {column["name"] for column in inspector.get_columns("quiz_attempts")}
|
| 482 |
+
with engine.begin() as connection:
|
| 483 |
+
if "client_attempt_id" not in columns:
|
| 484 |
+
connection.execute(
|
| 485 |
+
text("ALTER TABLE quiz_attempts ADD COLUMN client_attempt_id VARCHAR(180)")
|
| 486 |
+
)
|
| 487 |
+
connection.execute(
|
| 488 |
+
text(
|
| 489 |
+
"CREATE UNIQUE INDEX IF NOT EXISTS uq_quiz_attempts_user_client "
|
| 490 |
+
"ON quiz_attempts (user_id, client_attempt_id)"
|
| 491 |
+
)
|
| 492 |
+
)
|
| 493 |
+
|
| 494 |
+
|
| 495 |
def _ensure_user_columns() -> None:
|
| 496 |
inspector = inspect(engine)
|
| 497 |
if "users" not in inspector.get_table_names():
|
|
|
|
| 501 |
with engine.begin() as connection:
|
| 502 |
if "password_hash" not in columns:
|
| 503 |
connection.execute(text("ALTER TABLE users ADD COLUMN password_hash TEXT"))
|
| 504 |
+
if "auth_version" not in columns:
|
| 505 |
+
connection.execute(
|
| 506 |
+
text("ALTER TABLE users ADD COLUMN auth_version INTEGER NOT NULL DEFAULT 1")
|
| 507 |
+
)
|
| 508 |
|
| 509 |
|
| 510 |
def _ensure_document_chunk_columns() -> None:
|
|
|
|
| 532 |
)
|
| 533 |
|
| 534 |
|
| 535 |
+
def _ensure_subscription_columns() -> None:
|
| 536 |
+
"""Backfill Stripe lifecycle columns on existing local/hosted databases.
|
| 537 |
+
|
| 538 |
+
Explicit SQL migrations remain the production source of truth. This guard
|
| 539 |
+
keeps SQLite development databases usable when they predate that migration.
|
| 540 |
+
"""
|
| 541 |
+
|
| 542 |
+
inspector = inspect(engine)
|
| 543 |
+
if "subscriptions" not in inspector.get_table_names():
|
| 544 |
+
return
|
| 545 |
+
|
| 546 |
+
columns = {column["name"] for column in inspector.get_columns("subscriptions")}
|
| 547 |
+
with engine.begin() as connection:
|
| 548 |
+
if "provider_price_id" not in columns:
|
| 549 |
+
connection.execute(text("ALTER TABLE subscriptions ADD COLUMN provider_price_id TEXT"))
|
| 550 |
+
if "cancel_at_period_end" not in columns:
|
| 551 |
+
connection.execute(
|
| 552 |
+
text(
|
| 553 |
+
"ALTER TABLE subscriptions "
|
| 554 |
+
"ADD COLUMN cancel_at_period_end BOOLEAN NOT NULL DEFAULT FALSE"
|
| 555 |
+
)
|
| 556 |
+
)
|
| 557 |
+
connection.execute(
|
| 558 |
+
text(
|
| 559 |
+
"CREATE UNIQUE INDEX IF NOT EXISTS ix_subscriptions_provider_customer "
|
| 560 |
+
"ON subscriptions (provider_customer_id)"
|
| 561 |
+
)
|
| 562 |
+
)
|
| 563 |
+
connection.execute(
|
| 564 |
+
text(
|
| 565 |
+
"CREATE UNIQUE INDEX IF NOT EXISTS ix_subscriptions_provider_subscription "
|
| 566 |
+
"ON subscriptions (provider_subscription_id)"
|
| 567 |
+
)
|
| 568 |
+
)
|
| 569 |
+
|
| 570 |
+
|
| 571 |
def _ensure_generation_columns() -> None:
|
| 572 |
inspector = inspect(engine)
|
| 573 |
if "generations" not in inspector.get_table_names():
|
app/core/rate_limiter.py
CHANGED
|
@@ -56,7 +56,12 @@ def over_per_minute(key: str, now: float | None = None, path: str | None = None)
|
|
| 56 |
the brute-force guard.
|
| 57 |
"""
|
| 58 |
now = now or time.time()
|
| 59 |
-
is_auth = path
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
is_support = path is not None and path.startswith("/support")
|
| 61 |
if is_auth:
|
| 62 |
limit = _PER_IP_AUTH_MINUTE_LIMIT
|
|
|
|
| 56 |
the brute-force guard.
|
| 57 |
"""
|
| 58 |
now = now or time.time()
|
| 59 |
+
is_auth = path in {
|
| 60 |
+
"/auth/login",
|
| 61 |
+
"/auth/signup",
|
| 62 |
+
"/auth/forgot-password",
|
| 63 |
+
"/auth/reset-password",
|
| 64 |
+
}
|
| 65 |
is_support = path is not None and path.startswith("/support")
|
| 66 |
if is_auth:
|
| 67 |
limit = _PER_IP_AUTH_MINUTE_LIMIT
|
app/main.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
from collections.abc import AsyncIterator
|
| 2 |
from contextlib import asynccontextmanager
|
|
|
|
| 3 |
|
| 4 |
import logging
|
| 5 |
import time
|
|
@@ -25,6 +26,7 @@ from app.routes import (
|
|
| 25 |
billing,
|
| 26 |
chat,
|
| 27 |
chat_history,
|
|
|
|
| 28 |
dashboard,
|
| 29 |
dev,
|
| 30 |
documents,
|
|
@@ -40,6 +42,7 @@ from app.routes import (
|
|
| 40 |
pyq_discovery,
|
| 41 |
physics_video,
|
| 42 |
quizzes,
|
|
|
|
| 43 |
sources,
|
| 44 |
study,
|
| 45 |
study_path,
|
|
@@ -103,6 +106,8 @@ _RATE_LIMIT_PREFIXES = (
|
|
| 103 |
"/ask",
|
| 104 |
"/auth/login",
|
| 105 |
"/auth/signup",
|
|
|
|
|
|
|
| 106 |
"/chat",
|
| 107 |
"/generate/",
|
| 108 |
"/intelligence/",
|
|
@@ -722,6 +727,16 @@ app.include_router(
|
|
| 722 |
prefix="/video/physics-curriculum",
|
| 723 |
tags=["Physics Video Curriculum"],
|
| 724 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 725 |
app.include_router(study_profile.router, prefix="/study-profile", tags=["Study Profile"])
|
| 726 |
app.include_router(learning_state.router, prefix="/learning-state", tags=["Learning State"])
|
| 727 |
app.include_router(
|
|
@@ -764,3 +779,14 @@ app.mount(
|
|
| 764 |
StaticFiles(directory=settings.resolved_generated_video_output_dir, check_dir=False),
|
| 765 |
name="generated-videos",
|
| 766 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from collections.abc import AsyncIterator
|
| 2 |
from contextlib import asynccontextmanager
|
| 3 |
+
from pathlib import Path
|
| 4 |
|
| 5 |
import logging
|
| 6 |
import time
|
|
|
|
| 26 |
billing,
|
| 27 |
chat,
|
| 28 |
chat_history,
|
| 29 |
+
chemistry_video,
|
| 30 |
dashboard,
|
| 31 |
dev,
|
| 32 |
documents,
|
|
|
|
| 42 |
pyq_discovery,
|
| 43 |
physics_video,
|
| 44 |
quizzes,
|
| 45 |
+
social_science_video,
|
| 46 |
sources,
|
| 47 |
study,
|
| 48 |
study_path,
|
|
|
|
| 106 |
"/ask",
|
| 107 |
"/auth/login",
|
| 108 |
"/auth/signup",
|
| 109 |
+
"/auth/forgot-password",
|
| 110 |
+
"/auth/reset-password",
|
| 111 |
"/chat",
|
| 112 |
"/generate/",
|
| 113 |
"/intelligence/",
|
|
|
|
| 727 |
prefix="/video/physics-curriculum",
|
| 728 |
tags=["Physics Video Curriculum"],
|
| 729 |
)
|
| 730 |
+
app.include_router(
|
| 731 |
+
chemistry_video.router,
|
| 732 |
+
prefix="/video/chemistry-curriculum",
|
| 733 |
+
tags=["Chemistry Video Curriculum"],
|
| 734 |
+
)
|
| 735 |
+
app.include_router(
|
| 736 |
+
social_science_video.router,
|
| 737 |
+
prefix="/video/social-science-curriculum",
|
| 738 |
+
tags=["Social Science Video Curriculum"],
|
| 739 |
+
)
|
| 740 |
app.include_router(study_profile.router, prefix="/study-profile", tags=["Study Profile"])
|
| 741 |
app.include_router(learning_state.router, prefix="/learning-state", tags=["Learning State"])
|
| 742 |
app.include_router(
|
|
|
|
| 779 |
StaticFiles(directory=settings.resolved_generated_video_output_dir, check_dir=False),
|
| 780 |
name="generated-videos",
|
| 781 |
)
|
| 782 |
+
# Learn Anything lesson cache (manifests + per-beat audio). Must live under a
|
| 783 |
+
# writable path on Hugging Face (/app/generated/...), not monorepo public/.
|
| 784 |
+
_learn_lesson_static = (
|
| 785 |
+
Path(__file__).resolve().parents[1] / "generated" / "learn-anything"
|
| 786 |
+
)
|
| 787 |
+
_learn_lesson_static.mkdir(parents=True, exist_ok=True)
|
| 788 |
+
app.mount(
|
| 789 |
+
"/generated/learn-anything",
|
| 790 |
+
StaticFiles(directory=_learn_lesson_static, check_dir=False),
|
| 791 |
+
name="generated-learn-lessons",
|
| 792 |
+
)
|
app/models/chat_session.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
"""Chat session and message persistence models."""
|
| 2 |
from datetime import datetime
|
| 3 |
|
| 4 |
-
from sqlalchemy import DateTime, ForeignKey, String, Text, func
|
| 5 |
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
| 6 |
|
| 7 |
from app.core.database import Base
|
|
@@ -10,6 +10,9 @@ from app.utils.ids import prefixed_id
|
|
| 10 |
|
| 11 |
class ChatSession(Base):
|
| 12 |
__tablename__ = "chat_sessions"
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
id: Mapped[str] = mapped_column(
|
| 15 |
String(40), primary_key=True, default=lambda: prefixed_id("csess"),
|
|
@@ -20,6 +23,12 @@ class ChatSession(Base):
|
|
| 20 |
source_id: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
|
| 21 |
subject: Mapped[str] = mapped_column(String(120), nullable=False, default="")
|
| 22 |
title: Mapped[str] = mapped_column(String(255), nullable=False, default="New chat")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 23 |
created_at: Mapped[datetime] = mapped_column(
|
| 24 |
DateTime(timezone=True), server_default=func.now(), nullable=False,
|
| 25 |
)
|
|
@@ -34,6 +43,14 @@ class ChatSession(Base):
|
|
| 34 |
|
| 35 |
class ChatMessageRecord(Base):
|
| 36 |
__tablename__ = "chat_messages"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
|
| 38 |
id: Mapped[str] = mapped_column(
|
| 39 |
String(40), primary_key=True, default=lambda: prefixed_id("cmsg"),
|
|
@@ -45,6 +62,10 @@ class ChatMessageRecord(Base):
|
|
| 45 |
content: Mapped[str] = mapped_column(Text, nullable=False)
|
| 46 |
intent: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
| 47 |
evidence_label: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
created_at: Mapped[datetime] = mapped_column(
|
| 49 |
DateTime(timezone=True), server_default=func.now(), nullable=False,
|
| 50 |
)
|
|
|
|
| 1 |
"""Chat session and message persistence models."""
|
| 2 |
from datetime import datetime
|
| 3 |
|
| 4 |
+
from sqlalchemy import JSON, DateTime, ForeignKey, Index, String, Text, UniqueConstraint, func
|
| 5 |
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
| 6 |
|
| 7 |
from app.core.database import Base
|
|
|
|
| 10 |
|
| 11 |
class ChatSession(Base):
|
| 12 |
__tablename__ = "chat_sessions"
|
| 13 |
+
__table_args__ = (
|
| 14 |
+
Index("ix_chat_sessions_user_updated_at", "user_id", "updated_at"),
|
| 15 |
+
)
|
| 16 |
|
| 17 |
id: Mapped[str] = mapped_column(
|
| 18 |
String(40), primary_key=True, default=lambda: prefixed_id("csess"),
|
|
|
|
| 23 |
source_id: Mapped[str | None] = mapped_column(String(40), nullable=True, index=True)
|
| 24 |
subject: Mapped[str] = mapped_column(String(120), nullable=False, default="")
|
| 25 |
title: Mapped[str] = mapped_column(String(255), nullable=False, default="New chat")
|
| 26 |
+
# Structured academic origin (Tuition question, chapter/topic, uploaded
|
| 27 |
+
# source, and safe return route). This keeps continuity independent of the
|
| 28 |
+
# display title or free-form first message.
|
| 29 |
+
context_data: Mapped[dict[str, object]] = mapped_column(
|
| 30 |
+
JSON, nullable=False, default=dict,
|
| 31 |
+
)
|
| 32 |
created_at: Mapped[datetime] = mapped_column(
|
| 33 |
DateTime(timezone=True), server_default=func.now(), nullable=False,
|
| 34 |
)
|
|
|
|
| 43 |
|
| 44 |
class ChatMessageRecord(Base):
|
| 45 |
__tablename__ = "chat_messages"
|
| 46 |
+
__table_args__ = (
|
| 47 |
+
UniqueConstraint(
|
| 48 |
+
"session_id",
|
| 49 |
+
"client_turn_id",
|
| 50 |
+
"role",
|
| 51 |
+
name="uq_chat_messages_session_turn_role",
|
| 52 |
+
),
|
| 53 |
+
)
|
| 54 |
|
| 55 |
id: Mapped[str] = mapped_column(
|
| 56 |
String(40), primary_key=True, default=lambda: prefixed_id("cmsg"),
|
|
|
|
| 62 |
content: Mapped[str] = mapped_column(Text, nullable=False)
|
| 63 |
intent: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
| 64 |
evidence_label: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
| 65 |
+
client_turn_id: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
| 66 |
+
web_sources: Mapped[list[dict[str, object]]] = mapped_column(
|
| 67 |
+
JSON, nullable=False, default=list,
|
| 68 |
+
)
|
| 69 |
created_at: Mapped[datetime] = mapped_column(
|
| 70 |
DateTime(timezone=True), server_default=func.now(), nullable=False,
|
| 71 |
)
|
app/models/learning_state.py
CHANGED
|
@@ -170,10 +170,18 @@ class LessonProgress(Base):
|
|
| 170 |
|
| 171 |
class QuizAttempt(Base):
|
| 172 |
__tablename__ = "quiz_attempts"
|
| 173 |
-
__table_args__ = (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 174 |
|
| 175 |
id: Mapped[str] = mapped_column(String(40), primary_key=True, default=lambda: prefixed_id("qat"))
|
| 176 |
user_id: Mapped[str] = mapped_column(String(40), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False)
|
|
|
|
| 177 |
quiz_id: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
| 178 |
daily_task_id: Mapped[str | None] = mapped_column(String(40), ForeignKey("daily_tasks.id", ondelete="SET NULL"), index=True, nullable=True)
|
| 179 |
subject_id: Mapped[str | None] = mapped_column(String(40), ForeignKey("subjects.id", ondelete="SET NULL"), index=True, nullable=True)
|
|
@@ -298,6 +306,10 @@ class GeneratedResource(Base):
|
|
| 298 |
|
| 299 |
class Subscription(Base):
|
| 300 |
__tablename__ = "subscriptions"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 301 |
|
| 302 |
id: Mapped[str] = mapped_column(String(40), primary_key=True, default=lambda: prefixed_id("subn"))
|
| 303 |
user_id: Mapped[str] = mapped_column(String(40), ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True, nullable=False)
|
|
@@ -306,12 +318,33 @@ class Subscription(Base):
|
|
| 306 |
usage_limits: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
| 307 |
provider_customer_id: Mapped[str | None] = mapped_column(String(180), nullable=True)
|
| 308 |
provider_subscription_id: Mapped[str | None] = mapped_column(String(180), nullable=True)
|
|
|
|
|
|
|
| 309 |
current_period_start: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
| 310 |
current_period_end: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
| 311 |
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
| 312 |
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
| 313 |
|
| 314 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 315 |
class UsageEvent(Base):
|
| 316 |
__tablename__ = "usage_events"
|
| 317 |
__table_args__ = (Index("ix_usage_events_user_type_occurred", "user_id", "event_type", "occurred_at"),)
|
|
|
|
| 170 |
|
| 171 |
class QuizAttempt(Base):
|
| 172 |
__tablename__ = "quiz_attempts"
|
| 173 |
+
__table_args__ = (
|
| 174 |
+
UniqueConstraint(
|
| 175 |
+
"user_id",
|
| 176 |
+
"client_attempt_id",
|
| 177 |
+
name="uq_quiz_attempts_user_client",
|
| 178 |
+
),
|
| 179 |
+
Index("ix_quiz_attempts_user_completed", "user_id", "completed_at"),
|
| 180 |
+
)
|
| 181 |
|
| 182 |
id: Mapped[str] = mapped_column(String(40), primary_key=True, default=lambda: prefixed_id("qat"))
|
| 183 |
user_id: Mapped[str] = mapped_column(String(40), ForeignKey("users.id", ondelete="CASCADE"), index=True, nullable=False)
|
| 184 |
+
client_attempt_id: Mapped[str | None] = mapped_column(String(180), nullable=True)
|
| 185 |
quiz_id: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
| 186 |
daily_task_id: Mapped[str | None] = mapped_column(String(40), ForeignKey("daily_tasks.id", ondelete="SET NULL"), index=True, nullable=True)
|
| 187 |
subject_id: Mapped[str | None] = mapped_column(String(40), ForeignKey("subjects.id", ondelete="SET NULL"), index=True, nullable=True)
|
|
|
|
| 306 |
|
| 307 |
class Subscription(Base):
|
| 308 |
__tablename__ = "subscriptions"
|
| 309 |
+
__table_args__ = (
|
| 310 |
+
Index("ix_subscriptions_provider_customer", "provider_customer_id", unique=True),
|
| 311 |
+
Index("ix_subscriptions_provider_subscription", "provider_subscription_id", unique=True),
|
| 312 |
+
)
|
| 313 |
|
| 314 |
id: Mapped[str] = mapped_column(String(40), primary_key=True, default=lambda: prefixed_id("subn"))
|
| 315 |
user_id: Mapped[str] = mapped_column(String(40), ForeignKey("users.id", ondelete="CASCADE"), unique=True, index=True, nullable=False)
|
|
|
|
| 318 |
usage_limits: Mapped[dict[str, Any]] = mapped_column(JSON, default=dict, nullable=False)
|
| 319 |
provider_customer_id: Mapped[str | None] = mapped_column(String(180), nullable=True)
|
| 320 |
provider_subscription_id: Mapped[str | None] = mapped_column(String(180), nullable=True)
|
| 321 |
+
provider_price_id: Mapped[str | None] = mapped_column(String(180), nullable=True)
|
| 322 |
+
cancel_at_period_end: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
| 323 |
current_period_start: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
| 324 |
current_period_end: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
|
| 325 |
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), nullable=False)
|
| 326 |
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False)
|
| 327 |
|
| 328 |
|
| 329 |
+
class StripeWebhookEvent(Base):
|
| 330 |
+
"""Minimal idempotency ledger for Stripe event delivery.
|
| 331 |
+
|
| 332 |
+
The raw webhook payload is deliberately not stored because it can contain
|
| 333 |
+
customer and payment data. The event ID is enough to prevent duplicate
|
| 334 |
+
entitlement changes while still allowing failed transactions to retry.
|
| 335 |
+
"""
|
| 336 |
+
|
| 337 |
+
__tablename__ = "stripe_webhook_events"
|
| 338 |
+
|
| 339 |
+
event_id: Mapped[str] = mapped_column(String(180), primary_key=True)
|
| 340 |
+
event_type: Mapped[str] = mapped_column(String(100), nullable=False)
|
| 341 |
+
processed_at: Mapped[datetime] = mapped_column(
|
| 342 |
+
DateTime(timezone=True),
|
| 343 |
+
server_default=func.now(),
|
| 344 |
+
nullable=False,
|
| 345 |
+
)
|
| 346 |
+
|
| 347 |
+
|
| 348 |
class UsageEvent(Base):
|
| 349 |
__tablename__ = "usage_events"
|
| 350 |
__table_args__ = (Index("ix_usage_events_user_type_occurred", "user_id", "event_type", "occurred_at"),)
|
{backend/app → app}/models/password_reset_token.py
RENAMED
|
File without changes
|
app/models/user.py
CHANGED
|
@@ -1,6 +1,6 @@
|
|
| 1 |
from datetime import datetime
|
| 2 |
|
| 3 |
-
from sqlalchemy import DateTime, String, func
|
| 4 |
from sqlalchemy.orm import Mapped, mapped_column
|
| 5 |
|
| 6 |
from app.core.database import Base
|
|
@@ -18,6 +18,7 @@ class User(Base):
|
|
| 18 |
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
| 19 |
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
|
| 20 |
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
|
|
|
| 21 |
role: Mapped[str] = mapped_column(String(20), default="student", nullable=False)
|
| 22 |
class_level: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
| 23 |
syllabus: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
|
|
|
| 1 |
from datetime import datetime
|
| 2 |
|
| 3 |
+
from sqlalchemy import DateTime, Integer, String, func
|
| 4 |
from sqlalchemy.orm import Mapped, mapped_column
|
| 5 |
|
| 6 |
from app.core.database import Base
|
|
|
|
| 18 |
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
| 19 |
email: Mapped[str] = mapped_column(String(255), unique=True, index=True, nullable=False)
|
| 20 |
password_hash: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
| 21 |
+
auth_version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
|
| 22 |
role: Mapped[str] = mapped_column(String(20), default="student", nullable=False)
|
| 23 |
class_level: Mapped[str | None] = mapped_column(String(80), nullable=True)
|
| 24 |
syllabus: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
app/routes/ask.py
CHANGED
|
@@ -30,6 +30,7 @@ from app.services.source_guard import get_source_block_message
|
|
| 30 |
from app.services.monthly_usage_service import increment_usage
|
| 31 |
from app.services.usage_service import assert_generation_quota, record_generation
|
| 32 |
from app.services.weak_topic_service import get_user_weak_topics, record_topic_attempt
|
|
|
|
| 33 |
|
| 34 |
# Better profiles for Ask DocDoe (re-uses spirit of context_builder RETRIEVAL_PROFILES)
|
| 35 |
ASK_RETRIEVAL_PROFILES: dict[str, str] = {
|
|
@@ -327,6 +328,9 @@ def ask_docdoe(
|
|
| 327 |
)
|
| 328 |
response_evidence_label = evidence_ctx.evidence_label
|
| 329 |
context = f"{context}\n\n{evidence_ctx.prompt_rules}" if context.strip() else evidence_ctx.prompt_rules
|
|
|
|
|
|
|
|
|
|
| 330 |
|
| 331 |
try:
|
| 332 |
record_topic_attempt(
|
|
@@ -362,6 +366,7 @@ def ask_docdoe(
|
|
| 362 |
"class_level": _grade,
|
| 363 |
"subject": _subject,
|
| 364 |
"exam": (_profile.exam if _profile else None),
|
|
|
|
| 365 |
}
|
| 366 |
|
| 367 |
# enh5: pass weak topics to /ask generators too (so prompt_builder + _build use weak_topic directive)
|
|
@@ -395,6 +400,7 @@ def ask_docdoe(
|
|
| 395 |
"Do not refuse because a source is missing and do not make upload the main next step. "
|
| 396 |
"If the student sounds stressed, give one short reassuring line, then a practical plan. "
|
| 397 |
"Ask for an upload only when the user explicitly wants document-matched or PYQ evidence."
|
|
|
|
| 398 |
),
|
| 399 |
language=payload.language_preference or "English",
|
| 400 |
metadata=metadata,
|
|
|
|
| 30 |
from app.services.monthly_usage_service import increment_usage
|
| 31 |
from app.services.usage_service import assert_generation_quota, record_generation
|
| 32 |
from app.services.weak_topic_service import get_user_weak_topics, record_topic_attempt
|
| 33 |
+
from app.services.workspace_context import workspace_context_directive
|
| 34 |
|
| 35 |
# Better profiles for Ask DocDoe (re-uses spirit of context_builder RETRIEVAL_PROFILES)
|
| 36 |
ASK_RETRIEVAL_PROFILES: dict[str, str] = {
|
|
|
|
| 328 |
)
|
| 329 |
response_evidence_label = evidence_ctx.evidence_label
|
| 330 |
context = f"{context}\n\n{evidence_ctx.prompt_rules}" if context.strip() else evidence_ctx.prompt_rules
|
| 331 |
+
page_context = workspace_context_directive(payload.origin_page)
|
| 332 |
+
if page_context and context.strip():
|
| 333 |
+
context = f"{context}\n\n{page_context}"
|
| 334 |
|
| 335 |
try:
|
| 336 |
record_topic_attempt(
|
|
|
|
| 366 |
"class_level": _grade,
|
| 367 |
"subject": _subject,
|
| 368 |
"exam": (_profile.exam if _profile else None),
|
| 369 |
+
"origin_page": payload.origin_page,
|
| 370 |
}
|
| 371 |
|
| 372 |
# enh5: pass weak topics to /ask generators too (so prompt_builder + _build use weak_topic directive)
|
|
|
|
| 400 |
"Do not refuse because a source is missing and do not make upload the main next step. "
|
| 401 |
"If the student sounds stressed, give one short reassuring line, then a practical plan. "
|
| 402 |
"Ask for an upload only when the user explicitly wants document-matched or PYQ evidence."
|
| 403 |
+
+ (f" {page_context}" if page_context else "")
|
| 404 |
),
|
| 405 |
language=payload.language_preference or "English",
|
| 406 |
metadata=metadata,
|
app/routes/auth.py
CHANGED
|
@@ -1,6 +1,9 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import json
|
|
|
|
|
|
|
|
|
|
| 4 |
import urllib.error
|
| 5 |
import urllib.parse
|
| 6 |
import urllib.request
|
|
@@ -9,21 +12,80 @@ from datetime import datetime, timedelta, timezone
|
|
| 9 |
import jwt
|
| 10 |
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
| 11 |
from fastapi.responses import RedirectResponse
|
| 12 |
-
from sqlalchemy import select
|
| 13 |
from sqlalchemy.orm import Session
|
| 14 |
|
| 15 |
from app.core.auth import create_access_token, hash_password, require_user, verify_password
|
| 16 |
from app.core.config import get_settings
|
| 17 |
from app.core.database import get_db
|
| 18 |
from app.models.user import User
|
| 19 |
-
from app.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
|
| 22 |
router = APIRouter()
|
|
|
|
| 23 |
|
| 24 |
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
|
| 25 |
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
| 26 |
GOOGLE_USERINFO_URL = "https://openidconnect.googleapis.com/v1/userinfo"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
|
| 29 |
def _safe_next_path(next_path: str | None) -> str:
|
|
@@ -189,6 +251,7 @@ def signup(payload: AuthSignupRequest, db: Session = Depends(get_db)) -> AuthTok
|
|
| 189 |
status_code=status.HTTP_400_BAD_REQUEST,
|
| 190 |
detail="A valid email is required.",
|
| 191 |
)
|
|
|
|
| 192 |
|
| 193 |
existing_user = db.scalar(select(User).where(User.email == normalized_email))
|
| 194 |
if existing_user is not None:
|
|
@@ -239,6 +302,259 @@ def login(payload: AuthLoginRequest, db: Session = Depends(get_db)) -> AuthToken
|
|
| 239 |
)
|
| 240 |
|
| 241 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
@router.get("/session", response_model=UserRead)
|
| 243 |
def session(current_user: User = Depends(require_user)) -> User:
|
| 244 |
return current_user
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import json
|
| 4 |
+
import hashlib
|
| 5 |
+
import logging
|
| 6 |
+
import secrets
|
| 7 |
import urllib.error
|
| 8 |
import urllib.parse
|
| 9 |
import urllib.request
|
|
|
|
| 12 |
import jwt
|
| 13 |
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
| 14 |
from fastapi.responses import RedirectResponse
|
| 15 |
+
from sqlalchemy import delete, select, update
|
| 16 |
from sqlalchemy.orm import Session
|
| 17 |
|
| 18 |
from app.core.auth import create_access_token, hash_password, require_user, verify_password
|
| 19 |
from app.core.config import get_settings
|
| 20 |
from app.core.database import get_db
|
| 21 |
from app.models.user import User
|
| 22 |
+
from app.models.password_reset_token import PasswordResetToken
|
| 23 |
+
from app.schemas.user import (
|
| 24 |
+
AuthLoginRequest,
|
| 25 |
+
AuthSignupRequest,
|
| 26 |
+
AuthTokenResponse,
|
| 27 |
+
ForgotPasswordRequest,
|
| 28 |
+
ForgotPasswordResponse,
|
| 29 |
+
LoginOtpRequest,
|
| 30 |
+
LoginOtpRequestResponse,
|
| 31 |
+
LoginOtpVerifyRequest,
|
| 32 |
+
ResetPasswordRequest,
|
| 33 |
+
ResetPasswordResponse,
|
| 34 |
+
UserRead,
|
| 35 |
+
)
|
| 36 |
+
from app.services.email_service import (
|
| 37 |
+
password_email_delivery_configured,
|
| 38 |
+
send_login_otp_email,
|
| 39 |
+
send_password_reset_email,
|
| 40 |
+
)
|
| 41 |
|
| 42 |
|
| 43 |
router = APIRouter()
|
| 44 |
+
logger = logging.getLogger(__name__)
|
| 45 |
|
| 46 |
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
|
| 47 |
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
| 48 |
GOOGLE_USERINFO_URL = "https://openidconnect.googleapis.com/v1/userinfo"
|
| 49 |
+
PASSWORD_RESET_MESSAGE = (
|
| 50 |
+
"If a password account exists for that email, DocDoe will send a recovery link."
|
| 51 |
+
)
|
| 52 |
+
LOGIN_OTP_MESSAGE = (
|
| 53 |
+
"If an account exists for that email, DocDoe will send a 6-digit sign-in code."
|
| 54 |
+
)
|
| 55 |
+
LOGIN_OTP_TTL_MINUTES = 10
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def _validate_password(password: str) -> None:
|
| 59 |
+
if len(password) < 8 or len(password) > 128:
|
| 60 |
+
raise HTTPException(
|
| 61 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 62 |
+
detail="Use a password between 8 and 128 characters.",
|
| 63 |
+
)
|
| 64 |
+
if not any(character.isalpha() for character in password) or not any(
|
| 65 |
+
character.isdigit() for character in password
|
| 66 |
+
):
|
| 67 |
+
raise HTTPException(
|
| 68 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 69 |
+
detail="Use at least one letter and one number in your password.",
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _reset_token_hash(raw_token: str) -> str:
|
| 74 |
+
return hashlib.sha256(raw_token.encode("utf-8")).hexdigest()
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _aware_utc(value: datetime) -> datetime:
|
| 78 |
+
if value.tzinfo is None:
|
| 79 |
+
return value.replace(tzinfo=timezone.utc)
|
| 80 |
+
return value.astimezone(timezone.utc)
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def _password_recovery_available() -> bool:
|
| 84 |
+
settings = get_settings()
|
| 85 |
+
return (
|
| 86 |
+
(settings.auth_provider or "jwt").strip().lower() == "jwt"
|
| 87 |
+
and password_email_delivery_configured()
|
| 88 |
+
)
|
| 89 |
|
| 90 |
|
| 91 |
def _safe_next_path(next_path: str | None) -> str:
|
|
|
|
| 251 |
status_code=status.HTTP_400_BAD_REQUEST,
|
| 252 |
detail="A valid email is required.",
|
| 253 |
)
|
| 254 |
+
_validate_password(payload.password)
|
| 255 |
|
| 256 |
existing_user = db.scalar(select(User).where(User.email == normalized_email))
|
| 257 |
if existing_user is not None:
|
|
|
|
| 302 |
)
|
| 303 |
|
| 304 |
|
| 305 |
+
def _otp_token_hash(user_id: str, code: str) -> str:
|
| 306 |
+
return _reset_token_hash(f"login-otp:{user_id}:{code.strip()}")
|
| 307 |
+
|
| 308 |
+
|
| 309 |
+
@router.post(
|
| 310 |
+
"/login/otp/request",
|
| 311 |
+
response_model=LoginOtpRequestResponse,
|
| 312 |
+
status_code=status.HTTP_202_ACCEPTED,
|
| 313 |
+
)
|
| 314 |
+
def request_login_otp(
|
| 315 |
+
payload: LoginOtpRequest,
|
| 316 |
+
db: Session = Depends(get_db),
|
| 317 |
+
) -> LoginOtpRequestResponse:
|
| 318 |
+
if not _password_recovery_available():
|
| 319 |
+
raise HTTPException(
|
| 320 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 321 |
+
detail="Email sign-in codes are temporarily unavailable.",
|
| 322 |
+
)
|
| 323 |
+
|
| 324 |
+
normalized_email = payload.email.strip().lower()
|
| 325 |
+
user = db.scalar(select(User).where(User.email == normalized_email))
|
| 326 |
+
if user is None or not user.password_hash:
|
| 327 |
+
return LoginOtpRequestResponse(message=LOGIN_OTP_MESSAGE)
|
| 328 |
+
|
| 329 |
+
settings = get_settings()
|
| 330 |
+
now = datetime.now(timezone.utc)
|
| 331 |
+
cooldown_start = now - timedelta(
|
| 332 |
+
seconds=max(settings.password_reset_request_cooldown_seconds, 1)
|
| 333 |
+
)
|
| 334 |
+
recent_token = db.scalar(
|
| 335 |
+
select(PasswordResetToken.id)
|
| 336 |
+
.where(
|
| 337 |
+
PasswordResetToken.user_id == user.id,
|
| 338 |
+
PasswordResetToken.created_at >= cooldown_start,
|
| 339 |
+
PasswordResetToken.used_at.is_(None),
|
| 340 |
+
)
|
| 341 |
+
.limit(1)
|
| 342 |
+
)
|
| 343 |
+
if recent_token is not None:
|
| 344 |
+
return LoginOtpRequestResponse(message=LOGIN_OTP_MESSAGE)
|
| 345 |
+
|
| 346 |
+
db.execute(
|
| 347 |
+
delete(PasswordResetToken).where(
|
| 348 |
+
PasswordResetToken.user_id == user.id,
|
| 349 |
+
PasswordResetToken.expires_at < now,
|
| 350 |
+
)
|
| 351 |
+
)
|
| 352 |
+
|
| 353 |
+
code = f"{secrets.randbelow(1_000_000):06d}"
|
| 354 |
+
otp_row = PasswordResetToken(
|
| 355 |
+
user_id=user.id,
|
| 356 |
+
token_hash=_otp_token_hash(user.id, code),
|
| 357 |
+
expires_at=now + timedelta(minutes=LOGIN_OTP_TTL_MINUTES),
|
| 358 |
+
)
|
| 359 |
+
db.add(otp_row)
|
| 360 |
+
db.commit()
|
| 361 |
+
db.refresh(otp_row)
|
| 362 |
+
|
| 363 |
+
delivered = send_login_otp_email(
|
| 364 |
+
recipient=user.email,
|
| 365 |
+
student_name=user.name,
|
| 366 |
+
code=code,
|
| 367 |
+
expires_minutes=LOGIN_OTP_TTL_MINUTES,
|
| 368 |
+
idempotency_key=f"docdoe-login-otp-{otp_row.id}",
|
| 369 |
+
)
|
| 370 |
+
if not delivered:
|
| 371 |
+
db.execute(delete(PasswordResetToken).where(PasswordResetToken.id == otp_row.id))
|
| 372 |
+
db.commit()
|
| 373 |
+
logger.warning("Login OTP email was not delivered")
|
| 374 |
+
return LoginOtpRequestResponse(message=LOGIN_OTP_MESSAGE)
|
| 375 |
+
|
| 376 |
+
|
| 377 |
+
@router.post("/login/otp/verify", response_model=AuthTokenResponse)
|
| 378 |
+
def verify_login_otp(
|
| 379 |
+
payload: LoginOtpVerifyRequest,
|
| 380 |
+
db: Session = Depends(get_db),
|
| 381 |
+
) -> AuthTokenResponse:
|
| 382 |
+
if (get_settings().auth_provider or "jwt").strip().lower() != "jwt":
|
| 383 |
+
raise HTTPException(
|
| 384 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 385 |
+
detail="Email sign-in codes are unavailable for this sign-in provider.",
|
| 386 |
+
)
|
| 387 |
+
|
| 388 |
+
normalized_email = payload.email.strip().lower()
|
| 389 |
+
code = payload.code.strip()
|
| 390 |
+
if not code.isdigit() or len(code) != 6:
|
| 391 |
+
raise HTTPException(
|
| 392 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 393 |
+
detail="Invalid or expired sign-in code.",
|
| 394 |
+
)
|
| 395 |
+
|
| 396 |
+
user = db.scalar(select(User).where(User.email == normalized_email))
|
| 397 |
+
if user is None or not user.password_hash:
|
| 398 |
+
raise HTTPException(
|
| 399 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 400 |
+
detail="Invalid or expired sign-in code.",
|
| 401 |
+
)
|
| 402 |
+
|
| 403 |
+
now = datetime.now(timezone.utc)
|
| 404 |
+
token_hash = _otp_token_hash(user.id, code)
|
| 405 |
+
otp_row = db.scalar(
|
| 406 |
+
select(PasswordResetToken)
|
| 407 |
+
.where(PasswordResetToken.token_hash == token_hash)
|
| 408 |
+
.with_for_update()
|
| 409 |
+
)
|
| 410 |
+
if (
|
| 411 |
+
otp_row is None
|
| 412 |
+
or otp_row.used_at is not None
|
| 413 |
+
or otp_row.user_id != user.id
|
| 414 |
+
or _aware_utc(otp_row.expires_at) <= now
|
| 415 |
+
):
|
| 416 |
+
raise HTTPException(
|
| 417 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 418 |
+
detail="Invalid or expired sign-in code.",
|
| 419 |
+
)
|
| 420 |
+
|
| 421 |
+
otp_row.used_at = now
|
| 422 |
+
db.commit()
|
| 423 |
+
|
| 424 |
+
access_token, expires_in = create_access_token(user)
|
| 425 |
+
return AuthTokenResponse(
|
| 426 |
+
access_token=access_token,
|
| 427 |
+
expires_in=expires_in,
|
| 428 |
+
user=UserRead.model_validate(user),
|
| 429 |
+
)
|
| 430 |
+
|
| 431 |
+
|
| 432 |
+
@router.post(
|
| 433 |
+
"/forgot-password",
|
| 434 |
+
response_model=ForgotPasswordResponse,
|
| 435 |
+
status_code=status.HTTP_202_ACCEPTED,
|
| 436 |
+
)
|
| 437 |
+
def forgot_password(
|
| 438 |
+
payload: ForgotPasswordRequest,
|
| 439 |
+
db: Session = Depends(get_db),
|
| 440 |
+
) -> ForgotPasswordResponse:
|
| 441 |
+
if not _password_recovery_available():
|
| 442 |
+
raise HTTPException(
|
| 443 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 444 |
+
detail="Password recovery is temporarily unavailable. Try again shortly.",
|
| 445 |
+
)
|
| 446 |
+
|
| 447 |
+
normalized_email = payload.email.strip().lower()
|
| 448 |
+
user = db.scalar(select(User).where(User.email == normalized_email))
|
| 449 |
+
if user is None or not user.password_hash:
|
| 450 |
+
return ForgotPasswordResponse(message=PASSWORD_RESET_MESSAGE)
|
| 451 |
+
|
| 452 |
+
settings = get_settings()
|
| 453 |
+
now = datetime.now(timezone.utc)
|
| 454 |
+
cooldown_start = now - timedelta(
|
| 455 |
+
seconds=max(settings.password_reset_request_cooldown_seconds, 1)
|
| 456 |
+
)
|
| 457 |
+
recent_token = db.scalar(
|
| 458 |
+
select(PasswordResetToken.id)
|
| 459 |
+
.where(
|
| 460 |
+
PasswordResetToken.user_id == user.id,
|
| 461 |
+
PasswordResetToken.created_at >= cooldown_start,
|
| 462 |
+
PasswordResetToken.used_at.is_(None),
|
| 463 |
+
)
|
| 464 |
+
.limit(1)
|
| 465 |
+
)
|
| 466 |
+
if recent_token is not None:
|
| 467 |
+
return ForgotPasswordResponse(message=PASSWORD_RESET_MESSAGE)
|
| 468 |
+
|
| 469 |
+
db.execute(
|
| 470 |
+
delete(PasswordResetToken).where(
|
| 471 |
+
PasswordResetToken.user_id == user.id,
|
| 472 |
+
PasswordResetToken.expires_at < now,
|
| 473 |
+
)
|
| 474 |
+
)
|
| 475 |
+
raw_token = secrets.token_urlsafe(48)
|
| 476 |
+
reset_token = PasswordResetToken(
|
| 477 |
+
user_id=user.id,
|
| 478 |
+
token_hash=_reset_token_hash(raw_token),
|
| 479 |
+
expires_at=now
|
| 480 |
+
+ timedelta(minutes=max(settings.password_reset_token_minutes, 5)),
|
| 481 |
+
)
|
| 482 |
+
db.add(reset_token)
|
| 483 |
+
db.commit()
|
| 484 |
+
db.refresh(reset_token)
|
| 485 |
+
|
| 486 |
+
query = urllib.parse.urlencode({"token": raw_token})
|
| 487 |
+
reset_url = f"{settings.frontend_base_url.rstrip('/')}/reset-password?{query}"
|
| 488 |
+
delivered = send_password_reset_email(
|
| 489 |
+
recipient=user.email,
|
| 490 |
+
student_name=user.name,
|
| 491 |
+
reset_url=reset_url,
|
| 492 |
+
idempotency_key=f"docdoe-password-reset-{reset_token.id}",
|
| 493 |
+
)
|
| 494 |
+
if not delivered:
|
| 495 |
+
db.execute(
|
| 496 |
+
delete(PasswordResetToken).where(PasswordResetToken.id == reset_token.id)
|
| 497 |
+
)
|
| 498 |
+
db.commit()
|
| 499 |
+
logger.warning("Password recovery email was not delivered")
|
| 500 |
+
return ForgotPasswordResponse(message=PASSWORD_RESET_MESSAGE)
|
| 501 |
+
|
| 502 |
+
|
| 503 |
+
@router.post("/reset-password", response_model=ResetPasswordResponse)
|
| 504 |
+
def reset_password(
|
| 505 |
+
payload: ResetPasswordRequest,
|
| 506 |
+
db: Session = Depends(get_db),
|
| 507 |
+
) -> ResetPasswordResponse:
|
| 508 |
+
if (get_settings().auth_provider or "jwt").strip().lower() != "jwt":
|
| 509 |
+
raise HTTPException(
|
| 510 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 511 |
+
detail="Password recovery is unavailable for this sign-in provider.",
|
| 512 |
+
)
|
| 513 |
+
_validate_password(payload.password)
|
| 514 |
+
now = datetime.now(timezone.utc)
|
| 515 |
+
token_hash = _reset_token_hash(payload.token)
|
| 516 |
+
reset_token = db.scalar(
|
| 517 |
+
select(PasswordResetToken)
|
| 518 |
+
.where(PasswordResetToken.token_hash == token_hash)
|
| 519 |
+
.with_for_update()
|
| 520 |
+
)
|
| 521 |
+
if (
|
| 522 |
+
reset_token is None
|
| 523 |
+
or reset_token.used_at is not None
|
| 524 |
+
or _aware_utc(reset_token.expires_at) <= now
|
| 525 |
+
):
|
| 526 |
+
raise HTTPException(
|
| 527 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 528 |
+
detail="This recovery link is invalid or expired. Request a new one.",
|
| 529 |
+
)
|
| 530 |
+
|
| 531 |
+
user = db.get(User, reset_token.user_id)
|
| 532 |
+
if user is None or not user.password_hash:
|
| 533 |
+
raise HTTPException(
|
| 534 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 535 |
+
detail="This recovery link is invalid or expired. Request a new one.",
|
| 536 |
+
)
|
| 537 |
+
if verify_password(payload.password, user.password_hash):
|
| 538 |
+
raise HTTPException(
|
| 539 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 540 |
+
detail="Choose a password you have not already used for this account.",
|
| 541 |
+
)
|
| 542 |
+
|
| 543 |
+
user.password_hash = hash_password(payload.password)
|
| 544 |
+
user.auth_version += 1
|
| 545 |
+
db.add(user)
|
| 546 |
+
db.execute(
|
| 547 |
+
update(PasswordResetToken)
|
| 548 |
+
.where(
|
| 549 |
+
PasswordResetToken.user_id == user.id,
|
| 550 |
+
PasswordResetToken.used_at.is_(None),
|
| 551 |
+
)
|
| 552 |
+
.values(used_at=now)
|
| 553 |
+
)
|
| 554 |
+
db.commit()
|
| 555 |
+
return ResetPasswordResponse(reset=True)
|
| 556 |
+
|
| 557 |
+
|
| 558 |
@router.get("/session", response_model=UserRead)
|
| 559 |
def session(current_user: User = Depends(require_user)) -> User:
|
| 560 |
return current_user
|
app/routes/billing.py
CHANGED
|
@@ -1,67 +1,64 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
import
|
|
|
|
|
|
|
| 4 |
from datetime import datetime, timedelta, timezone
|
|
|
|
| 5 |
|
| 6 |
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
| 7 |
-
from pydantic import BaseModel, ConfigDict
|
| 8 |
-
from sqlalchemy import select
|
|
|
|
| 9 |
from sqlalchemy.orm import Session
|
| 10 |
|
| 11 |
from app.core.auth import require_user
|
| 12 |
from app.core.config import get_settings
|
| 13 |
from app.core.database import get_db
|
|
|
|
| 14 |
from app.models.user import User
|
| 15 |
from app.models.user_plan import UserPlan
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
try:
|
| 18 |
import stripe # type: ignore
|
| 19 |
-
except Exception: # pragma: no cover
|
| 20 |
stripe = None # type: ignore[assignment]
|
| 21 |
|
| 22 |
-
import logging
|
| 23 |
|
| 24 |
logger = logging.getLogger(__name__)
|
| 25 |
-
|
| 26 |
router = APIRouter()
|
| 27 |
|
| 28 |
-
|
| 29 |
-
PLAN_DEFAULTS: dict[str, dict] = {
|
| 30 |
-
"free_trial": {
|
| 31 |
-
"monthly_video_limit": 2,
|
| 32 |
-
"monthly_generation_limit": 20,
|
| 33 |
-
"coming_soon": False,
|
| 34 |
-
},
|
| 35 |
-
"starter_199": {
|
| 36 |
-
"monthly_video_limit": 10,
|
| 37 |
-
"monthly_generation_limit": 100,
|
| 38 |
-
"coming_soon": False,
|
| 39 |
-
},
|
| 40 |
-
"popular_299": {
|
| 41 |
-
"monthly_video_limit": 20,
|
| 42 |
-
"monthly_generation_limit": 200,
|
| 43 |
-
"coming_soon": False,
|
| 44 |
-
},
|
| 45 |
-
"premium_599": {
|
| 46 |
-
"monthly_video_limit": 30,
|
| 47 |
-
"monthly_generation_limit": 300,
|
| 48 |
-
"coming_soon": False,
|
| 49 |
-
},
|
| 50 |
-
"advanced_1299": {
|
| 51 |
-
"monthly_video_limit": 50,
|
| 52 |
-
"monthly_generation_limit": 500,
|
| 53 |
-
"coming_soon": True,
|
| 54 |
-
},
|
| 55 |
-
}
|
| 56 |
-
|
| 57 |
-
# Plan used when a trial is started (equivalent to popular_299).
|
| 58 |
_TRIAL_PLAN_KEY = "popular_299"
|
| 59 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 60 |
|
| 61 |
|
| 62 |
class UserPlanRead(BaseModel):
|
| 63 |
user_id: str
|
| 64 |
selected_plan: str
|
|
|
|
| 65 |
status: str
|
| 66 |
trial_started_at: datetime | None
|
| 67 |
trial_ends_at: datetime | None
|
|
@@ -70,10 +67,14 @@ class UserPlanRead(BaseModel):
|
|
| 70 |
monthly_generation_limit: int
|
| 71 |
monthly_generation_used: int
|
| 72 |
coming_soon: bool = False
|
| 73 |
-
# Derived convenience fields for frontend
|
| 74 |
remaining_generations: int = 0
|
| 75 |
remaining_videos: int = 0
|
| 76 |
usage_warning: str | None = None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
|
| 78 |
model_config = ConfigDict(from_attributes=True)
|
| 79 |
|
|
@@ -82,263 +83,959 @@ class SelectPlanRequest(BaseModel):
|
|
| 82 |
plan: str
|
| 83 |
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
def _get_or_create_plan(db: Session, user_id: str) -> UserPlan:
|
| 86 |
plan = db.scalar(select(UserPlan).where(UserPlan.user_id == user_id))
|
| 87 |
if plan is not None:
|
| 88 |
return plan
|
| 89 |
-
|
|
|
|
| 90 |
plan = UserPlan(
|
| 91 |
user_id=user_id,
|
| 92 |
-
selected_plan=
|
| 93 |
status="active",
|
| 94 |
-
|
| 95 |
-
|
|
|
|
| 96 |
)
|
| 97 |
db.add(plan)
|
| 98 |
-
db.
|
| 99 |
-
db.refresh(plan)
|
| 100 |
return plan
|
| 101 |
|
| 102 |
|
| 103 |
-
def
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
data.usage_warning = (
|
| 115 |
-
f"You
|
| 116 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 117 |
return data
|
| 118 |
|
| 119 |
|
| 120 |
-
|
| 121 |
-
|
| 122 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 123 |
def get_my_plan(
|
| 124 |
db: Session = Depends(get_db),
|
| 125 |
current_user: User = Depends(require_user),
|
| 126 |
) -> UserPlanRead:
|
| 127 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
|
| 130 |
-
@router.post(
|
| 131 |
-
|
| 132 |
-
|
|
|
|
|
|
|
|
|
|
| 133 |
def select_plan(
|
| 134 |
payload: SelectPlanRequest,
|
| 135 |
db: Session = Depends(get_db),
|
| 136 |
current_user: User = Depends(require_user),
|
| 137 |
) -> UserPlanRead:
|
| 138 |
plan_key = payload.plan.strip().lower()
|
| 139 |
-
if plan_key not in
|
| 140 |
raise HTTPException(
|
| 141 |
status_code=status.HTTP_400_BAD_REQUEST,
|
| 142 |
-
detail=f"Unknown plan '{payload.plan}'.
|
| 143 |
)
|
| 144 |
-
if get_settings().environment == "production" and plan_key !=
|
| 145 |
raise HTTPException(
|
| 146 |
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
| 147 |
-
detail=
|
| 148 |
-
"Paid plan activation requires checkout. Direct selection disabled in production. "
|
| 149 |
-
"Use POST /billing/create-checkout-session with the desired plan instead."
|
| 150 |
-
),
|
| 151 |
)
|
|
|
|
|
|
|
| 152 |
plan = _get_or_create_plan(db, current_user.id)
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
db.commit()
|
| 160 |
db.refresh(plan)
|
| 161 |
-
|
|
|
|
|
|
|
|
|
|
| 162 |
|
| 163 |
|
| 164 |
-
@router.post(
|
| 165 |
-
|
| 166 |
-
|
|
|
|
|
|
|
|
|
|
| 167 |
def start_trial(
|
| 168 |
db: Session = Depends(get_db),
|
| 169 |
current_user: User = Depends(require_user),
|
| 170 |
) -> UserPlanRead:
|
| 171 |
plan = _get_or_create_plan(db, current_user.id)
|
|
|
|
| 172 |
if plan.trial_started_at is not None:
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
plan.status = "trialing"
|
| 178 |
-
|
| 179 |
-
plan.
|
| 180 |
-
plan.
|
| 181 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 182 |
db.commit()
|
| 183 |
db.refresh(plan)
|
| 184 |
-
|
|
|
|
| 185 |
|
| 186 |
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
|
| 189 |
-
class CreateCheckoutRequest(BaseModel):
|
| 190 |
-
plan: str
|
| 191 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
|
| 193 |
-
class CheckoutResponse(BaseModel):
|
| 194 |
-
# Primary fields per production contract: {url, session_id}. checkout_url kept for frontend compat during transition.
|
| 195 |
-
url: str | None = None
|
| 196 |
-
session_id: str | None = None
|
| 197 |
-
mode: str = "live" # or "demo" when no Stripe key
|
| 198 |
-
message: str | None = None
|
| 199 |
-
checkout_url: str | None = None # deprecated alias; prefer url
|
| 200 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
|
| 202 |
-
def _get_stripe_client():
|
| 203 |
-
settings = get_settings()
|
| 204 |
-
if not settings.stripe_enabled or stripe is None:
|
| 205 |
-
return None
|
| 206 |
-
stripe.api_key = settings.stripe_secret_key
|
| 207 |
-
return stripe
|
| 208 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 213 |
def create_checkout_session(
|
| 214 |
payload: CreateCheckoutRequest,
|
| 215 |
db: Session = Depends(get_db),
|
| 216 |
current_user: User = Depends(require_user),
|
| 217 |
) -> CheckoutResponse:
|
| 218 |
plan_key = payload.plan.strip().lower()
|
| 219 |
-
if plan_key not in PLAN_DEFAULTS:
|
| 220 |
-
raise HTTPException(status_code=400, detail=f"Unknown plan: {plan_key}")
|
| 221 |
-
|
| 222 |
-
if plan_key == "free_trial":
|
| 223 |
-
raise HTTPException(
|
| 224 |
-
status_code=400,
|
| 225 |
-
detail="Free trial does not require Stripe checkout. Use POST /billing/start-trial (or /select-plan for free).",
|
| 226 |
-
)
|
| 227 |
-
|
| 228 |
-
defaults = PLAN_DEFAULTS[plan_key]
|
| 229 |
if plan_key not in _ACTIVE_PAID_CHECKOUT_PLANS:
|
| 230 |
raise HTTPException(
|
| 231 |
-
status_code=
|
| 232 |
detail="This plan is not available for checkout. Choose the 299 or 599 plan.",
|
| 233 |
)
|
| 234 |
-
if defaults.get("coming_soon"):
|
| 235 |
-
raise HTTPException(status_code=402, detail="This plan is coming soon.")
|
| 236 |
|
| 237 |
settings = get_settings()
|
| 238 |
-
client =
|
| 239 |
-
|
| 240 |
-
if not client:
|
| 241 |
-
# Demo / no-key mode: return a placeholder that frontend can handle
|
| 242 |
-
demo_url = f"/pricing?demo_upgrade={plan_key}"
|
| 243 |
-
return CheckoutResponse(
|
| 244 |
-
url=demo_url,
|
| 245 |
-
checkout_url=demo_url,
|
| 246 |
-
mode="demo",
|
| 247 |
-
message="Stripe not configured (dev/demo). In production this would redirect to real Stripe Checkout.",
|
| 248 |
-
)
|
| 249 |
-
|
| 250 |
price_id = settings.get_stripe_price_id(plan_key)
|
| 251 |
if not price_id:
|
| 252 |
-
|
| 253 |
-
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
| 257 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 258 |
)
|
| 259 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 260 |
try:
|
| 261 |
-
|
| 262 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 263 |
session = client.checkout.Session.create(
|
| 264 |
line_items=[{"price": price_id, "quantity": 1}],
|
| 265 |
mode="subscription",
|
| 266 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
cancel_url=f"{settings.frontend_base_url}/pricing?canceled=1",
|
| 268 |
client_reference_id=current_user.id,
|
| 269 |
-
metadata=
|
|
|
|
|
|
|
|
|
|
| 270 |
)
|
| 271 |
-
|
| 272 |
-
|
| 273 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 274 |
|
| 275 |
|
| 276 |
@router.post("/webhook", include_in_schema=False)
|
| 277 |
async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
|
| 278 |
-
"""
|
| 279 |
-
|
| 280 |
-
(upgrade tier + reset usage counters for the new billing period). Always returns 200
|
| 281 |
-
after signature validation (log errors internally); Stripe requires timely ack.
|
| 282 |
-
"""
|
| 283 |
settings = get_settings()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 284 |
payload = await request.body()
|
| 285 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
|
| 287 |
-
|
| 288 |
-
|
| 289 |
-
|
| 290 |
-
|
| 291 |
-
|
| 292 |
-
|
| 293 |
-
|
| 294 |
-
|
| 295 |
-
|
| 296 |
-
|
| 297 |
-
|
| 298 |
-
|
| 299 |
-
|
| 300 |
-
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
| 306 |
-
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 315 |
-
|
| 316 |
-
|
| 317 |
-
|
| 318 |
-
|
| 319 |
-
|
| 320 |
-
|
| 321 |
-
plan.trial_started_at = None
|
| 322 |
-
plan.trial_ends_at = None
|
| 323 |
-
db.add(plan)
|
| 324 |
-
db.commit()
|
| 325 |
-
logger.info(
|
| 326 |
-
"Stripe billing upgrade applied atomically: user_id=%s plan=%s event=%s",
|
| 327 |
-
user_id, plan_key, event_type,
|
| 328 |
-
)
|
| 329 |
-
except Exception as exc:
|
| 330 |
-
# Log but still ack 200 to Stripe so it does not retry the webhook indefinitely.
|
| 331 |
-
# Processing failures (e.g. DB) should be monitored via logs/alerts.
|
| 332 |
-
logger.exception("Non-fatal error processing Stripe %s (acked 200): %s", event_type, exc)
|
| 333 |
-
|
| 334 |
-
return {"received": True}
|
| 335 |
-
|
| 336 |
-
|
| 337 |
-
@router.get("/success", include_in_schema=False)
|
| 338 |
-
def billing_success(session_id: str | None = None):
|
| 339 |
-
return {"success": True, "message": "Thanks! Your plan should update shortly.", "session_id": session_id}
|
| 340 |
-
|
| 341 |
-
|
| 342 |
-
@router.get("/cancel", include_in_schema=False)
|
| 343 |
-
def billing_cancel():
|
| 344 |
-
return {"success": False, "message": "Checkout canceled. No charges made."}
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
import hashlib
|
| 4 |
+
import logging
|
| 5 |
+
from collections.abc import Mapping
|
| 6 |
from datetime import datetime, timedelta, timezone
|
| 7 |
+
from typing import Any
|
| 8 |
|
| 9 |
from fastapi import APIRouter, Depends, HTTPException, Request, status
|
| 10 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 11 |
+
from sqlalchemy import or_, select
|
| 12 |
+
from sqlalchemy.exc import IntegrityError
|
| 13 |
from sqlalchemy.orm import Session
|
| 14 |
|
| 15 |
from app.core.auth import require_user
|
| 16 |
from app.core.config import get_settings
|
| 17 |
from app.core.database import get_db
|
| 18 |
+
from app.models.learning_state import StripeWebhookEvent, Subscription
|
| 19 |
from app.models.user import User
|
| 20 |
from app.models.user_plan import UserPlan
|
| 21 |
+
from app.services.plan_catalog import (
|
| 22 |
+
get_billing_plan_defaults,
|
| 23 |
+
get_monthly_usage_limits,
|
| 24 |
+
get_plan_config,
|
| 25 |
+
)
|
| 26 |
|
| 27 |
try:
|
| 28 |
import stripe # type: ignore
|
| 29 |
+
except Exception: # pragma: no cover - optional dependency in minimal local installs
|
| 30 |
stripe = None # type: ignore[assignment]
|
| 31 |
|
|
|
|
| 32 |
|
| 33 |
logger = logging.getLogger(__name__)
|
|
|
|
| 34 |
router = APIRouter()
|
| 35 |
|
| 36 |
+
PLAN_DEFAULTS = get_billing_plan_defaults()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
_TRIAL_PLAN_KEY = "popular_299"
|
| 38 |
+
_FREE_PLAN_KEY = "free_trial"
|
| 39 |
+
_ACTIVE_PAID_CHECKOUT_PLANS = ("popular_299", "premium_599")
|
| 40 |
+
_DIRECT_SELECT_PLAN_KEYS = {
|
| 41 |
+
"free_trial",
|
| 42 |
+
"starter_199",
|
| 43 |
+
"popular_299",
|
| 44 |
+
"premium_599",
|
| 45 |
+
"advanced_1299",
|
| 46 |
+
}
|
| 47 |
+
_ACTIVE_PROVIDER_STATUSES = {"active", "trialing", "past_due"}
|
| 48 |
+
_ATTACHED_PROVIDER_STATUSES = _ACTIVE_PROVIDER_STATUSES | {"payment_pending"}
|
| 49 |
+
_CONFIRMED_CHECKOUT_PAYMENT_STATUSES = {"paid", "no_payment_required"}
|
| 50 |
+
_TRIAL_DAYS = 3
|
| 51 |
+
_PLAN_DISPLAY_NAMES = {
|
| 52 |
+
"free_trial": "Free",
|
| 53 |
+
"popular_299": "Popular",
|
| 54 |
+
"premium_599": "Premium",
|
| 55 |
+
}
|
| 56 |
|
| 57 |
|
| 58 |
class UserPlanRead(BaseModel):
|
| 59 |
user_id: str
|
| 60 |
selected_plan: str
|
| 61 |
+
plan_name: str
|
| 62 |
status: str
|
| 63 |
trial_started_at: datetime | None
|
| 64 |
trial_ends_at: datetime | None
|
|
|
|
| 67 |
monthly_generation_limit: int
|
| 68 |
monthly_generation_used: int
|
| 69 |
coming_soon: bool = False
|
|
|
|
| 70 |
remaining_generations: int = 0
|
| 71 |
remaining_videos: int = 0
|
| 72 |
usage_warning: str | None = None
|
| 73 |
+
checkout_plan_keys: list[str] = Field(default_factory=list)
|
| 74 |
+
portal_available: bool = False
|
| 75 |
+
current_period_end: datetime | None = None
|
| 76 |
+
cancel_at_period_end: bool = False
|
| 77 |
+
billing_message: str | None = None
|
| 78 |
|
| 79 |
model_config = ConfigDict(from_attributes=True)
|
| 80 |
|
|
|
|
| 83 |
plan: str
|
| 84 |
|
| 85 |
|
| 86 |
+
class CreateCheckoutRequest(BaseModel):
|
| 87 |
+
plan: str
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
class CheckoutResponse(BaseModel):
|
| 91 |
+
url: str
|
| 92 |
+
session_id: str
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
class PortalResponse(BaseModel):
|
| 96 |
+
url: str
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def _utc_now() -> datetime:
|
| 100 |
+
return datetime.now(timezone.utc)
|
| 101 |
+
|
| 102 |
+
|
| 103 |
+
def _plan_config(plan_key: str):
|
| 104 |
+
config = get_plan_config(plan_key)
|
| 105 |
+
if config is None:
|
| 106 |
+
raise HTTPException(
|
| 107 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 108 |
+
detail=f"Unknown plan '{plan_key}'.",
|
| 109 |
+
)
|
| 110 |
+
return config
|
| 111 |
+
|
| 112 |
+
|
| 113 |
def _get_or_create_plan(db: Session, user_id: str) -> UserPlan:
|
| 114 |
plan = db.scalar(select(UserPlan).where(UserPlan.user_id == user_id))
|
| 115 |
if plan is not None:
|
| 116 |
return plan
|
| 117 |
+
|
| 118 |
+
config = _plan_config(_FREE_PLAN_KEY)
|
| 119 |
plan = UserPlan(
|
| 120 |
user_id=user_id,
|
| 121 |
+
selected_plan=_FREE_PLAN_KEY,
|
| 122 |
status="active",
|
| 123 |
+
period_start=_utc_now(),
|
| 124 |
+
monthly_video_limit=config.monthly_video_limit,
|
| 125 |
+
monthly_generation_limit=config.monthly_generation_limit,
|
| 126 |
)
|
| 127 |
db.add(plan)
|
| 128 |
+
db.flush()
|
|
|
|
| 129 |
return plan
|
| 130 |
|
| 131 |
|
| 132 |
+
def _get_or_create_subscription(
|
| 133 |
+
db: Session,
|
| 134 |
+
user_id: str,
|
| 135 |
+
*,
|
| 136 |
+
plan_key: str = _FREE_PLAN_KEY,
|
| 137 |
+
) -> Subscription:
|
| 138 |
+
subscription = db.scalar(
|
| 139 |
+
select(Subscription).where(Subscription.user_id == user_id)
|
| 140 |
+
)
|
| 141 |
+
if subscription is not None:
|
| 142 |
+
return subscription
|
| 143 |
+
|
| 144 |
+
subscription = Subscription(
|
| 145 |
+
user_id=user_id,
|
| 146 |
+
plan_key=plan_key,
|
| 147 |
+
status="active",
|
| 148 |
+
usage_limits=get_monthly_usage_limits(plan_key),
|
| 149 |
+
)
|
| 150 |
+
db.add(subscription)
|
| 151 |
+
db.flush()
|
| 152 |
+
return subscription
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
def _configured_checkout_plan_keys() -> list[str]:
|
| 156 |
+
settings = get_settings()
|
| 157 |
+
if (
|
| 158 |
+
stripe is None
|
| 159 |
+
or not settings.stripe_secret_key
|
| 160 |
+
or not settings.stripe_webhook_secret
|
| 161 |
+
):
|
| 162 |
+
return []
|
| 163 |
+
return [
|
| 164 |
+
plan_key
|
| 165 |
+
for plan_key in _ACTIVE_PAID_CHECKOUT_PLANS
|
| 166 |
+
if settings.get_stripe_price_id(plan_key)
|
| 167 |
+
]
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
def _plan_read(plan: UserPlan, subscription: Subscription | None) -> UserPlanRead:
|
| 171 |
+
config = get_plan_config(plan.selected_plan) or _plan_config(_FREE_PLAN_KEY)
|
| 172 |
+
effective_status = subscription.status if subscription is not None else plan.status
|
| 173 |
+
data = UserPlanRead(
|
| 174 |
+
user_id=plan.user_id,
|
| 175 |
+
selected_plan=plan.selected_plan,
|
| 176 |
+
plan_name=(
|
| 177 |
+
"Free Trial"
|
| 178 |
+
if effective_status == "trialing"
|
| 179 |
+
and not subscription.provider_subscription_id
|
| 180 |
+
else _PLAN_DISPLAY_NAMES.get(
|
| 181 |
+
plan.selected_plan,
|
| 182 |
+
config.display_name.replace(" (Legacy)", ""),
|
| 183 |
+
)
|
| 184 |
+
),
|
| 185 |
+
status=effective_status,
|
| 186 |
+
trial_started_at=plan.trial_started_at,
|
| 187 |
+
trial_ends_at=plan.trial_ends_at,
|
| 188 |
+
monthly_video_limit=plan.monthly_video_limit,
|
| 189 |
+
monthly_video_used=plan.monthly_video_used,
|
| 190 |
+
monthly_generation_limit=plan.monthly_generation_limit,
|
| 191 |
+
monthly_generation_used=plan.monthly_generation_used,
|
| 192 |
+
coming_soon=config.coming_soon,
|
| 193 |
+
remaining_generations=max(
|
| 194 |
+
0, plan.monthly_generation_limit - plan.monthly_generation_used
|
| 195 |
+
),
|
| 196 |
+
remaining_videos=max(0, plan.monthly_video_limit - plan.monthly_video_used),
|
| 197 |
+
checkout_plan_keys=_configured_checkout_plan_keys(),
|
| 198 |
+
portal_available=bool(
|
| 199 |
+
subscription
|
| 200 |
+
and subscription.provider_customer_id
|
| 201 |
+
and subscription.provider_subscription_id
|
| 202 |
+
and subscription.status in _ATTACHED_PROVIDER_STATUSES
|
| 203 |
+
and stripe is not None
|
| 204 |
+
and get_settings().stripe_secret_key
|
| 205 |
+
),
|
| 206 |
+
current_period_end=subscription.current_period_end if subscription else None,
|
| 207 |
+
cancel_at_period_end=subscription.cancel_at_period_end
|
| 208 |
+
if subscription
|
| 209 |
+
else False,
|
| 210 |
+
)
|
| 211 |
+
|
| 212 |
+
generation_pct = (
|
| 213 |
+
plan.monthly_generation_used / max(1, plan.monthly_generation_limit)
|
| 214 |
+
) * 100
|
| 215 |
+
video_pct = (plan.monthly_video_used / max(1, plan.monthly_video_limit)) * 100
|
| 216 |
+
if generation_pct >= 100 or video_pct >= 100:
|
| 217 |
+
data.usage_warning = "You have reached your current plan limit."
|
| 218 |
+
elif generation_pct >= 80 or video_pct >= 80:
|
| 219 |
data.usage_warning = (
|
| 220 |
+
f"You have used {max(generation_pct, video_pct):.0f}% of your current plan."
|
| 221 |
)
|
| 222 |
+
|
| 223 |
+
if subscription and subscription.status == "payment_pending":
|
| 224 |
+
data.billing_message = "Stripe has not confirmed this payment yet. Your current access is unchanged."
|
| 225 |
+
elif subscription and subscription.status == "past_due":
|
| 226 |
+
data.billing_message = "Your latest payment needs attention. Open billing to update the payment method."
|
| 227 |
+
elif subscription and subscription.cancel_at_period_end:
|
| 228 |
+
data.billing_message = (
|
| 229 |
+
"Your paid access remains active until the end of this period."
|
| 230 |
+
)
|
| 231 |
+
elif not data.checkout_plan_keys:
|
| 232 |
+
data.billing_message = "Paid upgrades are not accepting payments yet. Your current plan is unchanged."
|
| 233 |
return data
|
| 234 |
|
| 235 |
|
| 236 |
+
def _apply_plan_limits(
|
| 237 |
+
plan: UserPlan,
|
| 238 |
+
plan_key: str,
|
| 239 |
+
*,
|
| 240 |
+
status_value: str,
|
| 241 |
+
reset_usage: bool,
|
| 242 |
+
period_start: datetime | None = None,
|
| 243 |
+
) -> None:
|
| 244 |
+
config = _plan_config(plan_key)
|
| 245 |
+
plan.selected_plan = plan_key
|
| 246 |
+
plan.status = status_value
|
| 247 |
+
plan.monthly_video_limit = config.monthly_video_limit
|
| 248 |
+
plan.monthly_generation_limit = config.monthly_generation_limit
|
| 249 |
+
if reset_usage:
|
| 250 |
+
plan.monthly_video_used = 0
|
| 251 |
+
plan.monthly_generation_used = 0
|
| 252 |
+
if period_start is not None:
|
| 253 |
+
plan.period_start = period_start
|
| 254 |
+
|
| 255 |
+
|
| 256 |
+
def _expire_trial_if_needed(plan: UserPlan, subscription: Subscription) -> None:
|
| 257 |
+
if plan.status != "trialing" or plan.trial_ends_at is None:
|
| 258 |
+
return
|
| 259 |
+
trial_end = plan.trial_ends_at
|
| 260 |
+
if trial_end.tzinfo is None:
|
| 261 |
+
trial_end = trial_end.replace(tzinfo=timezone.utc)
|
| 262 |
+
if _utc_now() <= trial_end:
|
| 263 |
+
return
|
| 264 |
+
_apply_plan_limits(
|
| 265 |
+
plan,
|
| 266 |
+
_FREE_PLAN_KEY,
|
| 267 |
+
status_value="trial_expired",
|
| 268 |
+
reset_usage=False,
|
| 269 |
+
period_start=plan.period_start,
|
| 270 |
+
)
|
| 271 |
+
subscription.status = "expired"
|
| 272 |
+
subscription.current_period_end = trial_end
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
def _sync_subscription_projection(
|
| 276 |
+
subscription: Subscription,
|
| 277 |
+
*,
|
| 278 |
+
plan_key: str,
|
| 279 |
+
status_value: str,
|
| 280 |
+
provider_customer_id: str | None = None,
|
| 281 |
+
provider_subscription_id: str | None = None,
|
| 282 |
+
provider_price_id: str | None = None,
|
| 283 |
+
current_period_start: datetime | None = None,
|
| 284 |
+
current_period_end: datetime | None = None,
|
| 285 |
+
cancel_at_period_end: bool | None = None,
|
| 286 |
+
) -> None:
|
| 287 |
+
subscription.plan_key = plan_key
|
| 288 |
+
subscription.status = status_value
|
| 289 |
+
subscription.usage_limits = get_monthly_usage_limits(plan_key)
|
| 290 |
+
if provider_customer_id:
|
| 291 |
+
subscription.provider_customer_id = provider_customer_id
|
| 292 |
+
if provider_subscription_id:
|
| 293 |
+
subscription.provider_subscription_id = provider_subscription_id
|
| 294 |
+
if provider_price_id:
|
| 295 |
+
subscription.provider_price_id = provider_price_id
|
| 296 |
+
if current_period_start is not None:
|
| 297 |
+
subscription.current_period_start = current_period_start
|
| 298 |
+
if current_period_end is not None:
|
| 299 |
+
subscription.current_period_end = current_period_end
|
| 300 |
+
if cancel_at_period_end is not None:
|
| 301 |
+
subscription.cancel_at_period_end = cancel_at_period_end
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
@router.get(
|
| 305 |
+
"/me",
|
| 306 |
+
response_model=UserPlanRead,
|
| 307 |
+
summary="Get my billing plan",
|
| 308 |
+
description="Returns the authenticated student's persisted plan, usage, and Stripe lifecycle state.",
|
| 309 |
+
)
|
| 310 |
def get_my_plan(
|
| 311 |
db: Session = Depends(get_db),
|
| 312 |
current_user: User = Depends(require_user),
|
| 313 |
) -> UserPlanRead:
|
| 314 |
+
plan = _get_or_create_plan(db, current_user.id)
|
| 315 |
+
subscription = _get_or_create_subscription(
|
| 316 |
+
db, current_user.id, plan_key=plan.selected_plan
|
| 317 |
+
)
|
| 318 |
+
_expire_trial_if_needed(plan, subscription)
|
| 319 |
+
db.commit()
|
| 320 |
+
db.refresh(plan)
|
| 321 |
+
db.refresh(subscription)
|
| 322 |
+
return _plan_read(plan, subscription)
|
| 323 |
|
| 324 |
|
| 325 |
+
@router.post(
|
| 326 |
+
"/select-plan",
|
| 327 |
+
response_model=UserPlanRead,
|
| 328 |
+
summary="Select a development/free plan",
|
| 329 |
+
description="Paid activation is always routed through Stripe Checkout in production.",
|
| 330 |
+
)
|
| 331 |
def select_plan(
|
| 332 |
payload: SelectPlanRequest,
|
| 333 |
db: Session = Depends(get_db),
|
| 334 |
current_user: User = Depends(require_user),
|
| 335 |
) -> UserPlanRead:
|
| 336 |
plan_key = payload.plan.strip().lower()
|
| 337 |
+
if plan_key not in _DIRECT_SELECT_PLAN_KEYS:
|
| 338 |
raise HTTPException(
|
| 339 |
status_code=status.HTTP_400_BAD_REQUEST,
|
| 340 |
+
detail=f"Unknown plan '{payload.plan}'.",
|
| 341 |
)
|
| 342 |
+
if get_settings().environment == "production" and plan_key != _FREE_PLAN_KEY:
|
| 343 |
raise HTTPException(
|
| 344 |
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
| 345 |
+
detail="Paid plan activation requires secure checkout.",
|
|
|
|
|
|
|
|
|
|
| 346 |
)
|
| 347 |
+
|
| 348 |
+
config = _plan_config(plan_key)
|
| 349 |
plan = _get_or_create_plan(db, current_user.id)
|
| 350 |
+
subscription = _get_or_create_subscription(db, current_user.id)
|
| 351 |
+
_apply_plan_limits(
|
| 352 |
+
plan,
|
| 353 |
+
plan_key,
|
| 354 |
+
status_value="active",
|
| 355 |
+
reset_usage=False,
|
| 356 |
+
period_start=plan.period_start or _utc_now(),
|
| 357 |
+
)
|
| 358 |
+
_sync_subscription_projection(
|
| 359 |
+
subscription,
|
| 360 |
+
plan_key=plan_key,
|
| 361 |
+
status_value="active",
|
| 362 |
+
)
|
| 363 |
db.commit()
|
| 364 |
db.refresh(plan)
|
| 365 |
+
db.refresh(subscription)
|
| 366 |
+
result = _plan_read(plan, subscription)
|
| 367 |
+
result.coming_soon = config.coming_soon
|
| 368 |
+
return result
|
| 369 |
|
| 370 |
|
| 371 |
+
@router.post(
|
| 372 |
+
"/start-trial",
|
| 373 |
+
response_model=UserPlanRead,
|
| 374 |
+
summary="Start the one-time free trial",
|
| 375 |
+
description="Starts the persisted three-day trial once per authenticated student.",
|
| 376 |
+
)
|
| 377 |
def start_trial(
|
| 378 |
db: Session = Depends(get_db),
|
| 379 |
current_user: User = Depends(require_user),
|
| 380 |
) -> UserPlanRead:
|
| 381 |
plan = _get_or_create_plan(db, current_user.id)
|
| 382 |
+
subscription = _get_or_create_subscription(db, current_user.id)
|
| 383 |
if plan.trial_started_at is not None:
|
| 384 |
+
db.commit()
|
| 385 |
+
return _plan_read(plan, subscription)
|
| 386 |
+
if (
|
| 387 |
+
subscription.provider_subscription_id
|
| 388 |
+
and subscription.status in _ACTIVE_PROVIDER_STATUSES
|
| 389 |
+
):
|
| 390 |
+
raise HTTPException(
|
| 391 |
+
status_code=status.HTTP_409_CONFLICT,
|
| 392 |
+
detail="A paid membership is already attached to this account.",
|
| 393 |
+
)
|
| 394 |
+
|
| 395 |
+
now = _utc_now()
|
| 396 |
+
trial_config = _plan_config(_TRIAL_PLAN_KEY)
|
| 397 |
+
# Persist the entitlement tier itself so every usage service sees the same
|
| 398 |
+
# Popular limits. The trialing status and dates identify that it is unpaid.
|
| 399 |
+
plan.selected_plan = _TRIAL_PLAN_KEY
|
| 400 |
plan.status = "trialing"
|
| 401 |
+
plan.trial_started_at = now
|
| 402 |
+
plan.trial_ends_at = now + timedelta(days=_TRIAL_DAYS)
|
| 403 |
+
plan.period_start = now
|
| 404 |
+
plan.monthly_video_limit = trial_config.monthly_video_limit
|
| 405 |
+
plan.monthly_generation_limit = trial_config.monthly_generation_limit
|
| 406 |
+
plan.monthly_video_used = 0
|
| 407 |
+
plan.monthly_generation_used = 0
|
| 408 |
+
_sync_subscription_projection(
|
| 409 |
+
subscription,
|
| 410 |
+
plan_key=_TRIAL_PLAN_KEY,
|
| 411 |
+
status_value="trialing",
|
| 412 |
+
current_period_start=now,
|
| 413 |
+
current_period_end=plan.trial_ends_at,
|
| 414 |
+
cancel_at_period_end=False,
|
| 415 |
+
)
|
| 416 |
db.commit()
|
| 417 |
db.refresh(plan)
|
| 418 |
+
db.refresh(subscription)
|
| 419 |
+
return _plan_read(plan, subscription)
|
| 420 |
|
| 421 |
|
| 422 |
+
def _stripe_client_or_503(*, require_webhook: bool) -> Any:
|
| 423 |
+
settings = get_settings()
|
| 424 |
+
if stripe is None or not settings.stripe_secret_key:
|
| 425 |
+
raise HTTPException(
|
| 426 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 427 |
+
detail="Secure billing is not available yet. No payment was started.",
|
| 428 |
+
)
|
| 429 |
+
if require_webhook and not settings.stripe_webhook_secret:
|
| 430 |
+
raise HTTPException(
|
| 431 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 432 |
+
detail="Secure billing is not ready to activate memberships. No payment was started.",
|
| 433 |
+
)
|
| 434 |
+
stripe.api_key = settings.stripe_secret_key
|
| 435 |
+
stripe.api_version = settings.stripe_api_version
|
| 436 |
+
return stripe
|
| 437 |
|
|
|
|
|
|
|
| 438 |
|
| 439 |
+
def _object_value(value: Any, key: str, default: Any = None) -> Any:
|
| 440 |
+
if isinstance(value, Mapping):
|
| 441 |
+
return value.get(key, default)
|
| 442 |
+
getter = getattr(value, "get", None)
|
| 443 |
+
if callable(getter):
|
| 444 |
+
return getter(key, default)
|
| 445 |
+
return getattr(value, key, default)
|
| 446 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 447 |
|
| 448 |
+
def _stripe_id(value: Any) -> str | None:
|
| 449 |
+
if isinstance(value, str):
|
| 450 |
+
return value or None
|
| 451 |
+
object_id = _object_value(value, "id")
|
| 452 |
+
return object_id if isinstance(object_id, str) and object_id else None
|
| 453 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 454 |
|
| 455 |
+
def _checkout_idempotency_key(user_id: str, plan_key: str) -> str:
|
| 456 |
+
five_minute_window = int(_utc_now().timestamp()) // 300
|
| 457 |
+
digest = hashlib.sha256(
|
| 458 |
+
f"{user_id}:{plan_key}:{five_minute_window}".encode()
|
| 459 |
+
).hexdigest()[:32]
|
| 460 |
+
return f"docdoe_checkout_{digest}"
|
| 461 |
|
| 462 |
+
|
| 463 |
+
@router.post(
|
| 464 |
+
"/create-checkout-session",
|
| 465 |
+
response_model=CheckoutResponse,
|
| 466 |
+
summary="Create secure Stripe Checkout",
|
| 467 |
+
description="Creates a Stripe-hosted subscription Checkout only when webhook activation is configured.",
|
| 468 |
+
)
|
| 469 |
def create_checkout_session(
|
| 470 |
payload: CreateCheckoutRequest,
|
| 471 |
db: Session = Depends(get_db),
|
| 472 |
current_user: User = Depends(require_user),
|
| 473 |
) -> CheckoutResponse:
|
| 474 |
plan_key = payload.plan.strip().lower()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 475 |
if plan_key not in _ACTIVE_PAID_CHECKOUT_PLANS:
|
| 476 |
raise HTTPException(
|
| 477 |
+
status_code=status.HTTP_402_PAYMENT_REQUIRED,
|
| 478 |
detail="This plan is not available for checkout. Choose the 299 or 599 plan.",
|
| 479 |
)
|
|
|
|
|
|
|
| 480 |
|
| 481 |
settings = get_settings()
|
| 482 |
+
client = _stripe_client_or_503(require_webhook=True)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 483 |
price_id = settings.get_stripe_price_id(plan_key)
|
| 484 |
if not price_id:
|
| 485 |
+
raise HTTPException(
|
| 486 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 487 |
+
detail="This membership is not accepting payments yet. No payment was started.",
|
| 488 |
+
)
|
| 489 |
+
|
| 490 |
+
subscription = _get_or_create_subscription(db, current_user.id)
|
| 491 |
+
if (
|
| 492 |
+
subscription.provider_subscription_id
|
| 493 |
+
and subscription.status in _ATTACHED_PROVIDER_STATUSES
|
| 494 |
+
):
|
| 495 |
+
raise HTTPException(
|
| 496 |
+
status_code=status.HTTP_409_CONFLICT,
|
| 497 |
+
detail="This account already has a Stripe membership. Use Manage billing to change it.",
|
| 498 |
)
|
| 499 |
|
| 500 |
+
metadata = {
|
| 501 |
+
"app": "docdoe",
|
| 502 |
+
"user_id": current_user.id,
|
| 503 |
+
"plan_key": plan_key,
|
| 504 |
+
}
|
| 505 |
try:
|
| 506 |
+
if not subscription.provider_customer_id:
|
| 507 |
+
customer = client.Customer.create(
|
| 508 |
+
email=current_user.email,
|
| 509 |
+
name=current_user.name,
|
| 510 |
+
metadata={"app": "docdoe", "user_id": current_user.id},
|
| 511 |
+
idempotency_key=f"docdoe_customer_{current_user.id}",
|
| 512 |
+
)
|
| 513 |
+
customer_id = _stripe_id(customer)
|
| 514 |
+
if not customer_id:
|
| 515 |
+
raise RuntimeError("Stripe did not return a customer ID")
|
| 516 |
+
subscription.provider_customer_id = customer_id
|
| 517 |
+
db.commit()
|
| 518 |
+
db.refresh(subscription)
|
| 519 |
+
|
| 520 |
session = client.checkout.Session.create(
|
| 521 |
line_items=[{"price": price_id, "quantity": 1}],
|
| 522 |
mode="subscription",
|
| 523 |
+
customer=subscription.provider_customer_id,
|
| 524 |
+
success_url=(
|
| 525 |
+
f"{settings.frontend_base_url}/pricing"
|
| 526 |
+
f"?success=1&session_id={{CHECKOUT_SESSION_ID}}"
|
| 527 |
+
),
|
| 528 |
cancel_url=f"{settings.frontend_base_url}/pricing?canceled=1",
|
| 529 |
client_reference_id=current_user.id,
|
| 530 |
+
metadata=metadata,
|
| 531 |
+
subscription_data={"metadata": metadata},
|
| 532 |
+
integration_identifier=settings.stripe_checkout_integration_identifier,
|
| 533 |
+
idempotency_key=_checkout_idempotency_key(current_user.id, plan_key),
|
| 534 |
)
|
| 535 |
+
except HTTPException:
|
| 536 |
+
raise
|
| 537 |
+
except Exception as exc:
|
| 538 |
+
db.rollback()
|
| 539 |
+
logger.exception(
|
| 540 |
+
"Stripe Checkout creation failed for user_id=%s plan=%s",
|
| 541 |
+
current_user.id,
|
| 542 |
+
plan_key,
|
| 543 |
+
)
|
| 544 |
+
raise HTTPException(
|
| 545 |
+
status_code=status.HTTP_502_BAD_GATEWAY,
|
| 546 |
+
detail="Secure checkout could not be started. No payment was completed.",
|
| 547 |
+
) from exc
|
| 548 |
+
|
| 549 |
+
session_id = _stripe_id(session)
|
| 550 |
+
session_url = _object_value(session, "url")
|
| 551 |
+
if not session_id or not isinstance(session_url, str) or not session_url:
|
| 552 |
+
raise HTTPException(
|
| 553 |
+
status_code=status.HTTP_502_BAD_GATEWAY,
|
| 554 |
+
detail="Stripe did not return a usable checkout session. No payment was completed.",
|
| 555 |
+
)
|
| 556 |
+
return CheckoutResponse(url=session_url, session_id=session_id)
|
| 557 |
+
|
| 558 |
+
|
| 559 |
+
@router.post(
|
| 560 |
+
"/create-portal-session",
|
| 561 |
+
response_model=PortalResponse,
|
| 562 |
+
summary="Open Stripe Billing Portal",
|
| 563 |
+
description="Creates a customer-scoped Stripe Billing Portal session for the authenticated student.",
|
| 564 |
+
)
|
| 565 |
+
def create_portal_session(
|
| 566 |
+
db: Session = Depends(get_db),
|
| 567 |
+
current_user: User = Depends(require_user),
|
| 568 |
+
) -> PortalResponse:
|
| 569 |
+
client = _stripe_client_or_503(require_webhook=False)
|
| 570 |
+
subscription = db.scalar(
|
| 571 |
+
select(Subscription).where(Subscription.user_id == current_user.id)
|
| 572 |
+
)
|
| 573 |
+
if (
|
| 574 |
+
subscription is None
|
| 575 |
+
or not subscription.provider_customer_id
|
| 576 |
+
or not subscription.provider_subscription_id
|
| 577 |
+
):
|
| 578 |
+
raise HTTPException(
|
| 579 |
+
status_code=status.HTTP_409_CONFLICT,
|
| 580 |
+
detail="No Stripe membership is attached to this account.",
|
| 581 |
+
)
|
| 582 |
+
|
| 583 |
+
try:
|
| 584 |
+
portal = client.billing_portal.Session.create(
|
| 585 |
+
customer=subscription.provider_customer_id,
|
| 586 |
+
return_url=f"{get_settings().frontend_base_url}/settings",
|
| 587 |
+
)
|
| 588 |
+
except Exception as exc:
|
| 589 |
+
logger.exception(
|
| 590 |
+
"Stripe Billing Portal creation failed for user_id=%s", current_user.id
|
| 591 |
+
)
|
| 592 |
+
raise HTTPException(
|
| 593 |
+
status_code=status.HTTP_502_BAD_GATEWAY,
|
| 594 |
+
detail="Billing management could not be opened. Try again shortly.",
|
| 595 |
+
) from exc
|
| 596 |
+
|
| 597 |
+
portal_url = _object_value(portal, "url")
|
| 598 |
+
if not isinstance(portal_url, str) or not portal_url:
|
| 599 |
+
raise HTTPException(
|
| 600 |
+
status_code=status.HTTP_502_BAD_GATEWAY,
|
| 601 |
+
detail="Stripe did not return a billing portal URL.",
|
| 602 |
+
)
|
| 603 |
+
return PortalResponse(url=portal_url)
|
| 604 |
+
|
| 605 |
+
|
| 606 |
+
def _timestamp(value: Any) -> datetime | None:
|
| 607 |
+
if isinstance(value, (int, float)):
|
| 608 |
+
return datetime.fromtimestamp(value, tz=timezone.utc)
|
| 609 |
+
return None
|
| 610 |
+
|
| 611 |
+
|
| 612 |
+
def _metadata(data: Any) -> Mapping[str, Any]:
|
| 613 |
+
value = _object_value(data, "metadata", {})
|
| 614 |
+
return value if isinstance(value, Mapping) else {}
|
| 615 |
+
|
| 616 |
+
|
| 617 |
+
def _subscription_id_from_invoice(data: Any) -> str | None:
|
| 618 |
+
direct = _stripe_id(_object_value(data, "subscription"))
|
| 619 |
+
if direct:
|
| 620 |
+
return direct
|
| 621 |
+
parent = _object_value(data, "parent", {})
|
| 622 |
+
details = _object_value(parent, "subscription_details", {})
|
| 623 |
+
return _stripe_id(_object_value(details, "subscription"))
|
| 624 |
+
|
| 625 |
+
|
| 626 |
+
def _invoice_period(data: Any) -> tuple[datetime | None, datetime | None]:
|
| 627 |
+
period_start = _timestamp(_object_value(data, "period_start"))
|
| 628 |
+
period_end = _timestamp(_object_value(data, "period_end"))
|
| 629 |
+
if period_start or period_end:
|
| 630 |
+
return period_start, period_end
|
| 631 |
+
|
| 632 |
+
lines = _object_value(data, "lines", {})
|
| 633 |
+
line_items = _object_value(lines, "data", [])
|
| 634 |
+
if isinstance(line_items, list) and line_items:
|
| 635 |
+
period = _object_value(line_items[0], "period", {})
|
| 636 |
+
return (
|
| 637 |
+
_timestamp(_object_value(period, "start")),
|
| 638 |
+
_timestamp(_object_value(period, "end")),
|
| 639 |
+
)
|
| 640 |
+
return None, None
|
| 641 |
+
|
| 642 |
+
|
| 643 |
+
def _find_subscription_for_provider_event(
|
| 644 |
+
db: Session,
|
| 645 |
+
data: Any,
|
| 646 |
+
) -> Subscription | None:
|
| 647 |
+
subscription_id = _stripe_id(_object_value(data, "id"))
|
| 648 |
+
object_name = _object_value(data, "object")
|
| 649 |
+
if object_name == "invoice":
|
| 650 |
+
subscription_id = _subscription_id_from_invoice(data)
|
| 651 |
+
customer_id = _stripe_id(_object_value(data, "customer"))
|
| 652 |
+
conditions = []
|
| 653 |
+
if subscription_id:
|
| 654 |
+
conditions.append(Subscription.provider_subscription_id == subscription_id)
|
| 655 |
+
if customer_id:
|
| 656 |
+
conditions.append(Subscription.provider_customer_id == customer_id)
|
| 657 |
+
if not conditions:
|
| 658 |
+
return None
|
| 659 |
+
return db.scalar(select(Subscription).where(or_(*conditions)))
|
| 660 |
+
|
| 661 |
+
|
| 662 |
+
def _plan_key_from_price_id(price_id: str | None) -> str | None:
|
| 663 |
+
if not price_id:
|
| 664 |
+
return None
|
| 665 |
+
settings = get_settings()
|
| 666 |
+
for plan_key in _ACTIVE_PAID_CHECKOUT_PLANS:
|
| 667 |
+
if settings.get_stripe_price_id(plan_key) == price_id:
|
| 668 |
+
return plan_key
|
| 669 |
+
return None
|
| 670 |
+
|
| 671 |
+
|
| 672 |
+
def _price_id_from_subscription(data: Any) -> str | None:
|
| 673 |
+
items = _object_value(data, "items", {})
|
| 674 |
+
item_data = _object_value(items, "data", [])
|
| 675 |
+
if not isinstance(item_data, list) or not item_data:
|
| 676 |
+
return None
|
| 677 |
+
price = _object_value(item_data[0], "price")
|
| 678 |
+
return _stripe_id(price)
|
| 679 |
+
|
| 680 |
+
|
| 681 |
+
def _provider_status(value: Any) -> str:
|
| 682 |
+
raw = str(value or "").lower()
|
| 683 |
+
if raw == "canceled":
|
| 684 |
+
return "cancelled"
|
| 685 |
+
if raw in {"active", "trialing", "past_due"}:
|
| 686 |
+
return raw
|
| 687 |
+
if raw in {"unpaid", "paused", "incomplete_expired"}:
|
| 688 |
+
return "expired"
|
| 689 |
+
if raw == "incomplete":
|
| 690 |
+
return "past_due"
|
| 691 |
+
return "active"
|
| 692 |
+
|
| 693 |
+
|
| 694 |
+
def _assert_provider_ownership(
|
| 695 |
+
subscription: Subscription,
|
| 696 |
+
*,
|
| 697 |
+
customer_id: str | None,
|
| 698 |
+
provider_subscription_id: str | None,
|
| 699 |
+
) -> None:
|
| 700 |
+
"""Reject provider identifiers already bound to a different local record."""
|
| 701 |
+
|
| 702 |
+
if (
|
| 703 |
+
customer_id
|
| 704 |
+
and subscription.provider_customer_id
|
| 705 |
+
and subscription.provider_customer_id != customer_id
|
| 706 |
+
):
|
| 707 |
+
raise RuntimeError("Stripe customer does not match the account billing record")
|
| 708 |
+
if (
|
| 709 |
+
provider_subscription_id
|
| 710 |
+
and subscription.provider_subscription_id
|
| 711 |
+
and subscription.provider_subscription_id != provider_subscription_id
|
| 712 |
+
and subscription.status not in {"cancelled", "expired"}
|
| 713 |
+
):
|
| 714 |
+
raise RuntimeError(
|
| 715 |
+
"Stripe subscription does not match the account billing record"
|
| 716 |
+
)
|
| 717 |
+
|
| 718 |
+
|
| 719 |
+
def _bind_pending_checkout(
|
| 720 |
+
subscription: Subscription,
|
| 721 |
+
*,
|
| 722 |
+
plan_key: str,
|
| 723 |
+
customer_id: str,
|
| 724 |
+
provider_subscription_id: str,
|
| 725 |
+
) -> None:
|
| 726 |
+
_assert_provider_ownership(
|
| 727 |
+
subscription,
|
| 728 |
+
customer_id=customer_id,
|
| 729 |
+
provider_subscription_id=provider_subscription_id,
|
| 730 |
+
)
|
| 731 |
+
_sync_subscription_projection(
|
| 732 |
+
subscription,
|
| 733 |
+
plan_key=plan_key,
|
| 734 |
+
status_value="payment_pending",
|
| 735 |
+
provider_customer_id=customer_id,
|
| 736 |
+
provider_subscription_id=provider_subscription_id,
|
| 737 |
+
provider_price_id=get_settings().get_stripe_price_id(plan_key),
|
| 738 |
+
cancel_at_period_end=False,
|
| 739 |
+
)
|
| 740 |
+
|
| 741 |
+
|
| 742 |
+
def _downgrade_to_free(
|
| 743 |
+
plan: UserPlan,
|
| 744 |
+
subscription: Subscription,
|
| 745 |
+
*,
|
| 746 |
+
status_value: str,
|
| 747 |
+
) -> None:
|
| 748 |
+
now = _utc_now()
|
| 749 |
+
_apply_plan_limits(
|
| 750 |
+
plan,
|
| 751 |
+
_FREE_PLAN_KEY,
|
| 752 |
+
status_value=status_value,
|
| 753 |
+
reset_usage=True,
|
| 754 |
+
period_start=now,
|
| 755 |
+
)
|
| 756 |
+
subscription.status = status_value
|
| 757 |
+
subscription.cancel_at_period_end = False
|
| 758 |
+
subscription.current_period_end = now
|
| 759 |
+
|
| 760 |
+
|
| 761 |
+
def _process_checkout_completed(db: Session, data: Any) -> None:
|
| 762 |
+
metadata = _metadata(data)
|
| 763 |
+
if metadata.get("app") != "docdoe":
|
| 764 |
+
return
|
| 765 |
+
user_id = _object_value(data, "client_reference_id") or metadata.get("user_id")
|
| 766 |
+
plan_key = metadata.get("plan_key")
|
| 767 |
+
if not isinstance(user_id, str) or plan_key not in _ACTIVE_PAID_CHECKOUT_PLANS:
|
| 768 |
+
raise RuntimeError("DocDoe Checkout event is missing valid ownership metadata")
|
| 769 |
+
|
| 770 |
+
customer_id = _stripe_id(_object_value(data, "customer"))
|
| 771 |
+
provider_subscription_id = _stripe_id(_object_value(data, "subscription"))
|
| 772 |
+
if not customer_id or not provider_subscription_id:
|
| 773 |
+
raise RuntimeError("DocDoe Checkout event is missing provider identifiers")
|
| 774 |
+
|
| 775 |
+
now = _utc_now()
|
| 776 |
+
plan = _get_or_create_plan(db, user_id)
|
| 777 |
+
subscription = _get_or_create_subscription(db, user_id)
|
| 778 |
+
_assert_provider_ownership(
|
| 779 |
+
subscription,
|
| 780 |
+
customer_id=customer_id,
|
| 781 |
+
provider_subscription_id=provider_subscription_id,
|
| 782 |
+
)
|
| 783 |
+
payment_status = str(_object_value(data, "payment_status", "")).lower()
|
| 784 |
+
if payment_status not in _CONFIRMED_CHECKOUT_PAYMENT_STATUSES:
|
| 785 |
+
_bind_pending_checkout(
|
| 786 |
+
subscription,
|
| 787 |
+
plan_key=plan_key,
|
| 788 |
+
customer_id=customer_id,
|
| 789 |
+
provider_subscription_id=provider_subscription_id,
|
| 790 |
+
)
|
| 791 |
+
return
|
| 792 |
+
|
| 793 |
+
_apply_plan_limits(
|
| 794 |
+
plan,
|
| 795 |
+
plan_key,
|
| 796 |
+
status_value="active",
|
| 797 |
+
reset_usage=True,
|
| 798 |
+
period_start=now,
|
| 799 |
+
)
|
| 800 |
+
plan.trial_ends_at = None
|
| 801 |
+
_sync_subscription_projection(
|
| 802 |
+
subscription,
|
| 803 |
+
plan_key=plan_key,
|
| 804 |
+
status_value="active",
|
| 805 |
+
provider_customer_id=customer_id,
|
| 806 |
+
provider_subscription_id=provider_subscription_id,
|
| 807 |
+
provider_price_id=get_settings().get_stripe_price_id(plan_key),
|
| 808 |
+
current_period_start=now,
|
| 809 |
+
cancel_at_period_end=False,
|
| 810 |
+
)
|
| 811 |
+
|
| 812 |
+
|
| 813 |
+
def _process_subscription_change(
|
| 814 |
+
db: Session,
|
| 815 |
+
data: Any,
|
| 816 |
+
*,
|
| 817 |
+
deleted: bool,
|
| 818 |
+
) -> None:
|
| 819 |
+
metadata = _metadata(data)
|
| 820 |
+
subscription = _find_subscription_for_provider_event(db, data)
|
| 821 |
+
if subscription is None:
|
| 822 |
+
if metadata.get("app") != "docdoe":
|
| 823 |
+
return
|
| 824 |
+
user_id = metadata.get("user_id")
|
| 825 |
+
if not isinstance(user_id, str):
|
| 826 |
+
raise RuntimeError(
|
| 827 |
+
"DocDoe subscription event is missing ownership metadata"
|
| 828 |
+
)
|
| 829 |
+
subscription = _get_or_create_subscription(db, user_id)
|
| 830 |
+
|
| 831 |
+
provider_subscription_id = _stripe_id(_object_value(data, "id"))
|
| 832 |
+
customer_id = _stripe_id(_object_value(data, "customer"))
|
| 833 |
+
_assert_provider_ownership(
|
| 834 |
+
subscription,
|
| 835 |
+
customer_id=customer_id,
|
| 836 |
+
provider_subscription_id=provider_subscription_id,
|
| 837 |
+
)
|
| 838 |
+
price_id = _price_id_from_subscription(data)
|
| 839 |
+
plan_key = (
|
| 840 |
+
metadata.get("plan_key")
|
| 841 |
+
or _plan_key_from_price_id(price_id)
|
| 842 |
+
or subscription.plan_key
|
| 843 |
+
)
|
| 844 |
+
if plan_key not in _ACTIVE_PAID_CHECKOUT_PLANS:
|
| 845 |
+
if metadata.get("app") == "docdoe":
|
| 846 |
+
raise RuntimeError("DocDoe subscription event has an unknown price/plan")
|
| 847 |
+
return
|
| 848 |
+
|
| 849 |
+
status_value = (
|
| 850 |
+
"cancelled" if deleted else _provider_status(_object_value(data, "status"))
|
| 851 |
+
)
|
| 852 |
+
period_start = _timestamp(_object_value(data, "current_period_start"))
|
| 853 |
+
period_end = _timestamp(_object_value(data, "current_period_end"))
|
| 854 |
+
cancel_at_period_end = bool(_object_value(data, "cancel_at_period_end", False))
|
| 855 |
+
plan = _get_or_create_plan(db, subscription.user_id)
|
| 856 |
+
|
| 857 |
+
if status_value in {"cancelled", "expired"}:
|
| 858 |
+
_downgrade_to_free(plan, subscription, status_value=status_value)
|
| 859 |
+
else:
|
| 860 |
+
entitlement_already_active = (
|
| 861 |
+
plan.selected_plan == plan_key
|
| 862 |
+
and plan.status in _ACTIVE_PROVIDER_STATUSES
|
| 863 |
+
and subscription.status != "payment_pending"
|
| 864 |
+
)
|
| 865 |
+
if status_value in {"active", "trialing"} and not entitlement_already_active:
|
| 866 |
+
if not customer_id or not provider_subscription_id:
|
| 867 |
+
raise RuntimeError(
|
| 868 |
+
"Stripe subscription event is missing provider identifiers"
|
| 869 |
+
)
|
| 870 |
+
_bind_pending_checkout(
|
| 871 |
+
subscription,
|
| 872 |
+
plan_key=plan_key,
|
| 873 |
+
customer_id=customer_id,
|
| 874 |
+
provider_subscription_id=provider_subscription_id,
|
| 875 |
+
)
|
| 876 |
+
if period_start is not None:
|
| 877 |
+
subscription.current_period_start = period_start
|
| 878 |
+
if period_end is not None:
|
| 879 |
+
subscription.current_period_end = period_end
|
| 880 |
+
return
|
| 881 |
+
|
| 882 |
+
previous_period = subscription.current_period_start
|
| 883 |
+
reset_usage = bool(
|
| 884 |
+
period_start
|
| 885 |
+
and (
|
| 886 |
+
previous_period is None
|
| 887 |
+
or period_start
|
| 888 |
+
> (
|
| 889 |
+
previous_period.replace(tzinfo=timezone.utc)
|
| 890 |
+
if previous_period.tzinfo is None
|
| 891 |
+
else previous_period
|
| 892 |
+
)
|
| 893 |
+
)
|
| 894 |
+
)
|
| 895 |
+
_apply_plan_limits(
|
| 896 |
+
plan,
|
| 897 |
+
plan_key,
|
| 898 |
+
status_value=status_value,
|
| 899 |
+
reset_usage=reset_usage,
|
| 900 |
+
period_start=period_start or plan.period_start or _utc_now(),
|
| 901 |
+
)
|
| 902 |
+
_sync_subscription_projection(
|
| 903 |
+
subscription,
|
| 904 |
+
plan_key=plan_key,
|
| 905 |
+
status_value=status_value,
|
| 906 |
+
provider_customer_id=customer_id,
|
| 907 |
+
provider_subscription_id=provider_subscription_id,
|
| 908 |
+
provider_price_id=price_id,
|
| 909 |
+
current_period_start=period_start,
|
| 910 |
+
current_period_end=period_end,
|
| 911 |
+
cancel_at_period_end=cancel_at_period_end,
|
| 912 |
+
)
|
| 913 |
+
|
| 914 |
+
|
| 915 |
+
def _process_invoice_event(db: Session, data: Any, *, paid: bool) -> None:
|
| 916 |
+
subscription = _find_subscription_for_provider_event(db, data)
|
| 917 |
+
if subscription is None:
|
| 918 |
+
return
|
| 919 |
+
_assert_provider_ownership(
|
| 920 |
+
subscription,
|
| 921 |
+
customer_id=_stripe_id(_object_value(data, "customer")),
|
| 922 |
+
provider_subscription_id=_subscription_id_from_invoice(data),
|
| 923 |
+
)
|
| 924 |
+
plan = _get_or_create_plan(db, subscription.user_id)
|
| 925 |
+
if not paid:
|
| 926 |
+
subscription.status = "past_due"
|
| 927 |
+
plan.status = "past_due"
|
| 928 |
+
return
|
| 929 |
+
|
| 930 |
+
period_start, period_end = _invoice_period(data)
|
| 931 |
+
previous_period = subscription.current_period_start
|
| 932 |
+
advanced_period = bool(
|
| 933 |
+
period_start
|
| 934 |
+
and (
|
| 935 |
+
previous_period is None
|
| 936 |
+
or period_start
|
| 937 |
+
> (
|
| 938 |
+
previous_period.replace(tzinfo=timezone.utc)
|
| 939 |
+
if previous_period.tzinfo is None
|
| 940 |
+
else previous_period
|
| 941 |
+
)
|
| 942 |
+
)
|
| 943 |
+
)
|
| 944 |
+
plan_key = subscription.plan_key
|
| 945 |
+
if plan_key not in _ACTIVE_PAID_CHECKOUT_PLANS:
|
| 946 |
+
return
|
| 947 |
+
_apply_plan_limits(
|
| 948 |
+
plan,
|
| 949 |
+
plan_key,
|
| 950 |
+
status_value="active",
|
| 951 |
+
reset_usage=advanced_period,
|
| 952 |
+
period_start=period_start or plan.period_start or _utc_now(),
|
| 953 |
+
)
|
| 954 |
+
subscription.status = "active"
|
| 955 |
+
if period_start is not None:
|
| 956 |
+
subscription.current_period_start = period_start
|
| 957 |
+
if period_end is not None:
|
| 958 |
+
subscription.current_period_end = period_end
|
| 959 |
+
|
| 960 |
+
|
| 961 |
+
def _process_stripe_event(db: Session, event_type: str, data: Any) -> None:
|
| 962 |
+
if event_type == "checkout.session.completed":
|
| 963 |
+
_process_checkout_completed(db, data)
|
| 964 |
+
elif event_type in {
|
| 965 |
+
"customer.subscription.created",
|
| 966 |
+
"customer.subscription.updated",
|
| 967 |
+
}:
|
| 968 |
+
_process_subscription_change(db, data, deleted=False)
|
| 969 |
+
elif event_type == "customer.subscription.deleted":
|
| 970 |
+
_process_subscription_change(db, data, deleted=True)
|
| 971 |
+
elif event_type == "invoice.paid":
|
| 972 |
+
_process_invoice_event(db, data, paid=True)
|
| 973 |
+
elif event_type == "invoice.payment_failed":
|
| 974 |
+
_process_invoice_event(db, data, paid=False)
|
| 975 |
|
| 976 |
|
| 977 |
@router.post("/webhook", include_in_schema=False)
|
| 978 |
async def stripe_webhook(request: Request, db: Session = Depends(get_db)):
|
| 979 |
+
"""Verify and atomically project Stripe events into DocDoe entitlements."""
|
| 980 |
+
|
|
|
|
|
|
|
|
|
|
| 981 |
settings = get_settings()
|
| 982 |
+
if stripe is None or not settings.stripe_webhook_secret:
|
| 983 |
+
raise HTTPException(
|
| 984 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 985 |
+
detail="Stripe webhook processing is not configured.",
|
| 986 |
+
)
|
| 987 |
+
|
| 988 |
payload = await request.body()
|
| 989 |
+
signature = request.headers.get("stripe-signature")
|
| 990 |
+
if not signature:
|
| 991 |
+
raise HTTPException(
|
| 992 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 993 |
+
detail="Missing Stripe signature.",
|
| 994 |
+
)
|
| 995 |
+
try:
|
| 996 |
+
event = stripe.Webhook.construct_event(
|
| 997 |
+
payload,
|
| 998 |
+
signature,
|
| 999 |
+
settings.stripe_webhook_secret,
|
| 1000 |
+
)
|
| 1001 |
+
except Exception as exc:
|
| 1002 |
+
logger.warning("Rejected Stripe webhook with an invalid signature")
|
| 1003 |
+
raise HTTPException(
|
| 1004 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 1005 |
+
detail="Invalid Stripe signature.",
|
| 1006 |
+
) from exc
|
| 1007 |
|
| 1008 |
+
event_id = _object_value(event, "id")
|
| 1009 |
+
event_type = _object_value(event, "type")
|
| 1010 |
+
event_data = _object_value(_object_value(event, "data", {}), "object", {})
|
| 1011 |
+
if not isinstance(event_id, str) or not event_id:
|
| 1012 |
+
raise HTTPException(status_code=400, detail="Stripe event ID is missing.")
|
| 1013 |
+
if not isinstance(event_type, str) or not event_type:
|
| 1014 |
+
raise HTTPException(status_code=400, detail="Stripe event type is missing.")
|
| 1015 |
+
|
| 1016 |
+
if db.get(StripeWebhookEvent, event_id) is not None:
|
| 1017 |
+
return {"received": True, "duplicate": True}
|
| 1018 |
+
|
| 1019 |
+
db.add(StripeWebhookEvent(event_id=event_id, event_type=event_type))
|
| 1020 |
+
try:
|
| 1021 |
+
db.flush()
|
| 1022 |
+
except IntegrityError:
|
| 1023 |
+
db.rollback()
|
| 1024 |
+
return {"received": True, "duplicate": True}
|
| 1025 |
+
|
| 1026 |
+
try:
|
| 1027 |
+
_process_stripe_event(db, event_type, event_data)
|
| 1028 |
+
db.commit()
|
| 1029 |
+
except Exception as exc:
|
| 1030 |
+
db.rollback()
|
| 1031 |
+
logger.exception(
|
| 1032 |
+
"Stripe webhook processing failed; event will be retried: event_id=%s type=%s",
|
| 1033 |
+
event_id,
|
| 1034 |
+
event_type,
|
| 1035 |
+
)
|
| 1036 |
+
raise HTTPException(
|
| 1037 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 1038 |
+
detail="Stripe event could not be applied.",
|
| 1039 |
+
) from exc
|
| 1040 |
+
|
| 1041 |
+
return {"received": True, "duplicate": False}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
app/routes/chat.py
CHANGED
|
@@ -46,6 +46,7 @@ from app.services.syllabus_teacher import (
|
|
| 46 |
from app.services.monthly_usage_service import increment_usage
|
| 47 |
from app.services.usage_service import record_generation
|
| 48 |
from app.services.exa_search import ExaSearchUnavailable, search_exa_web
|
|
|
|
| 49 |
|
| 50 |
|
| 51 |
router = APIRouter()
|
|
@@ -59,7 +60,12 @@ def _strip_think(text: str) -> str:
|
|
| 59 |
return _THINK_RE.sub("", text).strip()
|
| 60 |
|
| 61 |
|
| 62 |
-
def _mock_chat_answer(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
"""Development-only answer for mock AI mode."""
|
| 64 |
topic = message.strip().split("\n")[-1].replace("Student's question:", "").strip() or "your topic"
|
| 65 |
if intent == "cram_plan":
|
|
@@ -84,7 +90,12 @@ def _mock_chat_answer(intent: str, message: str) -> str:
|
|
| 84 |
teaching_steps_for,
|
| 85 |
)
|
| 86 |
|
| 87 |
-
ctx = infer_syllabus_context(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 88 |
steps = teaching_steps_for(ctx)[:8]
|
| 89 |
mistakes = common_mistakes_for(ctx)[:3]
|
| 90 |
return (
|
|
@@ -132,12 +143,34 @@ _PYQ_TOKENS = frozenset({
|
|
| 132 |
"last year question", "previous paper", "model question",
|
| 133 |
"2022", "2023", "2024", "2025",
|
| 134 |
})
|
| 135 |
-
|
| 136 |
-
"search
|
| 137 |
-
"
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
_NOTES_TOKENS = frozenset({
|
| 142 |
"notes", "write notes", "study notes", "key points",
|
| 143 |
"summarize", "summary", "bullet points", "revision notes",
|
|
@@ -155,6 +188,34 @@ _CRAM_PLAN_TOKENS = frozenset({
|
|
| 155 |
})
|
| 156 |
|
| 157 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
def _detect_intent(message: str, has_sources: bool) -> str:
|
| 159 |
lower = message.lower().strip().rstrip("!?.")
|
| 160 |
|
|
@@ -167,7 +228,7 @@ def _detect_intent(message: str, has_sources: bool) -> str:
|
|
| 167 |
if re.match(r"^(hi+|hey+|hello+|yo+|bro)\b[\s,!.]*(my name is|i'?m|i am)\b", lower):
|
| 168 |
return "casual"
|
| 169 |
|
| 170 |
-
if
|
| 171 |
return "current_info"
|
| 172 |
|
| 173 |
if any(tok in lower for tok in _CRAM_PLAN_TOKENS) or re.search(r"\blearn\b.*\bnight\b", lower):
|
|
@@ -526,6 +587,7 @@ def _call_ai_chat_sync(
|
|
| 526 |
intent: str = "study_explain",
|
| 527 |
max_tokens: int = 700,
|
| 528 |
max_retries: int = 1,
|
|
|
|
| 529 |
) -> tuple[str, str]:
|
| 530 |
"""Call the configured OpenAI-compatible chat provider in a worker thread.
|
| 531 |
|
|
@@ -536,7 +598,14 @@ def _call_ai_chat_sync(
|
|
| 536 |
settings = get_settings()
|
| 537 |
|
| 538 |
if str(settings.ai_provider).strip().lower() == "mock":
|
| 539 |
-
return (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 540 |
|
| 541 |
try:
|
| 542 |
from openai import APIConnectionError, APIStatusError, APITimeoutError, OpenAI
|
|
@@ -549,7 +618,14 @@ def _call_ai_chat_sync(
|
|
| 549 |
candidates = _chat_provider_candidates(settings, intent)
|
| 550 |
if not candidates:
|
| 551 |
if settings.environment != "production" and settings.ai_fallback_to_mock:
|
| 552 |
-
return (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 553 |
logger.error("No configured AI provider is available for /chat")
|
| 554 |
raise HTTPException(
|
| 555 |
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
@@ -621,7 +697,14 @@ def _call_ai_chat_sync(
|
|
| 621 |
last_exc,
|
| 622 |
)
|
| 623 |
if settings.environment != "production" and settings.ai_fallback_to_mock:
|
| 624 |
-
return (
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 625 |
raise HTTPException(
|
| 626 |
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 627 |
detail="DocDoe could not reach the AI service right now. Please try again shortly.",
|
|
@@ -692,6 +775,7 @@ async def chat_with_docdoe(
|
|
| 692 |
user_message=user_message,
|
| 693 |
intent=intent,
|
| 694 |
max_tokens=_MAX_TOKENS.get(intent, 700),
|
|
|
|
| 695 |
)
|
| 696 |
|
| 697 |
record_generation(db, current_user.id)
|
|
@@ -756,13 +840,14 @@ def _prepare_chat_inputs(
|
|
| 756 |
)
|
| 757 |
effective_subject = (
|
| 758 |
payload.subject
|
|
|
|
| 759 |
or (learning_subject.name if learning_subject is not None else None)
|
| 760 |
or (chat_profile.subject if chat_profile is not None and not profile_skipped else None)
|
| 761 |
)
|
| 762 |
effective_chapter = (
|
| 763 |
-
|
| 764 |
-
if learning_chapter is not None
|
| 765 |
-
|
| 766 |
)
|
| 767 |
evidence_kwargs = {
|
| 768 |
"subject": effective_subject,
|
|
@@ -800,6 +885,25 @@ def _prepare_chat_inputs(
|
|
| 800 |
|
| 801 |
if effective_subject and intent != "casual":
|
| 802 |
system_prompt = f"{system_prompt}\n\nCurrent subject context: {effective_subject}."
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 803 |
|
| 804 |
# For all study intents, append evidence rules to the system prompt so the
|
| 805 |
# AI cannot fabricate PYQ claims regardless of what the user asks.
|
|
@@ -939,7 +1043,7 @@ async def _prepare_current_info_context(
|
|
| 939 |
# Never log headers or configuration values. The existing current-info
|
| 940 |
# prompt remains honest when search is not configured or temporarily fails.
|
| 941 |
logger.info("Current-information web search unavailable: %s", exc)
|
| 942 |
-
return system_prompt, user_message,
|
| 943 |
|
| 944 |
web_sources = [WebCitation(**source.as_dict()) for source in search_result.sources]
|
| 945 |
web_prompt = system_prompt.replace(_SYSTEM_PROMPTS["current_info"], _WEB_SEARCH_PROMPT)
|
|
@@ -975,7 +1079,11 @@ async def chat_stream_docdoe(
|
|
| 975 |
|
| 976 |
settings = get_settings()
|
| 977 |
if str(settings.ai_provider).strip().lower() == "mock":
|
| 978 |
-
mock_answer = _mock_chat_answer(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 979 |
|
| 980 |
async def mock_generate() -> AsyncGenerator[str, None]:
|
| 981 |
yield f"data: {json.dumps({'delta': mock_answer})}\n\n"
|
|
@@ -991,7 +1099,11 @@ async def chat_stream_docdoe(
|
|
| 991 |
candidates = _chat_provider_candidates(settings, intent)
|
| 992 |
if not candidates:
|
| 993 |
if settings.environment != "production" and settings.ai_fallback_to_mock:
|
| 994 |
-
mock_answer = _mock_chat_answer(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 995 |
|
| 996 |
async def mock_generate() -> AsyncGenerator[str, None]:
|
| 997 |
yield f"data: {json.dumps({'delta': mock_answer})}\n\n"
|
|
|
|
| 46 |
from app.services.monthly_usage_service import increment_usage
|
| 47 |
from app.services.usage_service import record_generation
|
| 48 |
from app.services.exa_search import ExaSearchUnavailable, search_exa_web
|
| 49 |
+
from app.services.workspace_context import workspace_context_directive
|
| 50 |
|
| 51 |
|
| 52 |
router = APIRouter()
|
|
|
|
| 60 |
return _THINK_RE.sub("", text).strip()
|
| 61 |
|
| 62 |
|
| 63 |
+
def _mock_chat_answer(
|
| 64 |
+
intent: str,
|
| 65 |
+
message: str,
|
| 66 |
+
*,
|
| 67 |
+
academic_metadata: dict[str, str | None] | None = None,
|
| 68 |
+
) -> str:
|
| 69 |
"""Development-only answer for mock AI mode."""
|
| 70 |
topic = message.strip().split("\n")[-1].replace("Student's question:", "").strip() or "your topic"
|
| 71 |
if intent == "cram_plan":
|
|
|
|
| 90 |
teaching_steps_for,
|
| 91 |
)
|
| 92 |
|
| 93 |
+
ctx = infer_syllabus_context(
|
| 94 |
+
topic,
|
| 95 |
+
academic_metadata or {},
|
| 96 |
+
source_context=message,
|
| 97 |
+
has_source="Source context" in message,
|
| 98 |
+
)
|
| 99 |
steps = teaching_steps_for(ctx)[:8]
|
| 100 |
mistakes = common_mistakes_for(ctx)[:3]
|
| 101 |
return (
|
|
|
|
| 143 |
"last year question", "previous paper", "model question",
|
| 144 |
"2022", "2023", "2024", "2025",
|
| 145 |
})
|
| 146 |
+
_EXPLICIT_WEB_SEARCH_RE = re.compile(
|
| 147 |
+
r"\b(?:search|browse|look\s+up|check)\s+(?:(?:the|on)\s+)?"
|
| 148 |
+
r"(?:internet|web|online)\b|\b(?:internet|web|online)\s+search\b",
|
| 149 |
+
re.IGNORECASE,
|
| 150 |
+
)
|
| 151 |
+
_CURRENT_INFO_TOPIC_RE = re.compile(
|
| 152 |
+
r"\b(?:latest|current|today(?:'s)?|recent|updated|new)\b.{0,80}\b"
|
| 153 |
+
r"(?:news|notices?|announcements?|results?|exam\s+dates?|"
|
| 154 |
+
r"(?:official|board|exam)\s+(?:timetables?|schedules?)|syllabus|prices?|rates?|"
|
| 155 |
+
r"status|weather|scores?|rankings?|releases?|deadlines?|admissions?|"
|
| 156 |
+
r"scholarships?|polic(?:y|ies)|rules?|guidelines?)\b"
|
| 157 |
+
r"|\b(?:news|notices?|announcements?|results?|exam\s+dates?|"
|
| 158 |
+
r"(?:official|board|exam)\s+(?:timetables?|schedules?)|syllabus|prices?|rates?|"
|
| 159 |
+
r"status|weather|scores?|rankings?|releases?|deadlines?|admissions?|"
|
| 160 |
+
r"scholarships?|polic(?:y|ies)|rules?|guidelines?)\b.{0,80}\b"
|
| 161 |
+
r"(?:today|latest|current|recent|updated)\b",
|
| 162 |
+
re.IGNORECASE,
|
| 163 |
+
)
|
| 164 |
+
_CURRENT_ROLE_RE = re.compile(
|
| 165 |
+
r"\b(?:who\s+is|who's|name)\s+(?:the\s+)?(?:current|latest)\s+"
|
| 166 |
+
r"(?:president|prime\s+minister|minister|chief\s+minister|governor|"
|
| 167 |
+
r"chief\s+executive|ceo|chairperson|head)\b",
|
| 168 |
+
re.IGNORECASE,
|
| 169 |
+
)
|
| 170 |
+
_YEARLY_UPDATE_RE = re.compile(
|
| 171 |
+
r"\b20\d{2}\s+(?:update|notice|announcement|result|syllabus|timetable|schedule)\b",
|
| 172 |
+
re.IGNORECASE,
|
| 173 |
+
)
|
| 174 |
_NOTES_TOKENS = frozenset({
|
| 175 |
"notes", "write notes", "study notes", "key points",
|
| 176 |
"summarize", "summary", "bullet points", "revision notes",
|
|
|
|
| 188 |
})
|
| 189 |
|
| 190 |
|
| 191 |
+
def _requests_current_information(message: str, *, has_sources: bool) -> bool:
|
| 192 |
+
"""Return true only when the student genuinely needs live web information.
|
| 193 |
+
|
| 194 |
+
A selected upload is the primary context unless the student explicitly asks
|
| 195 |
+
to search online. Broad words such as "today", "recent", and "update" are
|
| 196 |
+
intentionally insufficient on their own: they are common in personal study
|
| 197 |
+
actions ("update my plan", "today's lesson", "recent mistakes").
|
| 198 |
+
"""
|
| 199 |
+
if _EXPLICIT_WEB_SEARCH_RE.search(message):
|
| 200 |
+
return True
|
| 201 |
+
if has_sources:
|
| 202 |
+
return False
|
| 203 |
+
return bool(
|
| 204 |
+
_CURRENT_INFO_TOPIC_RE.search(message)
|
| 205 |
+
or _CURRENT_ROLE_RE.search(message)
|
| 206 |
+
or _YEARLY_UPDATE_RE.search(message)
|
| 207 |
+
)
|
| 208 |
+
|
| 209 |
+
|
| 210 |
+
def _mock_academic_metadata(payload: ChatRequest) -> dict[str, str | None]:
|
| 211 |
+
academic = payload.academic_context
|
| 212 |
+
return {
|
| 213 |
+
"subject": payload.subject or (academic.subject if academic else None),
|
| 214 |
+
"chapter": academic.chapter if academic else None,
|
| 215 |
+
"topic": academic.topic if academic else None,
|
| 216 |
+
}
|
| 217 |
+
|
| 218 |
+
|
| 219 |
def _detect_intent(message: str, has_sources: bool) -> str:
|
| 220 |
lower = message.lower().strip().rstrip("!?.")
|
| 221 |
|
|
|
|
| 228 |
if re.match(r"^(hi+|hey+|hello+|yo+|bro)\b[\s,!.]*(my name is|i'?m|i am)\b", lower):
|
| 229 |
return "casual"
|
| 230 |
|
| 231 |
+
if _requests_current_information(message, has_sources=has_sources):
|
| 232 |
return "current_info"
|
| 233 |
|
| 234 |
if any(tok in lower for tok in _CRAM_PLAN_TOKENS) or re.search(r"\blearn\b.*\bnight\b", lower):
|
|
|
|
| 587 |
intent: str = "study_explain",
|
| 588 |
max_tokens: int = 700,
|
| 589 |
max_retries: int = 1,
|
| 590 |
+
mock_academic_metadata: dict[str, str | None] | None = None,
|
| 591 |
) -> tuple[str, str]:
|
| 592 |
"""Call the configured OpenAI-compatible chat provider in a worker thread.
|
| 593 |
|
|
|
|
| 598 |
settings = get_settings()
|
| 599 |
|
| 600 |
if str(settings.ai_provider).strip().lower() == "mock":
|
| 601 |
+
return (
|
| 602 |
+
_mock_chat_answer(
|
| 603 |
+
intent,
|
| 604 |
+
user_message,
|
| 605 |
+
academic_metadata=mock_academic_metadata,
|
| 606 |
+
),
|
| 607 |
+
"mock",
|
| 608 |
+
)
|
| 609 |
|
| 610 |
try:
|
| 611 |
from openai import APIConnectionError, APIStatusError, APITimeoutError, OpenAI
|
|
|
|
| 618 |
candidates = _chat_provider_candidates(settings, intent)
|
| 619 |
if not candidates:
|
| 620 |
if settings.environment != "production" and settings.ai_fallback_to_mock:
|
| 621 |
+
return (
|
| 622 |
+
_mock_chat_answer(
|
| 623 |
+
intent,
|
| 624 |
+
user_message,
|
| 625 |
+
academic_metadata=mock_academic_metadata,
|
| 626 |
+
),
|
| 627 |
+
"mock",
|
| 628 |
+
)
|
| 629 |
logger.error("No configured AI provider is available for /chat")
|
| 630 |
raise HTTPException(
|
| 631 |
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
|
|
|
| 697 |
last_exc,
|
| 698 |
)
|
| 699 |
if settings.environment != "production" and settings.ai_fallback_to_mock:
|
| 700 |
+
return (
|
| 701 |
+
_mock_chat_answer(
|
| 702 |
+
intent,
|
| 703 |
+
user_message,
|
| 704 |
+
academic_metadata=mock_academic_metadata,
|
| 705 |
+
),
|
| 706 |
+
"mock",
|
| 707 |
+
)
|
| 708 |
raise HTTPException(
|
| 709 |
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 710 |
detail="DocDoe could not reach the AI service right now. Please try again shortly.",
|
|
|
|
| 775 |
user_message=user_message,
|
| 776 |
intent=intent,
|
| 777 |
max_tokens=_MAX_TOKENS.get(intent, 700),
|
| 778 |
+
mock_academic_metadata=_mock_academic_metadata(payload),
|
| 779 |
)
|
| 780 |
|
| 781 |
record_generation(db, current_user.id)
|
|
|
|
| 840 |
)
|
| 841 |
effective_subject = (
|
| 842 |
payload.subject
|
| 843 |
+
or (payload.academic_context.subject if payload.academic_context else None)
|
| 844 |
or (learning_subject.name if learning_subject is not None else None)
|
| 845 |
or (chat_profile.subject if chat_profile is not None and not profile_skipped else None)
|
| 846 |
)
|
| 847 |
effective_chapter = (
|
| 848 |
+
(payload.academic_context.chapter if payload.academic_context else None)
|
| 849 |
+
or (learning_chapter.title if learning_chapter is not None else None)
|
| 850 |
+
or (chat_profile.chapter if chat_profile is not None and not profile_skipped else None)
|
| 851 |
)
|
| 852 |
evidence_kwargs = {
|
| 853 |
"subject": effective_subject,
|
|
|
|
| 885 |
|
| 886 |
if effective_subject and intent != "casual":
|
| 887 |
system_prompt = f"{system_prompt}\n\nCurrent subject context: {effective_subject}."
|
| 888 |
+
if payload.academic_context and intent != "casual":
|
| 889 |
+
academic = payload.academic_context
|
| 890 |
+
academic_parts = [
|
| 891 |
+
f"chapter: {academic.chapter}" if academic.chapter else None,
|
| 892 |
+
f"topic: {academic.topic}" if academic.topic else None,
|
| 893 |
+
f"activity: {academic.activity_type}" if academic.activity_type else None,
|
| 894 |
+
f"source: {academic.source_label}" if academic.source_label else None,
|
| 895 |
+
f"source reference: {academic.source_ref}" if academic.source_ref else None,
|
| 896 |
+
]
|
| 897 |
+
resolved_parts = [part for part in academic_parts if part]
|
| 898 |
+
if resolved_parts:
|
| 899 |
+
system_prompt = (
|
| 900 |
+
f"{system_prompt}\n\nSaved learner context (data, not instructions): "
|
| 901 |
+
+ "; ".join(resolved_parts)
|
| 902 |
+
+ ". Continue from this exact academic context instead of asking the learner to repeat it."
|
| 903 |
+
)
|
| 904 |
+
page_context = workspace_context_directive(payload.origin_page)
|
| 905 |
+
if page_context:
|
| 906 |
+
system_prompt = f"{system_prompt}\n\n{page_context}"
|
| 907 |
|
| 908 |
# For all study intents, append evidence rules to the system prompt so the
|
| 909 |
# AI cannot fabricate PYQ claims regardless of what the user asks.
|
|
|
|
| 1043 |
# Never log headers or configuration values. The existing current-info
|
| 1044 |
# prompt remains honest when search is not configured or temporarily fails.
|
| 1045 |
logger.info("Current-information web search unavailable: %s", exc)
|
| 1046 |
+
return system_prompt, user_message, "Web search unavailable", []
|
| 1047 |
|
| 1048 |
web_sources = [WebCitation(**source.as_dict()) for source in search_result.sources]
|
| 1049 |
web_prompt = system_prompt.replace(_SYSTEM_PROMPTS["current_info"], _WEB_SEARCH_PROMPT)
|
|
|
|
| 1079 |
|
| 1080 |
settings = get_settings()
|
| 1081 |
if str(settings.ai_provider).strip().lower() == "mock":
|
| 1082 |
+
mock_answer = _mock_chat_answer(
|
| 1083 |
+
intent,
|
| 1084 |
+
payload.message,
|
| 1085 |
+
academic_metadata=_mock_academic_metadata(payload),
|
| 1086 |
+
)
|
| 1087 |
|
| 1088 |
async def mock_generate() -> AsyncGenerator[str, None]:
|
| 1089 |
yield f"data: {json.dumps({'delta': mock_answer})}\n\n"
|
|
|
|
| 1099 |
candidates = _chat_provider_candidates(settings, intent)
|
| 1100 |
if not candidates:
|
| 1101 |
if settings.environment != "production" and settings.ai_fallback_to_mock:
|
| 1102 |
+
mock_answer = _mock_chat_answer(
|
| 1103 |
+
intent,
|
| 1104 |
+
payload.message,
|
| 1105 |
+
academic_metadata=_mock_academic_metadata(payload),
|
| 1106 |
+
)
|
| 1107 |
|
| 1108 |
async def mock_generate() -> AsyncGenerator[str, None]:
|
| 1109 |
yield f"data: {json.dumps({'delta': mock_answer})}\n\n"
|
app/routes/chat_history.py
CHANGED
|
@@ -2,14 +2,17 @@
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import logging
|
|
|
|
| 5 |
|
| 6 |
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
| 7 |
from sqlalchemy import func as sa_func
|
|
|
|
| 8 |
from sqlalchemy.orm import Session
|
| 9 |
|
| 10 |
from app.core.auth import require_user
|
| 11 |
from app.core.database import get_db
|
| 12 |
from app.models.chat_session import ChatMessageRecord, ChatSession
|
|
|
|
| 13 |
from app.models.user import User
|
| 14 |
from app.schemas.chat_history import (
|
| 15 |
AppendMessagesRequest,
|
|
@@ -56,6 +59,7 @@ def list_sessions(
|
|
| 56 |
source_id=sess.source_id,
|
| 57 |
subject=sess.subject,
|
| 58 |
title=sess.title,
|
|
|
|
| 59 |
message_count=msg_count,
|
| 60 |
created_at=sess.created_at,
|
| 61 |
updated_at=sess.updated_at,
|
|
@@ -87,6 +91,7 @@ def get_session(
|
|
| 87 |
source_id=sess.source_id,
|
| 88 |
subject=sess.subject,
|
| 89 |
title=sess.title,
|
|
|
|
| 90 |
messages=[ChatMessageOut.model_validate(m) for m in msgs],
|
| 91 |
created_at=sess.created_at,
|
| 92 |
updated_at=sess.updated_at,
|
|
@@ -105,20 +110,36 @@ def create_session(
|
|
| 105 |
.scalar()
|
| 106 |
)
|
| 107 |
if existing_count and existing_count >= _MAX_SESSIONS_PER_USER:
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
|
| 112 |
-
|
|
|
|
| 113 |
)
|
| 114 |
-
|
| 115 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
|
| 117 |
sess = ChatSession(
|
| 118 |
user_id=current_user.id,
|
| 119 |
-
source_id=
|
| 120 |
-
subject=body.subject,
|
| 121 |
-
title=body.title,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 122 |
)
|
| 123 |
db.add(sess)
|
| 124 |
db.commit()
|
|
@@ -129,6 +150,7 @@ def create_session(
|
|
| 129 |
source_id=sess.source_id,
|
| 130 |
subject=sess.subject,
|
| 131 |
title=sess.title,
|
|
|
|
| 132 |
message_count=0,
|
| 133 |
created_at=sess.created_at,
|
| 134 |
updated_at=sess.updated_at,
|
|
@@ -146,32 +168,73 @@ def append_messages(
|
|
| 146 |
if sess is None or sess.user_id != current_user.id:
|
| 147 |
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found.")
|
| 148 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
msg_count = (
|
| 150 |
db.query(sa_func.count(ChatMessageRecord.id))
|
| 151 |
.filter(ChatMessageRecord.session_id == session_id)
|
| 152 |
.scalar()
|
| 153 |
) or 0
|
| 154 |
-
if msg_count >
|
| 155 |
raise HTTPException(
|
| 156 |
status_code=status.HTTP_400_BAD_REQUEST,
|
| 157 |
detail="This chat has reached its message limit. Start a new chat to continue.",
|
| 158 |
)
|
| 159 |
|
| 160 |
-
user_msg = ChatMessageRecord(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 161 |
assistant_msg = ChatMessageRecord(
|
| 162 |
session_id=session_id,
|
| 163 |
role="assistant",
|
| 164 |
content=body.assistant_content,
|
| 165 |
intent=body.intent,
|
| 166 |
evidence_label=body.evidence_label,
|
|
|
|
|
|
|
| 167 |
)
|
| 168 |
db.add(user_msg)
|
| 169 |
db.add(assistant_msg)
|
| 170 |
|
| 171 |
if msg_count == 0:
|
| 172 |
sess.title = body.user_content[:80].strip() or "New chat"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 173 |
|
| 174 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 175 |
db.refresh(user_msg)
|
| 176 |
db.refresh(assistant_msg)
|
| 177 |
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import logging
|
| 5 |
+
from datetime import datetime, timezone
|
| 6 |
|
| 7 |
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
| 8 |
from sqlalchemy import func as sa_func
|
| 9 |
+
from sqlalchemy.exc import IntegrityError
|
| 10 |
from sqlalchemy.orm import Session
|
| 11 |
|
| 12 |
from app.core.auth import require_user
|
| 13 |
from app.core.database import get_db
|
| 14 |
from app.models.chat_session import ChatMessageRecord, ChatSession
|
| 15 |
+
from app.models.document import Document
|
| 16 |
from app.models.user import User
|
| 17 |
from app.schemas.chat_history import (
|
| 18 |
AppendMessagesRequest,
|
|
|
|
| 59 |
source_id=sess.source_id,
|
| 60 |
subject=sess.subject,
|
| 61 |
title=sess.title,
|
| 62 |
+
context_data=sess.context_data or None,
|
| 63 |
message_count=msg_count,
|
| 64 |
created_at=sess.created_at,
|
| 65 |
updated_at=sess.updated_at,
|
|
|
|
| 91 |
source_id=sess.source_id,
|
| 92 |
subject=sess.subject,
|
| 93 |
title=sess.title,
|
| 94 |
+
context_data=sess.context_data or None,
|
| 95 |
messages=[ChatMessageOut.model_validate(m) for m in msgs],
|
| 96 |
created_at=sess.created_at,
|
| 97 |
updated_at=sess.updated_at,
|
|
|
|
| 110 |
.scalar()
|
| 111 |
)
|
| 112 |
if existing_count and existing_count >= _MAX_SESSIONS_PER_USER:
|
| 113 |
+
raise HTTPException(
|
| 114 |
+
status_code=status.HTTP_409_CONFLICT,
|
| 115 |
+
detail=(
|
| 116 |
+
"You have reached the 100-chat history limit. "
|
| 117 |
+
"Delete a chat you no longer need before starting another."
|
| 118 |
+
),
|
| 119 |
)
|
| 120 |
+
|
| 121 |
+
context_source_id = body.context_data.source_id if body.context_data else None
|
| 122 |
+
if body.source_id and context_source_id and body.source_id != context_source_id:
|
| 123 |
+
raise HTTPException(
|
| 124 |
+
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
| 125 |
+
detail="The chat source and academic context source must match.",
|
| 126 |
+
)
|
| 127 |
+
source_id = body.source_id or context_source_id
|
| 128 |
+
if source_id:
|
| 129 |
+
source: Document | None = db.get(Document, source_id)
|
| 130 |
+
if source is None or source.user_id != current_user.id:
|
| 131 |
+
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Source not found.")
|
| 132 |
|
| 133 |
sess = ChatSession(
|
| 134 |
user_id=current_user.id,
|
| 135 |
+
source_id=source_id,
|
| 136 |
+
subject=body.subject.strip(),
|
| 137 |
+
title=body.title.strip() or "New chat",
|
| 138 |
+
context_data=(
|
| 139 |
+
body.context_data.model_dump(mode="json", exclude_none=True)
|
| 140 |
+
if body.context_data
|
| 141 |
+
else {}
|
| 142 |
+
),
|
| 143 |
)
|
| 144 |
db.add(sess)
|
| 145 |
db.commit()
|
|
|
|
| 150 |
source_id=sess.source_id,
|
| 151 |
subject=sess.subject,
|
| 152 |
title=sess.title,
|
| 153 |
+
context_data=sess.context_data or None,
|
| 154 |
message_count=0,
|
| 155 |
created_at=sess.created_at,
|
| 156 |
updated_at=sess.updated_at,
|
|
|
|
| 168 |
if sess is None or sess.user_id != current_user.id:
|
| 169 |
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Session not found.")
|
| 170 |
|
| 171 |
+
if body.client_turn_id:
|
| 172 |
+
existing_turn = (
|
| 173 |
+
db.query(ChatMessageRecord)
|
| 174 |
+
.filter(
|
| 175 |
+
ChatMessageRecord.session_id == session_id,
|
| 176 |
+
ChatMessageRecord.client_turn_id == body.client_turn_id,
|
| 177 |
+
)
|
| 178 |
+
.order_by(ChatMessageRecord.created_at, ChatMessageRecord.role.desc())
|
| 179 |
+
.all()
|
| 180 |
+
)
|
| 181 |
+
if existing_turn:
|
| 182 |
+
return [ChatMessageOut.model_validate(message) for message in existing_turn]
|
| 183 |
+
|
| 184 |
msg_count = (
|
| 185 |
db.query(sa_func.count(ChatMessageRecord.id))
|
| 186 |
.filter(ChatMessageRecord.session_id == session_id)
|
| 187 |
.scalar()
|
| 188 |
) or 0
|
| 189 |
+
if msg_count > _MAX_MESSAGES_PER_SESSION - 2:
|
| 190 |
raise HTTPException(
|
| 191 |
status_code=status.HTTP_400_BAD_REQUEST,
|
| 192 |
detail="This chat has reached its message limit. Start a new chat to continue.",
|
| 193 |
)
|
| 194 |
|
| 195 |
+
user_msg = ChatMessageRecord(
|
| 196 |
+
session_id=session_id,
|
| 197 |
+
role="user",
|
| 198 |
+
content=body.user_content,
|
| 199 |
+
client_turn_id=body.client_turn_id,
|
| 200 |
+
)
|
| 201 |
assistant_msg = ChatMessageRecord(
|
| 202 |
session_id=session_id,
|
| 203 |
role="assistant",
|
| 204 |
content=body.assistant_content,
|
| 205 |
intent=body.intent,
|
| 206 |
evidence_label=body.evidence_label,
|
| 207 |
+
client_turn_id=body.client_turn_id,
|
| 208 |
+
web_sources=[source.model_dump(mode="json") for source in body.web_sources],
|
| 209 |
)
|
| 210 |
db.add(user_msg)
|
| 211 |
db.add(assistant_msg)
|
| 212 |
|
| 213 |
if msg_count == 0:
|
| 214 |
sess.title = body.user_content[:80].strip() or "New chat"
|
| 215 |
+
# Keep history ordered by the student's latest real activity. SQLAlchemy's
|
| 216 |
+
# onupdate only fires when the session row itself changes; inserting child
|
| 217 |
+
# messages alone would otherwise leave an active chat buried in the list.
|
| 218 |
+
sess.updated_at = datetime.now(timezone.utc)
|
| 219 |
|
| 220 |
+
try:
|
| 221 |
+
db.commit()
|
| 222 |
+
except IntegrityError:
|
| 223 |
+
db.rollback()
|
| 224 |
+
if not body.client_turn_id:
|
| 225 |
+
raise
|
| 226 |
+
existing_turn = (
|
| 227 |
+
db.query(ChatMessageRecord)
|
| 228 |
+
.filter(
|
| 229 |
+
ChatMessageRecord.session_id == session_id,
|
| 230 |
+
ChatMessageRecord.client_turn_id == body.client_turn_id,
|
| 231 |
+
)
|
| 232 |
+
.order_by(ChatMessageRecord.created_at, ChatMessageRecord.role.desc())
|
| 233 |
+
.all()
|
| 234 |
+
)
|
| 235 |
+
if not existing_turn:
|
| 236 |
+
raise
|
| 237 |
+
return [ChatMessageOut.model_validate(message) for message in existing_turn]
|
| 238 |
db.refresh(user_msg)
|
| 239 |
db.refresh(assistant_msg)
|
| 240 |
|
{backend/app → app}/routes/chemistry_video.py
RENAMED
|
File without changes
|
app/routes/documents.py
CHANGED
|
@@ -21,6 +21,7 @@ from app.schemas.chunk import (
|
|
| 21 |
)
|
| 22 |
from app.schemas.document import DocumentPreview, DocumentRead, DocumentUploadResponse
|
| 23 |
from app.schemas.generation import GenerationRead
|
|
|
|
| 24 |
from app.services.file_storage import save_upload_file
|
| 25 |
from app.services.education_ingestion import run_document_education_ingestion
|
| 26 |
from app.services.monthly_usage_service import check_usage_limit, get_user_plan_name
|
|
@@ -29,7 +30,7 @@ from app.services.retrieval import retrieve_relevant_chunks
|
|
| 29 |
from app.services.source_classifier import classify_material_type
|
| 30 |
from app.services.study_intelligence import create_study_map_generation
|
| 31 |
from app.services.text_extraction import TextExtractionError, extract_text_from_file
|
| 32 |
-
from app.utils.errors import
|
| 33 |
from app.utils.ownership import require_user_owned_resource
|
| 34 |
|
| 35 |
_VALID_MATERIAL_TYPES = frozenset({
|
|
@@ -112,7 +113,7 @@ def upload_document(
|
|
| 112 |
document.extracted_text = None
|
| 113 |
document.extraction_error = str(exc)
|
| 114 |
document.chunk_count = 0
|
| 115 |
-
except Exception
|
| 116 |
logger.exception("Document indexing failed for %s", document.id)
|
| 117 |
document.status = "failed"
|
| 118 |
document.extraction_error = "Document indexing failed. Please try again."
|
|
@@ -169,6 +170,25 @@ def get_document(
|
|
| 169 |
return document
|
| 170 |
|
| 171 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
class ReclassifyRequest(BaseModel):
|
| 173 |
material_type: str
|
| 174 |
|
|
@@ -365,7 +385,7 @@ def retry_processing(
|
|
| 365 |
document.extracted_text = None
|
| 366 |
document.extraction_error = str(exc)
|
| 367 |
document.chunk_count = 0
|
| 368 |
-
except Exception
|
| 369 |
logger.exception("Document retry indexing failed for %s", document.id)
|
| 370 |
document.status = "failed"
|
| 371 |
document.extraction_error = "Document indexing failed. Please try again."
|
|
|
|
| 21 |
)
|
| 22 |
from app.schemas.document import DocumentPreview, DocumentRead, DocumentUploadResponse
|
| 23 |
from app.schemas.generation import GenerationRead
|
| 24 |
+
from app.services.document_deletion import delete_document_and_derivatives
|
| 25 |
from app.services.file_storage import save_upload_file
|
| 26 |
from app.services.education_ingestion import run_document_education_ingestion
|
| 27 |
from app.services.monthly_usage_service import check_usage_limit, get_user_plan_name
|
|
|
|
| 30 |
from app.services.source_classifier import classify_material_type
|
| 31 |
from app.services.study_intelligence import create_study_map_generation
|
| 32 |
from app.services.text_extraction import TextExtractionError, extract_text_from_file
|
| 33 |
+
from app.utils.errors import rate_limited_error
|
| 34 |
from app.utils.ownership import require_user_owned_resource
|
| 35 |
|
| 36 |
_VALID_MATERIAL_TYPES = frozenset({
|
|
|
|
| 113 |
document.extracted_text = None
|
| 114 |
document.extraction_error = str(exc)
|
| 115 |
document.chunk_count = 0
|
| 116 |
+
except Exception:
|
| 117 |
logger.exception("Document indexing failed for %s", document.id)
|
| 118 |
document.status = "failed"
|
| 119 |
document.extraction_error = "Document indexing failed. Please try again."
|
|
|
|
| 170 |
return document
|
| 171 |
|
| 172 |
|
| 173 |
+
@router.delete("/{document_id}", status_code=status.HTTP_204_NO_CONTENT)
|
| 174 |
+
def delete_document(
|
| 175 |
+
document_id: str,
|
| 176 |
+
db: Session = Depends(get_db),
|
| 177 |
+
current_user: User = Depends(require_user),
|
| 178 |
+
) -> None:
|
| 179 |
+
"""Permanently delete a document owned by the current user.
|
| 180 |
+
|
| 181 |
+
Removes derived records (generations, quizzes, flashcard sets, chunks —
|
| 182 |
+
the document's own chunks cascade via the ORM relationship), detaches
|
| 183 |
+
references from records that may legitimately outlive the source (a
|
| 184 |
+
rendered video, an onboarding profile snapshot), and deletes the stored
|
| 185 |
+
file from disk. Cross-account access returns 404, never leaking existence.
|
| 186 |
+
"""
|
| 187 |
+
document = require_user_owned_resource(db, Document, document_id, current_user.id)
|
| 188 |
+
|
| 189 |
+
delete_document_and_derivatives(db, document)
|
| 190 |
+
|
| 191 |
+
|
| 192 |
class ReclassifyRequest(BaseModel):
|
| 193 |
material_type: str
|
| 194 |
|
|
|
|
| 385 |
document.extracted_text = None
|
| 386 |
document.extraction_error = str(exc)
|
| 387 |
document.chunk_count = 0
|
| 388 |
+
except Exception:
|
| 389 |
logger.exception("Document retry indexing failed for %s", document.id)
|
| 390 |
document.status = "failed"
|
| 391 |
document.extraction_error = "Document indexing failed. Please try again."
|
app/routes/learning_engine.py
CHANGED
|
@@ -196,3 +196,20 @@ def generate_learn_lesson(
|
|
| 196 |
)
|
| 197 |
except LessonBuildError as exc:
|
| 198 |
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 196 |
)
|
| 197 |
except LessonBuildError as exc:
|
| 198 |
raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=str(exc)) from exc
|
| 199 |
+
except Exception as exc:
|
| 200 |
+
# Surface a clear student-safe message instead of the global 500
|
| 201 |
+
# "internal error" envelope (common when cache paths fail on HF).
|
| 202 |
+
import logging
|
| 203 |
+
|
| 204 |
+
logging.getLogger(__name__).exception(
|
| 205 |
+
"Unhandled learn-lesson failure for topic=%s lesson=%s",
|
| 206 |
+
payload.topic,
|
| 207 |
+
payload.lesson_title,
|
| 208 |
+
)
|
| 209 |
+
raise HTTPException(
|
| 210 |
+
status_code=status.HTTP_502_BAD_GATEWAY,
|
| 211 |
+
detail=(
|
| 212 |
+
f"Could not prepare this class ({type(exc).__name__}). "
|
| 213 |
+
"Your plan is still saved — retry in a moment."
|
| 214 |
+
),
|
| 215 |
+
) from exc
|
app/routes/previous_papers.py
CHANGED
|
@@ -30,7 +30,6 @@ from app.services.pyq_discovery import VERIFIED
|
|
| 30 |
from app.services.retrieval import chunks_to_context, retrieve_relevant_chunks
|
| 31 |
from app.services.source_guard import assert_source_eligible_for_exam
|
| 32 |
from app.services.text_extraction import TextExtractionError, extract_text_from_file
|
| 33 |
-
from app.utils.errors import get_or_404
|
| 34 |
from app.utils.ownership import require_user_owned_resource
|
| 35 |
|
| 36 |
|
|
@@ -108,16 +107,10 @@ def list_previous_papers(
|
|
| 108 |
.where(PreviousPaper.user_id == current_user.id)
|
| 109 |
.order_by(PreviousPaper.created_at.desc())
|
| 110 |
)
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
|
| 114 |
-
|
| 115 |
-
from scripts.seed_hse_pyqs import seed_default_hse_papers
|
| 116 |
-
seed_default_hse_papers(db, current_user.id)
|
| 117 |
-
results = list(db.scalars(query).all())
|
| 118 |
-
except Exception:
|
| 119 |
-
pass
|
| 120 |
-
return results
|
| 121 |
|
| 122 |
|
| 123 |
@router.post("/analyze")
|
|
|
|
| 30 |
from app.services.retrieval import chunks_to_context, retrieve_relevant_chunks
|
| 31 |
from app.services.source_guard import assert_source_eligible_for_exam
|
| 32 |
from app.services.text_extraction import TextExtractionError, extract_text_from_file
|
|
|
|
| 33 |
from app.utils.ownership import require_user_owned_resource
|
| 34 |
|
| 35 |
|
|
|
|
| 107 |
.where(PreviousPaper.user_id == current_user.id)
|
| 108 |
.order_by(PreviousPaper.created_at.desc())
|
| 109 |
)
|
| 110 |
+
# An empty account must stay empty. Runtime demo seeding made real users
|
| 111 |
+
# appear to own papers they never uploaded and could unlock evidence claims
|
| 112 |
+
# from bundled data. Demo fixtures belong in explicit demo mode only.
|
| 113 |
+
return list(db.scalars(query).all())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 114 |
|
| 115 |
|
| 116 |
@router.post("/analyze")
|
{backend/app → app}/routes/social_science_video.py
RENAMED
|
File without changes
|
app/routes/sources.py
CHANGED
|
@@ -15,7 +15,7 @@ from app.schemas.chunk import (
|
|
| 15 |
RetrievalResponse,
|
| 16 |
)
|
| 17 |
from app.services.chunking import replace_document_chunks
|
| 18 |
-
from app.services.
|
| 19 |
from app.services.retrieval import retrieve_relevant_chunks
|
| 20 |
from app.utils.errors import get_or_404
|
| 21 |
|
|
@@ -208,8 +208,4 @@ def delete_source(
|
|
| 208 |
) -> None:
|
| 209 |
document = get_or_404(db, Document, source_id, "Source")
|
| 210 |
_ensure_owner(document, current_user)
|
| 211 |
-
|
| 212 |
-
file_path = document.file_path
|
| 213 |
-
db.delete(document)
|
| 214 |
-
db.commit()
|
| 215 |
-
delete_upload_file(file_path)
|
|
|
|
| 15 |
RetrievalResponse,
|
| 16 |
)
|
| 17 |
from app.services.chunking import replace_document_chunks
|
| 18 |
+
from app.services.document_deletion import delete_document_and_derivatives
|
| 19 |
from app.services.retrieval import retrieve_relevant_chunks
|
| 20 |
from app.utils.errors import get_or_404
|
| 21 |
|
|
|
|
| 208 |
) -> None:
|
| 209 |
document = get_or_404(db, Document, source_id, "Source")
|
| 210 |
_ensure_owner(document, current_user)
|
| 211 |
+
delete_document_and_derivatives(db, document)
|
|
|
|
|
|
|
|
|
|
|
|
app/routes/sync.py
CHANGED
|
@@ -1,104 +1,330 @@
|
|
| 1 |
-
"""
|
| 2 |
-
|
| 3 |
-
Accepts client-side learning events and progress snapshots so the frontend
|
| 4 |
-
can report study activity back to the server. Currently a stub that
|
| 5 |
-
acknowledges the payload and logs it for future persistence.
|
| 6 |
-
|
| 7 |
-
Planned future behaviour: write events to a ``study_progress`` table, feed
|
| 8 |
-
into the weak-topic tracker and the study-path engine.
|
| 9 |
-
"""
|
| 10 |
from __future__ import annotations
|
| 11 |
|
| 12 |
-
import
|
| 13 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
-
from fastapi import APIRouter, Depends
|
| 16 |
-
from pydantic import BaseModel, Field
|
|
|
|
|
|
|
| 17 |
|
| 18 |
from app.core.auth import require_user
|
|
|
|
| 19 |
from app.core.response import created, ok
|
|
|
|
| 20 |
from app.models.user import User
|
| 21 |
|
| 22 |
router = APIRouter()
|
| 23 |
-
logger = logging.getLogger(__name__)
|
| 24 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 25 |
|
| 26 |
-
# ── Request / response schemas ────────────────────────────────────────────────
|
| 27 |
|
| 28 |
class ProgressEvent(BaseModel):
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
)
|
| 33 |
-
topic: str | None = None
|
| 34 |
-
subject: str | None = None
|
| 35 |
-
chapter: str | None = None
|
|
|
|
|
|
|
| 36 |
score: float | None = Field(default=None, ge=0.0, le=1.0)
|
| 37 |
-
duration_seconds: int | None = Field(default=None, ge=0)
|
| 38 |
-
session_id: str | None = None
|
| 39 |
-
source_id: str | None = None
|
|
|
|
|
|
|
| 40 |
metadata: dict[str, Any] = Field(default_factory=dict)
|
| 41 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
|
| 43 |
class ProgressBatch(BaseModel):
|
| 44 |
-
events: list[ProgressEvent] = Field(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
-
# ── Endpoints ─────────────────────────────────────────────────────────────────
|
| 48 |
|
| 49 |
@router.post(
|
| 50 |
"/sync/progress",
|
| 51 |
-
summary="
|
| 52 |
description=(
|
| 53 |
-
"
|
| 54 |
-
"
|
| 55 |
-
"Future: writes to study_progress table and feeds weak-topic tracker."
|
| 56 |
),
|
| 57 |
)
|
| 58 |
def sync_progress(
|
| 59 |
payload: ProgressBatch,
|
|
|
|
| 60 |
current_user: User = Depends(require_user),
|
| 61 |
) -> dict[str, Any]:
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
|
|
|
| 71 |
)
|
| 72 |
|
| 73 |
-
|
| 74 |
-
|
| 75 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 76 |
|
| 77 |
return created(
|
| 78 |
{
|
| 79 |
"synced": True,
|
| 80 |
"user_id": current_user.id,
|
| 81 |
-
"events_received":
|
|
|
|
|
|
|
| 82 |
},
|
| 83 |
-
message=
|
|
|
|
|
|
|
|
|
|
|
|
|
| 84 |
)
|
| 85 |
|
| 86 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 87 |
@router.get(
|
| 88 |
"/sync/progress",
|
| 89 |
-
summary="Get
|
| 90 |
-
description="
|
| 91 |
)
|
| 92 |
def get_progress_summary(
|
|
|
|
| 93 |
current_user: User = Depends(require_user),
|
| 94 |
) -> dict[str, Any]:
|
| 95 |
-
|
| 96 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 97 |
return ok(
|
| 98 |
{
|
| 99 |
"user_id": current_user.id,
|
| 100 |
-
"total_sessions":
|
| 101 |
-
"total_events":
|
| 102 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
},
|
| 104 |
)
|
|
|
|
| 1 |
+
"""Authenticated, idempotent cross-device learning activity sync."""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
+
import hashlib
|
| 5 |
+
import json
|
| 6 |
+
from collections import Counter
|
| 7 |
+
from datetime import datetime, timedelta, timezone
|
| 8 |
+
from typing import Any, Literal
|
| 9 |
+
from uuid import uuid4
|
| 10 |
|
| 11 |
+
from fastapi import APIRouter, Depends, HTTPException, status
|
| 12 |
+
from pydantic import BaseModel, Field, field_validator
|
| 13 |
+
from sqlalchemy import or_, select
|
| 14 |
+
from sqlalchemy.orm import Session
|
| 15 |
|
| 16 |
from app.core.auth import require_user
|
| 17 |
+
from app.core.database import get_db
|
| 18 |
from app.core.response import created, ok
|
| 19 |
+
from app.models.learning_state import UsageEvent
|
| 20 |
from app.models.user import User
|
| 21 |
|
| 22 |
router = APIRouter()
|
|
|
|
| 23 |
|
| 24 |
+
ActivityKind = Literal[
|
| 25 |
+
"question_asked",
|
| 26 |
+
"lesson_started",
|
| 27 |
+
"lesson_completed",
|
| 28 |
+
"note_saved",
|
| 29 |
+
"quiz_attempted",
|
| 30 |
+
"demo_class_completed",
|
| 31 |
+
]
|
| 32 |
+
|
| 33 |
+
_CLIENT_ACTIVITY_KINDS = {
|
| 34 |
+
"question_asked",
|
| 35 |
+
"lesson_started",
|
| 36 |
+
"lesson_completed",
|
| 37 |
+
"note_saved",
|
| 38 |
+
"quiz_attempted",
|
| 39 |
+
"demo_class_completed",
|
| 40 |
+
}
|
| 41 |
+
_LEGACY_KIND_MAP: dict[str, ActivityKind] = {
|
| 42 |
+
"study_session": "lesson_started",
|
| 43 |
+
"quiz_complete": "quiz_attempted",
|
| 44 |
+
"flashcard_review": "lesson_started",
|
| 45 |
+
}
|
| 46 |
+
_NATIVE_KIND_MAP: dict[str, ActivityKind] = {
|
| 47 |
+
"lesson_completed": "lesson_completed",
|
| 48 |
+
"assessment_completed": "quiz_attempted",
|
| 49 |
+
"task_completed": "lesson_completed",
|
| 50 |
+
}
|
| 51 |
+
_SYNC_RESOURCE_TYPE = "progress_sync"
|
| 52 |
+
_MAX_SYNC_EVENTS = 100
|
| 53 |
+
_MAX_METADATA_BYTES = 4096
|
| 54 |
|
|
|
|
| 55 |
|
| 56 |
class ProgressEvent(BaseModel):
|
| 57 |
+
event_id: str | None = Field(default=None, min_length=1, max_length=120)
|
| 58 |
+
event_type: str = Field(default="study_session", min_length=1, max_length=64)
|
| 59 |
+
title: str | None = Field(default=None, max_length=140)
|
| 60 |
+
detail: str | None = Field(default=None, max_length=240)
|
| 61 |
+
topic: str | None = Field(default=None, max_length=160)
|
| 62 |
+
subject: str | None = Field(default=None, max_length=100)
|
| 63 |
+
chapter: str | None = Field(default=None, max_length=160)
|
| 64 |
+
chapter_id: str | None = Field(default=None, max_length=80)
|
| 65 |
+
mission_id: str | None = Field(default=None, max_length=80)
|
| 66 |
score: float | None = Field(default=None, ge=0.0, le=1.0)
|
| 67 |
+
duration_seconds: int | None = Field(default=None, ge=0, le=86400)
|
| 68 |
+
session_id: str | None = Field(default=None, max_length=120)
|
| 69 |
+
source_id: str | None = Field(default=None, max_length=120)
|
| 70 |
+
occurred_at: datetime | None = None
|
| 71 |
+
sample: bool = False
|
| 72 |
metadata: dict[str, Any] = Field(default_factory=dict)
|
| 73 |
|
| 74 |
+
@field_validator("event_type")
|
| 75 |
+
@classmethod
|
| 76 |
+
def validate_event_type(cls, value: str) -> str:
|
| 77 |
+
normalized = value.strip().lower()
|
| 78 |
+
allowed = _CLIENT_ACTIVITY_KINDS | set(_LEGACY_KIND_MAP)
|
| 79 |
+
if normalized not in allowed:
|
| 80 |
+
raise ValueError("Unsupported progress event type.")
|
| 81 |
+
return normalized
|
| 82 |
+
|
| 83 |
+
@field_validator("occurred_at")
|
| 84 |
+
@classmethod
|
| 85 |
+
def validate_occurred_at(cls, value: datetime | None) -> datetime | None:
|
| 86 |
+
if value is None:
|
| 87 |
+
return None
|
| 88 |
+
resolved = value if value.tzinfo is not None else value.replace(tzinfo=timezone.utc)
|
| 89 |
+
now = datetime.now(timezone.utc)
|
| 90 |
+
if resolved > now + timedelta(minutes=5):
|
| 91 |
+
raise ValueError("Progress event time cannot be in the future.")
|
| 92 |
+
if resolved < datetime(2020, 1, 1, tzinfo=timezone.utc):
|
| 93 |
+
raise ValueError("Progress event time is outside the supported range.")
|
| 94 |
+
return resolved
|
| 95 |
+
|
| 96 |
+
@field_validator("metadata")
|
| 97 |
+
@classmethod
|
| 98 |
+
def validate_metadata(cls, value: dict[str, Any]) -> dict[str, Any]:
|
| 99 |
+
try:
|
| 100 |
+
size = len(json.dumps(value, separators=(",", ":")).encode("utf-8"))
|
| 101 |
+
except (TypeError, ValueError) as exc:
|
| 102 |
+
raise ValueError("Progress metadata must be JSON serializable.") from exc
|
| 103 |
+
if size > _MAX_METADATA_BYTES:
|
| 104 |
+
raise ValueError("Progress metadata is too large.")
|
| 105 |
+
return value
|
| 106 |
+
|
| 107 |
|
| 108 |
class ProgressBatch(BaseModel):
|
| 109 |
+
events: list[ProgressEvent] = Field(
|
| 110 |
+
default_factory=list,
|
| 111 |
+
max_length=_MAX_SYNC_EVENTS,
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
|
| 115 |
+
def _stable_event_id(user_id: str, client_event_id: str) -> str:
|
| 116 |
+
digest = hashlib.sha256(f"{user_id}:{client_event_id}".encode()).hexdigest()[:32]
|
| 117 |
+
return f"uev_{digest}"
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _event_payload(event: ProgressEvent, client_event_id: str) -> dict[str, Any]:
|
| 121 |
+
payload = {
|
| 122 |
+
"client_event_id": client_event_id,
|
| 123 |
+
"title": event.title,
|
| 124 |
+
"detail": event.detail,
|
| 125 |
+
"topic": event.topic,
|
| 126 |
+
"subject": event.subject,
|
| 127 |
+
"chapter": event.chapter,
|
| 128 |
+
"chapter_id": event.chapter_id,
|
| 129 |
+
"mission_id": event.mission_id,
|
| 130 |
+
"score": event.score,
|
| 131 |
+
"duration_seconds": event.duration_seconds,
|
| 132 |
+
"session_id": event.session_id,
|
| 133 |
+
"source_id": event.source_id,
|
| 134 |
+
"sample": event.sample,
|
| 135 |
+
"metadata": event.metadata,
|
| 136 |
+
}
|
| 137 |
+
return {key: value for key, value in payload.items() if value is not None}
|
| 138 |
|
|
|
|
| 139 |
|
| 140 |
@router.post(
|
| 141 |
"/sync/progress",
|
| 142 |
+
summary="Persist learning activity",
|
| 143 |
description=(
|
| 144 |
+
"Stores authenticated, account-scoped learning events. Client event IDs "
|
| 145 |
+
"are idempotent, so offline retries cannot inflate progress."
|
|
|
|
| 146 |
),
|
| 147 |
)
|
| 148 |
def sync_progress(
|
| 149 |
payload: ProgressBatch,
|
| 150 |
+
db: Session = Depends(get_db),
|
| 151 |
current_user: User = Depends(require_user),
|
| 152 |
) -> dict[str, Any]:
|
| 153 |
+
if not payload.events:
|
| 154 |
+
return created(
|
| 155 |
+
{
|
| 156 |
+
"synced": True,
|
| 157 |
+
"user_id": current_user.id,
|
| 158 |
+
"events_received": 0,
|
| 159 |
+
"events_stored": 0,
|
| 160 |
+
"duplicates": 0,
|
| 161 |
+
},
|
| 162 |
+
message="No events to sync.",
|
| 163 |
)
|
| 164 |
|
| 165 |
+
candidate_ids: list[str] = []
|
| 166 |
+
normalized: list[tuple[ProgressEvent, str, str]] = []
|
| 167 |
+
for event in payload.events:
|
| 168 |
+
client_event_id = event.event_id or f"server-{uuid4().hex}"
|
| 169 |
+
event_id = _stable_event_id(current_user.id, client_event_id)
|
| 170 |
+
candidate_ids.append(event_id)
|
| 171 |
+
normalized.append((event, client_event_id, event_id))
|
| 172 |
+
|
| 173 |
+
existing_ids = set(
|
| 174 |
+
db.scalars(
|
| 175 |
+
select(UsageEvent.id).where(
|
| 176 |
+
UsageEvent.user_id == current_user.id,
|
| 177 |
+
UsageEvent.id.in_(candidate_ids),
|
| 178 |
+
)
|
| 179 |
+
).all()
|
| 180 |
+
)
|
| 181 |
+
stored = 0
|
| 182 |
+
seen_batch_ids: set[str] = set()
|
| 183 |
+
for event, client_event_id, event_id in normalized:
|
| 184 |
+
if event_id in existing_ids or event_id in seen_batch_ids:
|
| 185 |
+
continue
|
| 186 |
+
seen_batch_ids.add(event_id)
|
| 187 |
+
db.add(
|
| 188 |
+
UsageEvent(
|
| 189 |
+
id=event_id,
|
| 190 |
+
user_id=current_user.id,
|
| 191 |
+
event_type=event.event_type,
|
| 192 |
+
resource_type=_SYNC_RESOURCE_TYPE,
|
| 193 |
+
units=1.0,
|
| 194 |
+
event_data=_event_payload(event, client_event_id),
|
| 195 |
+
occurred_at=event.occurred_at or datetime.now(timezone.utc),
|
| 196 |
+
)
|
| 197 |
+
)
|
| 198 |
+
stored += 1
|
| 199 |
+
db.commit()
|
| 200 |
|
| 201 |
return created(
|
| 202 |
{
|
| 203 |
"synced": True,
|
| 204 |
"user_id": current_user.id,
|
| 205 |
+
"events_received": len(payload.events),
|
| 206 |
+
"events_stored": stored,
|
| 207 |
+
"duplicates": len(payload.events) - stored,
|
| 208 |
},
|
| 209 |
+
message=(
|
| 210 |
+
"Progress synced across your account."
|
| 211 |
+
if stored
|
| 212 |
+
else "Progress was already up to date."
|
| 213 |
+
),
|
| 214 |
)
|
| 215 |
|
| 216 |
|
| 217 |
+
def _timeline_kind(event: UsageEvent) -> ActivityKind | None:
|
| 218 |
+
if event.resource_type == _SYNC_RESOURCE_TYPE:
|
| 219 |
+
if event.event_type in _CLIENT_ACTIVITY_KINDS:
|
| 220 |
+
return event.event_type # type: ignore[return-value]
|
| 221 |
+
return _LEGACY_KIND_MAP.get(event.event_type)
|
| 222 |
+
return _NATIVE_KIND_MAP.get(event.event_type)
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def _timeline_title(event: UsageEvent, kind: ActivityKind) -> str:
|
| 226 |
+
data = event.event_data or {}
|
| 227 |
+
explicit = data.get("title")
|
| 228 |
+
if isinstance(explicit, str) and explicit.strip():
|
| 229 |
+
return explicit.strip()[:140]
|
| 230 |
+
topic = data.get("topic")
|
| 231 |
+
if event.event_type == "assessment_completed":
|
| 232 |
+
return f"Assessment completed{f': {topic}' if topic else ''}"
|
| 233 |
+
if event.event_type == "task_completed":
|
| 234 |
+
return "Study task completed"
|
| 235 |
+
if kind == "lesson_completed":
|
| 236 |
+
return f"Lesson completed{f': {topic}' if topic else ''}"
|
| 237 |
+
if event.event_type == "flashcard_review":
|
| 238 |
+
return f"Flashcards reviewed{f': {topic}' if topic else ''}"
|
| 239 |
+
if event.event_type == "quiz_complete":
|
| 240 |
+
return f"Quiz completed{f': {topic}' if topic else ''}"
|
| 241 |
+
if event.event_type == "study_session":
|
| 242 |
+
return f"Study session{f': {topic}' if topic else ''}"
|
| 243 |
+
return "Learning activity"
|
| 244 |
+
|
| 245 |
+
|
| 246 |
+
def _timeline_event(event: UsageEvent) -> dict[str, Any] | None:
|
| 247 |
+
kind = _timeline_kind(event)
|
| 248 |
+
if kind is None:
|
| 249 |
+
return None
|
| 250 |
+
data = event.event_data or {}
|
| 251 |
+
client_event_id = data.get("client_event_id")
|
| 252 |
+
return {
|
| 253 |
+
"id": client_event_id if isinstance(client_event_id, str) else event.id,
|
| 254 |
+
"kind": kind,
|
| 255 |
+
"title": _timeline_title(event, kind),
|
| 256 |
+
"subject": data.get("subject"),
|
| 257 |
+
"chapterId": data.get("chapter_id"),
|
| 258 |
+
"missionId": data.get("mission_id"),
|
| 259 |
+
"detail": data.get("detail"),
|
| 260 |
+
"at": event.occurred_at.isoformat(),
|
| 261 |
+
"provenance": "demo" if kind == "demo_class_completed" else None,
|
| 262 |
+
"sample": bool(data.get("sample", False)),
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
+
|
| 266 |
@router.get(
|
| 267 |
"/sync/progress",
|
| 268 |
+
summary="Get account activity summary",
|
| 269 |
+
description="Aggregates persisted, authenticated progress events for the current account.",
|
| 270 |
)
|
| 271 |
def get_progress_summary(
|
| 272 |
+
db: Session = Depends(get_db),
|
| 273 |
current_user: User = Depends(require_user),
|
| 274 |
) -> dict[str, Any]:
|
| 275 |
+
relevant_native_types = tuple(_NATIVE_KIND_MAP)
|
| 276 |
+
events = list(
|
| 277 |
+
db.scalars(
|
| 278 |
+
select(UsageEvent)
|
| 279 |
+
.where(
|
| 280 |
+
UsageEvent.user_id == current_user.id,
|
| 281 |
+
or_(
|
| 282 |
+
UsageEvent.resource_type == _SYNC_RESOURCE_TYPE,
|
| 283 |
+
UsageEvent.event_type.in_(relevant_native_types),
|
| 284 |
+
),
|
| 285 |
+
)
|
| 286 |
+
.order_by(UsageEvent.occurred_at.desc())
|
| 287 |
+
.limit(1000)
|
| 288 |
+
).all()
|
| 289 |
+
)
|
| 290 |
+
timeline = [
|
| 291 |
+
item
|
| 292 |
+
for event in events[:100]
|
| 293 |
+
if (item := _timeline_event(event)) is not None
|
| 294 |
+
][:50]
|
| 295 |
+
synced_events = [
|
| 296 |
+
event for event in events if event.resource_type == _SYNC_RESOURCE_TYPE
|
| 297 |
+
]
|
| 298 |
+
type_counts = Counter(event.event_type for event in synced_events)
|
| 299 |
+
session_ids = {
|
| 300 |
+
str(event.event_data.get("session_id"))
|
| 301 |
+
for event in synced_events
|
| 302 |
+
if event.event_data.get("session_id")
|
| 303 |
+
}
|
| 304 |
+
session_events_without_id = sum(
|
| 305 |
+
event.event_type == "study_session"
|
| 306 |
+
and not event.event_data.get("session_id")
|
| 307 |
+
for event in synced_events
|
| 308 |
+
)
|
| 309 |
+
total_duration_seconds = sum(
|
| 310 |
+
int(event.event_data.get("duration_seconds") or 0)
|
| 311 |
+
for event in synced_events
|
| 312 |
+
)
|
| 313 |
+
|
| 314 |
return ok(
|
| 315 |
{
|
| 316 |
"user_id": current_user.id,
|
| 317 |
+
"total_sessions": len(session_ids) + session_events_without_id,
|
| 318 |
+
"total_events": len(synced_events),
|
| 319 |
+
"sample_events": sum(
|
| 320 |
+
bool(event.event_data.get("sample", False))
|
| 321 |
+
for event in synced_events
|
| 322 |
+
),
|
| 323 |
+
"total_duration_seconds": total_duration_seconds,
|
| 324 |
+
"by_type": dict(sorted(type_counts.items())),
|
| 325 |
+
"last_activity_at": (
|
| 326 |
+
events[0].occurred_at.isoformat() if events else None
|
| 327 |
+
),
|
| 328 |
+
"recent_events": timeline,
|
| 329 |
},
|
| 330 |
)
|
app/routes/users.py
CHANGED
|
@@ -1,12 +1,21 @@
|
|
| 1 |
from fastapi import APIRouter, Depends, HTTPException, status
|
|
|
|
| 2 |
from sqlalchemy import select
|
| 3 |
from sqlalchemy.orm import Session
|
| 4 |
|
| 5 |
-
from app.core.auth import require_user
|
|
|
|
| 6 |
from app.core.database import get_db
|
| 7 |
from app.models.user import User
|
| 8 |
from app.models.user_plan import UserPlan
|
| 9 |
-
from app.schemas.user import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
from app.services.monthly_usage_service import get_usage_summary
|
| 11 |
from app.utils.errors import get_or_404
|
| 12 |
|
|
@@ -68,6 +77,36 @@ def update_me(
|
|
| 68 |
return current_user
|
| 69 |
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
@router.get("", response_model=list[UserRead])
|
| 72 |
def list_users(
|
| 73 |
current_user: User = Depends(require_user),
|
|
|
|
| 1 |
from fastapi import APIRouter, Depends, HTTPException, status
|
| 2 |
+
from fastapi.security import HTTPAuthorizationCredentials
|
| 3 |
from sqlalchemy import select
|
| 4 |
from sqlalchemy.orm import Session
|
| 5 |
|
| 6 |
+
from app.core.auth import bearer_scheme, get_verified_auth_subject, require_user
|
| 7 |
+
from app.core.config import get_settings
|
| 8 |
from app.core.database import get_db
|
| 9 |
from app.models.user import User
|
| 10 |
from app.models.user_plan import UserPlan
|
| 11 |
+
from app.schemas.user import (
|
| 12 |
+
AccountDeleteRequest,
|
| 13 |
+
AccountDeleteResponse,
|
| 14 |
+
UserCreate,
|
| 15 |
+
UserRead,
|
| 16 |
+
UserUpdate,
|
| 17 |
+
)
|
| 18 |
+
from app.services.account_deletion import delete_user_account
|
| 19 |
from app.services.monthly_usage_service import get_usage_summary
|
| 20 |
from app.utils.errors import get_or_404
|
| 21 |
|
|
|
|
| 77 |
return current_user
|
| 78 |
|
| 79 |
|
| 80 |
+
@router.delete("/me", response_model=AccountDeleteResponse)
|
| 81 |
+
def delete_me(
|
| 82 |
+
payload: AccountDeleteRequest,
|
| 83 |
+
credentials: HTTPAuthorizationCredentials | None = Depends(bearer_scheme),
|
| 84 |
+
current_user: User = Depends(require_user),
|
| 85 |
+
db: Session = Depends(get_db),
|
| 86 |
+
) -> AccountDeleteResponse:
|
| 87 |
+
settings = get_settings()
|
| 88 |
+
identity_subject: str | None = None
|
| 89 |
+
if (settings.auth_provider or "").strip().lower() == "supabase":
|
| 90 |
+
if credentials is None or credentials.scheme.lower() != "bearer":
|
| 91 |
+
raise HTTPException(
|
| 92 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 93 |
+
detail="Authentication required",
|
| 94 |
+
)
|
| 95 |
+
identity_subject = get_verified_auth_subject(credentials.credentials)
|
| 96 |
+
|
| 97 |
+
result = delete_user_account(
|
| 98 |
+
db,
|
| 99 |
+
current_user,
|
| 100 |
+
confirmation=payload.confirmation,
|
| 101 |
+
password=payload.password,
|
| 102 |
+
identity_subject=identity_subject,
|
| 103 |
+
)
|
| 104 |
+
return AccountDeleteResponse(
|
| 105 |
+
deleted=True,
|
| 106 |
+
billing_subscription_canceled=result.billing_subscription_canceled,
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
|
| 110 |
@router.get("", response_model=list[UserRead])
|
| 111 |
def list_users(
|
| 112 |
current_user: User = Depends(require_user),
|
app/schemas/ask.py
CHANGED
|
@@ -4,6 +4,8 @@ from typing import Literal
|
|
| 4 |
|
| 5 |
from pydantic import BaseModel, Field
|
| 6 |
|
|
|
|
|
|
|
| 7 |
|
| 8 |
AskMode = Literal[
|
| 9 |
"explain_simple",
|
|
@@ -22,6 +24,7 @@ class AskRequest(BaseModel):
|
|
| 22 |
mode: AskMode = "explain_simple"
|
| 23 |
language_preference: str | None = None
|
| 24 |
level: str | None = None
|
|
|
|
| 25 |
|
| 26 |
|
| 27 |
class AskCitation(BaseModel):
|
|
|
|
| 4 |
|
| 5 |
from pydantic import BaseModel, Field
|
| 6 |
|
| 7 |
+
from app.services.workspace_context import ChatOriginPage
|
| 8 |
+
|
| 9 |
|
| 10 |
AskMode = Literal[
|
| 11 |
"explain_simple",
|
|
|
|
| 24 |
mode: AskMode = "explain_simple"
|
| 25 |
language_preference: str | None = None
|
| 26 |
level: str | None = None
|
| 27 |
+
origin_page: ChatOriginPage | None = None
|
| 28 |
|
| 29 |
|
| 30 |
class AskCitation(BaseModel):
|
app/schemas/chat.py
CHANGED
|
@@ -4,6 +4,9 @@ from typing import Optional
|
|
| 4 |
|
| 5 |
from pydantic import BaseModel, Field
|
| 6 |
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
class ChatRequest(BaseModel):
|
| 9 |
message: str = Field(..., min_length=1, max_length=4000)
|
|
@@ -11,6 +14,8 @@ class ChatRequest(BaseModel):
|
|
| 11 |
source_ids: Optional[list[str]] = None
|
| 12 |
subject: Optional[str] = None
|
| 13 |
language: Optional[str] = "English"
|
|
|
|
|
|
|
| 14 |
|
| 15 |
|
| 16 |
class WebCitation(BaseModel):
|
|
|
|
| 4 |
|
| 5 |
from pydantic import BaseModel, Field
|
| 6 |
|
| 7 |
+
from app.schemas.chat_history import StudyChatContextData
|
| 8 |
+
from app.services.workspace_context import ChatOriginPage
|
| 9 |
+
|
| 10 |
|
| 11 |
class ChatRequest(BaseModel):
|
| 12 |
message: str = Field(..., min_length=1, max_length=4000)
|
|
|
|
| 14 |
source_ids: Optional[list[str]] = None
|
| 15 |
subject: Optional[str] = None
|
| 16 |
language: Optional[str] = "English"
|
| 17 |
+
origin_page: ChatOriginPage | None = None
|
| 18 |
+
academic_context: StudyChatContextData | None = None
|
| 19 |
|
| 20 |
|
| 21 |
class WebCitation(BaseModel):
|
app/schemas/chat_history.py
CHANGED
|
@@ -4,21 +4,69 @@ from __future__ import annotations
|
|
| 4 |
from datetime import datetime
|
| 5 |
from typing import Optional
|
| 6 |
|
| 7 |
-
from pydantic import BaseModel, Field
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 8 |
|
| 9 |
|
| 10 |
class CreateSessionRequest(BaseModel):
|
| 11 |
-
source_id: Optional[str] = None
|
| 12 |
-
subject: str = ""
|
| 13 |
-
title: str = "New chat"
|
|
|
|
| 14 |
|
| 15 |
|
| 16 |
class AppendMessagesRequest(BaseModel):
|
| 17 |
"""Append a user+assistant message pair to a session."""
|
| 18 |
user_content: str = Field(..., min_length=1, max_length=8000)
|
| 19 |
assistant_content: str = Field(..., min_length=1, max_length=32000)
|
| 20 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
evidence_label: Optional[str] = Field(None, max_length=255)
|
|
|
|
| 22 |
|
| 23 |
|
| 24 |
class ChatMessageOut(BaseModel):
|
|
@@ -27,6 +75,8 @@ class ChatMessageOut(BaseModel):
|
|
| 27 |
content: str
|
| 28 |
intent: Optional[str] = None
|
| 29 |
evidence_label: Optional[str] = None
|
|
|
|
|
|
|
| 30 |
created_at: datetime
|
| 31 |
|
| 32 |
model_config = {"from_attributes": True}
|
|
@@ -37,6 +87,7 @@ class ChatSessionOut(BaseModel):
|
|
| 37 |
source_id: Optional[str] = None
|
| 38 |
subject: str
|
| 39 |
title: str
|
|
|
|
| 40 |
message_count: int = 0
|
| 41 |
created_at: datetime
|
| 42 |
updated_at: datetime
|
|
@@ -47,6 +98,7 @@ class ChatSessionDetail(BaseModel):
|
|
| 47 |
source_id: Optional[str] = None
|
| 48 |
subject: str
|
| 49 |
title: str
|
|
|
|
| 50 |
messages: list[ChatMessageOut]
|
| 51 |
created_at: datetime
|
| 52 |
updated_at: datetime
|
|
|
|
| 4 |
from datetime import datetime
|
| 5 |
from typing import Optional
|
| 6 |
|
| 7 |
+
from pydantic import BaseModel, ConfigDict, Field, HttpUrl, field_validator
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class PersistedWebCitation(BaseModel):
|
| 11 |
+
"""Bounded safe citation metadata that survives history reloads."""
|
| 12 |
+
|
| 13 |
+
title: str = Field(..., min_length=1, max_length=240)
|
| 14 |
+
url: HttpUrl
|
| 15 |
+
publisher: str = Field(..., min_length=1, max_length=255)
|
| 16 |
+
published_date: Optional[str] = Field(None, max_length=64)
|
| 17 |
+
author: Optional[str] = Field(None, max_length=160)
|
| 18 |
+
snippet: Optional[str] = Field(None, max_length=1000)
|
| 19 |
+
is_official: bool = False
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
class StudyChatContextData(BaseModel):
|
| 23 |
+
"""Bounded structured context for returning to a saved academic thread."""
|
| 24 |
+
|
| 25 |
+
origin: Optional[str] = Field(None, max_length=40)
|
| 26 |
+
subject: Optional[str] = Field(None, max_length=120)
|
| 27 |
+
chapter: Optional[str] = Field(None, max_length=180)
|
| 28 |
+
topic: Optional[str] = Field(None, max_length=180)
|
| 29 |
+
source_id: Optional[str] = Field(None, max_length=40)
|
| 30 |
+
source_ref: Optional[str] = Field(None, max_length=120)
|
| 31 |
+
source_label: Optional[str] = Field(None, max_length=255)
|
| 32 |
+
activity_type: Optional[str] = Field(None, max_length=64)
|
| 33 |
+
question_id: Optional[str] = Field(None, max_length=120)
|
| 34 |
+
question_label: Optional[str] = Field(None, max_length=500)
|
| 35 |
+
return_href: Optional[str] = Field(None, max_length=500)
|
| 36 |
+
captured_at: Optional[datetime] = None
|
| 37 |
+
|
| 38 |
+
model_config = ConfigDict(extra="forbid")
|
| 39 |
+
|
| 40 |
+
@field_validator("return_href")
|
| 41 |
+
@classmethod
|
| 42 |
+
def safe_internal_return_href(cls, value: str | None) -> str | None:
|
| 43 |
+
if value is None:
|
| 44 |
+
return None
|
| 45 |
+
if not value.startswith("/") or value.startswith("//"):
|
| 46 |
+
raise ValueError("return_href must be an internal DocDoe path")
|
| 47 |
+
return value
|
| 48 |
|
| 49 |
|
| 50 |
class CreateSessionRequest(BaseModel):
|
| 51 |
+
source_id: Optional[str] = Field(None, max_length=40)
|
| 52 |
+
subject: str = Field("", max_length=120)
|
| 53 |
+
title: str = Field("New chat", min_length=1, max_length=255)
|
| 54 |
+
context_data: StudyChatContextData | None = None
|
| 55 |
|
| 56 |
|
| 57 |
class AppendMessagesRequest(BaseModel):
|
| 58 |
"""Append a user+assistant message pair to a session."""
|
| 59 |
user_content: str = Field(..., min_length=1, max_length=8000)
|
| 60 |
assistant_content: str = Field(..., min_length=1, max_length=32000)
|
| 61 |
+
client_turn_id: Optional[str] = Field(
|
| 62 |
+
None,
|
| 63 |
+
min_length=8,
|
| 64 |
+
max_length=64,
|
| 65 |
+
pattern=r"^[A-Za-z0-9._:-]+$",
|
| 66 |
+
)
|
| 67 |
+
intent: Optional[str] = Field(None, max_length=40)
|
| 68 |
evidence_label: Optional[str] = Field(None, max_length=255)
|
| 69 |
+
web_sources: list[PersistedWebCitation] = Field(default_factory=list, max_length=10)
|
| 70 |
|
| 71 |
|
| 72 |
class ChatMessageOut(BaseModel):
|
|
|
|
| 75 |
content: str
|
| 76 |
intent: Optional[str] = None
|
| 77 |
evidence_label: Optional[str] = None
|
| 78 |
+
client_turn_id: Optional[str] = None
|
| 79 |
+
web_sources: list[PersistedWebCitation] = Field(default_factory=list)
|
| 80 |
created_at: datetime
|
| 81 |
|
| 82 |
model_config = {"from_attributes": True}
|
|
|
|
| 87 |
source_id: Optional[str] = None
|
| 88 |
subject: str
|
| 89 |
title: str
|
| 90 |
+
context_data: StudyChatContextData | None = None
|
| 91 |
message_count: int = 0
|
| 92 |
created_at: datetime
|
| 93 |
updated_at: datetime
|
|
|
|
| 98 |
source_id: Optional[str] = None
|
| 99 |
subject: str
|
| 100 |
title: str
|
| 101 |
+
context_data: StudyChatContextData | None = None
|
| 102 |
messages: list[ChatMessageOut]
|
| 103 |
created_at: datetime
|
| 104 |
updated_at: datetime
|
app/schemas/learning_state.py
CHANGED
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
| 3 |
from datetime import date, datetime
|
| 4 |
from typing import Any, Literal
|
| 5 |
|
| 6 |
-
from pydantic import BaseModel, ConfigDict, Field, field_validator
|
| 7 |
|
| 8 |
|
| 9 |
class LearningOnboardingRequest(BaseModel):
|
|
@@ -192,6 +192,138 @@ class LearningResourceOut(BaseModel):
|
|
| 192 |
created_at: datetime
|
| 193 |
|
| 194 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 195 |
class LearningStateSummary(BaseModel):
|
| 196 |
profile: LearningProfileOut | None
|
| 197 |
subjects: list[LearningSubjectOut]
|
|
@@ -207,6 +339,8 @@ class LearningStateSummary(BaseModel):
|
|
| 207 |
generated_resources: int
|
| 208 |
generated_notes: int
|
| 209 |
questions_asked: int
|
|
|
|
|
|
|
| 210 |
|
| 211 |
|
| 212 |
class TaskStatusRequest(BaseModel):
|
|
@@ -245,6 +379,7 @@ class LessonProgressResponse(BaseModel):
|
|
| 245 |
|
| 246 |
|
| 247 |
class AssessmentResultRequest(BaseModel):
|
|
|
|
| 248 |
title: str = Field(min_length=1, max_length=180)
|
| 249 |
topic_key: str = Field(min_length=1, max_length=180)
|
| 250 |
topic_label: str = Field(min_length=1, max_length=180)
|
|
@@ -266,6 +401,12 @@ class AssessmentResultRequest(BaseModel):
|
|
| 266 |
|
| 267 |
model_config = ConfigDict(extra="forbid")
|
| 268 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
|
| 270 |
class AssessmentConsequenceResponse(BaseModel):
|
| 271 |
attempt_id: str
|
|
@@ -296,6 +437,8 @@ class PlanAdjustmentRequest(BaseModel):
|
|
| 296 |
|
| 297 |
class PlanAdjustmentResponse(BaseModel):
|
| 298 |
tasks: list[LearningTaskOut]
|
|
|
|
|
|
|
| 299 |
message: str
|
| 300 |
|
| 301 |
|
|
|
|
| 3 |
from datetime import date, datetime
|
| 4 |
from typing import Any, Literal
|
| 5 |
|
| 6 |
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
| 7 |
|
| 8 |
|
| 9 |
class LearningOnboardingRequest(BaseModel):
|
|
|
|
| 192 |
created_at: datetime
|
| 193 |
|
| 194 |
|
| 195 |
+
class AcademicTopicOut(BaseModel):
|
| 196 |
+
"""A student-facing topic state backed by mastery evidence."""
|
| 197 |
+
|
| 198 |
+
topic_key: str
|
| 199 |
+
label: str
|
| 200 |
+
state: str
|
| 201 |
+
score: float
|
| 202 |
+
confidence: float
|
| 203 |
+
subject: str | None = None
|
| 204 |
+
chapter: str | None = None
|
| 205 |
+
next_review_at: datetime | None = None
|
| 206 |
+
|
| 207 |
+
|
| 208 |
+
class AcademicMistakeOut(BaseModel):
|
| 209 |
+
repair_id: str
|
| 210 |
+
topic_key: str
|
| 211 |
+
label: str
|
| 212 |
+
error_category: str
|
| 213 |
+
diagnosis: str
|
| 214 |
+
activity_prompt: str
|
| 215 |
+
subject: str | None = None
|
| 216 |
+
chapter: str | None = None
|
| 217 |
+
mission_id: str | None = None
|
| 218 |
+
occurred_at: datetime
|
| 219 |
+
|
| 220 |
+
|
| 221 |
+
class AcademicActivityOut(BaseModel):
|
| 222 |
+
kind: str
|
| 223 |
+
title: str
|
| 224 |
+
detail: str | None = None
|
| 225 |
+
occurred_at: datetime
|
| 226 |
+
href: str
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
class AcademicTuitionSessionOut(BaseModel):
|
| 230 |
+
class_session_id: str
|
| 231 |
+
subject: str | None = None
|
| 232 |
+
chapter: str | None = None
|
| 233 |
+
chapter_catalog_id: str | None = None
|
| 234 |
+
mission_id: str | None = None
|
| 235 |
+
topic: str | None = None
|
| 236 |
+
current_step_id: str
|
| 237 |
+
current_step_label: str
|
| 238 |
+
progress_percent: int
|
| 239 |
+
updated_at: datetime
|
| 240 |
+
href: str
|
| 241 |
+
|
| 242 |
+
|
| 243 |
+
class AcademicCourseOut(BaseModel):
|
| 244 |
+
roadmap_id: str
|
| 245 |
+
topic: str
|
| 246 |
+
current_module: str | None = None
|
| 247 |
+
completed_topics: int
|
| 248 |
+
total_topics: int
|
| 249 |
+
progress_percent: int
|
| 250 |
+
updated_at: datetime
|
| 251 |
+
href: str
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
class AcademicChatContextOut(BaseModel):
|
| 255 |
+
session_id: str
|
| 256 |
+
subject: str | None = None
|
| 257 |
+
chapter: str | None = None
|
| 258 |
+
topic: str | None = None
|
| 259 |
+
source_id: str | None = None
|
| 260 |
+
source_ref: str | None = None
|
| 261 |
+
source_label: str | None = None
|
| 262 |
+
activity_type: str | None = None
|
| 263 |
+
question_id: str | None = None
|
| 264 |
+
question_label: str | None = None
|
| 265 |
+
return_href: str | None = None
|
| 266 |
+
updated_at: datetime
|
| 267 |
+
href: str
|
| 268 |
+
|
| 269 |
+
|
| 270 |
+
class AcademicNotificationOut(BaseModel):
|
| 271 |
+
"""A deterministic, evidence-triggered notification suggestion."""
|
| 272 |
+
|
| 273 |
+
id: str
|
| 274 |
+
title: str
|
| 275 |
+
body: str
|
| 276 |
+
kind: Literal["info", "success", "warning", "action"]
|
| 277 |
+
href: str
|
| 278 |
+
created_at: datetime
|
| 279 |
+
|
| 280 |
+
|
| 281 |
+
class AcademicStateOut(BaseModel):
|
| 282 |
+
class_level: str | None = None
|
| 283 |
+
board: str | None = None
|
| 284 |
+
subjects: list[str] = Field(default_factory=list)
|
| 285 |
+
exam_date: date | None = None
|
| 286 |
+
days_to_exam: int | None = None
|
| 287 |
+
current_subject: str | None = None
|
| 288 |
+
current_chapter: str | None = None
|
| 289 |
+
current_topic: str | None = None
|
| 290 |
+
last_meaningful_activity: AcademicActivityOut | None = None
|
| 291 |
+
unfinished_lesson: AcademicTuitionSessionOut | None = None
|
| 292 |
+
recent_mistakes: list[AcademicMistakeOut] = Field(default_factory=list)
|
| 293 |
+
weak_topics: list[AcademicTopicOut] = Field(default_factory=list)
|
| 294 |
+
mastered_topics: list[AcademicTopicOut] = Field(default_factory=list)
|
| 295 |
+
revision_due: list[AcademicTopicOut] = Field(default_factory=list)
|
| 296 |
+
active_course: AcademicCourseOut | None = None
|
| 297 |
+
active_tuition_session: AcademicTuitionSessionOut | None = None
|
| 298 |
+
recent_study_chat_context: AcademicChatContextOut | None = None
|
| 299 |
+
suggested_notifications: list[AcademicNotificationOut] = Field(default_factory=list)
|
| 300 |
+
|
| 301 |
+
|
| 302 |
+
class NextAcademicActionOut(BaseModel):
|
| 303 |
+
type: Literal[
|
| 304 |
+
"COMPLETE_SETUP",
|
| 305 |
+
"CONTINUE_LESSON",
|
| 306 |
+
"CORRECT_MISTAKE",
|
| 307 |
+
"PRACTISE_TOPIC",
|
| 308 |
+
"REVISE_TOPIC",
|
| 309 |
+
"START_TODAYS_PLAN",
|
| 310 |
+
"CONTINUE_COURSE",
|
| 311 |
+
"REVIEW_PYQ",
|
| 312 |
+
"ASK_FOLLOWUP",
|
| 313 |
+
"START_NEXT_LESSON",
|
| 314 |
+
]
|
| 315 |
+
title: str
|
| 316 |
+
reason: str
|
| 317 |
+
action_label: str
|
| 318 |
+
subject: str | None = None
|
| 319 |
+
chapter: str | None = None
|
| 320 |
+
topic: str | None = None
|
| 321 |
+
href: str
|
| 322 |
+
resume_payload: dict[str, Any] = Field(default_factory=dict)
|
| 323 |
+
urgency: Literal["low", "normal", "high", "urgent"]
|
| 324 |
+
estimated_minutes: int
|
| 325 |
+
|
| 326 |
+
|
| 327 |
class LearningStateSummary(BaseModel):
|
| 328 |
profile: LearningProfileOut | None
|
| 329 |
subjects: list[LearningSubjectOut]
|
|
|
|
| 339 |
generated_resources: int
|
| 340 |
generated_notes: int
|
| 341 |
questions_asked: int
|
| 342 |
+
academic_state: AcademicStateOut
|
| 343 |
+
next_action: NextAcademicActionOut
|
| 344 |
|
| 345 |
|
| 346 |
class TaskStatusRequest(BaseModel):
|
|
|
|
| 379 |
|
| 380 |
|
| 381 |
class AssessmentResultRequest(BaseModel):
|
| 382 |
+
client_attempt_id: str | None = Field(default=None, min_length=8, max_length=180)
|
| 383 |
title: str = Field(min_length=1, max_length=180)
|
| 384 |
topic_key: str = Field(min_length=1, max_length=180)
|
| 385 |
topic_label: str = Field(min_length=1, max_length=180)
|
|
|
|
| 401 |
|
| 402 |
model_config = ConfigDict(extra="forbid")
|
| 403 |
|
| 404 |
+
@model_validator(mode="after")
|
| 405 |
+
def score_cannot_exceed_maximum(self) -> "AssessmentResultRequest":
|
| 406 |
+
if self.score > self.max_score:
|
| 407 |
+
raise ValueError("score cannot exceed max_score")
|
| 408 |
+
return self
|
| 409 |
+
|
| 410 |
|
| 411 |
class AssessmentConsequenceResponse(BaseModel):
|
| 412 |
attempt_id: str
|
|
|
|
| 437 |
|
| 438 |
class PlanAdjustmentResponse(BaseModel):
|
| 439 |
tasks: list[LearningTaskOut]
|
| 440 |
+
unavailable_chapters: list[str] = Field(default_factory=list)
|
| 441 |
+
rescheduled_tasks: int = 0
|
| 442 |
message: str
|
| 443 |
|
| 444 |
|
app/schemas/student_workspace.py
CHANGED
|
@@ -3,7 +3,7 @@ from __future__ import annotations
|
|
| 3 |
from datetime import datetime
|
| 4 |
from typing import Literal
|
| 5 |
|
| 6 |
-
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
| 7 |
|
| 8 |
|
| 9 |
class WorkspaceClass(BaseModel):
|
|
@@ -107,6 +107,15 @@ class WorkspacePreferences(BaseModel):
|
|
| 107 |
compact_mode: bool = False
|
| 108 |
assistant_enabled: bool = True
|
| 109 |
study_reminders_enabled: bool = True
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 110 |
|
| 111 |
model_config = ConfigDict(extra="forbid")
|
| 112 |
|
|
|
|
| 3 |
from datetime import datetime
|
| 4 |
from typing import Literal
|
| 5 |
|
| 6 |
+
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
| 7 |
|
| 8 |
|
| 9 |
class WorkspaceClass(BaseModel):
|
|
|
|
| 107 |
compact_mode: bool = False
|
| 108 |
assistant_enabled: bool = True
|
| 109 |
study_reminders_enabled: bool = True
|
| 110 |
+
# Keeps deterministic academic alerts dismissible. Without this small UI
|
| 111 |
+
# ledger, a still-open repair would reappear after every refresh.
|
| 112 |
+
dismissed_notification_ids: list[str] = Field(default_factory=list, max_length=200)
|
| 113 |
+
|
| 114 |
+
@field_validator("dismissed_notification_ids")
|
| 115 |
+
@classmethod
|
| 116 |
+
def bounded_dismissed_ids(cls, values: list[str]) -> list[str]:
|
| 117 |
+
cleaned = [value.strip()[:80] for value in values if value.strip()]
|
| 118 |
+
return list(dict.fromkeys(cleaned))
|
| 119 |
|
| 120 |
model_config = ConfigDict(extra="forbid")
|
| 121 |
|
app/schemas/user.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
| 1 |
from datetime import datetime
|
| 2 |
from typing import Literal
|
| 3 |
|
| 4 |
-
from pydantic import BaseModel, ConfigDict
|
| 5 |
|
| 6 |
|
| 7 |
UserRole = Literal["student", "teacher", "admin"]
|
|
@@ -26,6 +26,16 @@ class UserUpdate(BaseModel):
|
|
| 26 |
preferred_language: str | None = None
|
| 27 |
|
| 28 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
class UserRead(BaseModel):
|
| 30 |
id: str
|
| 31 |
name: str
|
|
@@ -49,6 +59,36 @@ class AuthLoginRequest(BaseModel):
|
|
| 49 |
password: str
|
| 50 |
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
class AuthTokenResponse(BaseModel):
|
| 53 |
access_token: str
|
| 54 |
token_type: str = "bearer"
|
|
|
|
| 1 |
from datetime import datetime
|
| 2 |
from typing import Literal
|
| 3 |
|
| 4 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 5 |
|
| 6 |
|
| 7 |
UserRole = Literal["student", "teacher", "admin"]
|
|
|
|
| 26 |
preferred_language: str | None = None
|
| 27 |
|
| 28 |
|
| 29 |
+
class AccountDeleteRequest(BaseModel):
|
| 30 |
+
confirmation: str
|
| 31 |
+
password: str | None = None
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class AccountDeleteResponse(BaseModel):
|
| 35 |
+
deleted: bool
|
| 36 |
+
billing_subscription_canceled: bool = False
|
| 37 |
+
|
| 38 |
+
|
| 39 |
class UserRead(BaseModel):
|
| 40 |
id: str
|
| 41 |
name: str
|
|
|
|
| 59 |
password: str
|
| 60 |
|
| 61 |
|
| 62 |
+
class LoginOtpRequest(BaseModel):
|
| 63 |
+
email: str = Field(min_length=3, max_length=255)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class LoginOtpVerifyRequest(BaseModel):
|
| 67 |
+
email: str = Field(min_length=3, max_length=255)
|
| 68 |
+
code: str = Field(min_length=6, max_length=6)
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class LoginOtpRequestResponse(BaseModel):
|
| 72 |
+
message: str
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
class ForgotPasswordRequest(BaseModel):
|
| 76 |
+
email: str = Field(min_length=3, max_length=255)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
class ForgotPasswordResponse(BaseModel):
|
| 80 |
+
message: str
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
class ResetPasswordRequest(BaseModel):
|
| 84 |
+
token: str = Field(min_length=32, max_length=256)
|
| 85 |
+
password: str = Field(min_length=8, max_length=128)
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
class ResetPasswordResponse(BaseModel):
|
| 89 |
+
reset: bool
|
| 90 |
+
|
| 91 |
+
|
| 92 |
class AuthTokenResponse(BaseModel):
|
| 93 |
access_token: str
|
| 94 |
token_type: str = "bearer"
|
{backend/app → app}/services/academic_state.py
RENAMED
|
File without changes
|
{backend/app → app}/services/account_deletion.py
RENAMED
|
File without changes
|
app/services/ai_provider.py
CHANGED
|
@@ -529,19 +529,50 @@ def _is_physics_topic(*, subject: str | None, topic: str | None, context: str |
|
|
| 529 |
)
|
| 530 |
|
| 531 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 532 |
def _long_answer_item(topic: str, snippet: str, keywords: list[str]) -> dict[str, Any]:
|
| 533 |
-
|
| 534 |
-
|
| 535 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 536 |
return {
|
| 537 |
"question": f"Write a 5/6-mark long answer on {topic}.",
|
| 538 |
"answer": (
|
| 539 |
f"Introduction: {topic} is a board-level scoring concept. "
|
| 540 |
f"Law/principle: state the central principle clearly. Formula: {formula}. "
|
| 541 |
"Explanation: define each symbol, show how the change/process produces the result, "
|
| 542 |
-
"and connect it to the exam keyword.
|
| 543 |
-
"
|
| 544 |
-
"transformer, or a real chapter example. Conclusion: end with the key principle."
|
| 545 |
),
|
| 546 |
"intro": f"{topic} is an important concept for long-answer board questions.",
|
| 547 |
"main_points": [
|
|
@@ -549,11 +580,11 @@ def _long_answer_item(topic: str, snippet: str, keywords: list[str]) -> dict[str
|
|
| 549 |
"Law/principle stated exactly",
|
| 550 |
f"Formula with units: {formula}",
|
| 551 |
"Explanation of each symbol and physical meaning",
|
| 552 |
-
|
| 553 |
-
"
|
| 554 |
],
|
| 555 |
"conclusion": f"Underline: {', '.join(keywords[:5])}. Avoid missing the sign/unit/diagram labels.",
|
| 556 |
-
"diagram_needed":
|
| 557 |
}
|
| 558 |
|
| 559 |
|
|
@@ -698,11 +729,14 @@ def _ensure_quiz_quality(
|
|
| 698 |
)
|
| 699 |
if not any(token in joined for token in ("numerical", "calculate", "diagram", "process", "draw")):
|
| 700 |
questions[-1] = {
|
| 701 |
-
"question": "Draw or
|
| 702 |
-
"type": "
|
| 703 |
"options": [],
|
| 704 |
-
"answer":
|
| 705 |
-
|
|
|
|
|
|
|
|
|
|
| 706 |
"difficulty": difficulty,
|
| 707 |
"skill": "diagram/process",
|
| 708 |
"topic": topic,
|
|
@@ -748,8 +782,16 @@ def _ensure_exam_long_answer(
|
|
| 748 |
combined = " ".join(str(value) for value in item.values()).lower()
|
| 749 |
answer = str(item.get("answer") or "")
|
| 750 |
additions: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
|
| 751 |
if "formula" not in combined:
|
| 752 |
-
additions.append(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 753 |
if "keyword" not in combined:
|
| 754 |
additions.append(f"Keywords to underline: {', '.join(keywords[:5])}.")
|
| 755 |
if "diagram" not in combined and _is_physics_topic(
|
|
@@ -759,6 +801,8 @@ def _ensure_exam_long_answer(
|
|
| 759 |
):
|
| 760 |
additions.append(
|
| 761 |
"Diagram guidance: draw and label magnet, coil, galvanometer, field lines, motion arrow, and induced current."
|
|
|
|
|
|
|
| 762 |
)
|
| 763 |
item.setdefault("diagram_needed", True)
|
| 764 |
if additions:
|
|
@@ -1016,68 +1060,96 @@ class MockAIProvider(BaseAIProvider):
|
|
| 1016 |
question_count: int,
|
| 1017 |
metadata: dict[str, Any] | None = None,
|
| 1018 |
) -> dict[str, Any]:
|
| 1019 |
-
|
| 1020 |
-
keywords = _keyword_candidates(context)
|
| 1021 |
topic = _topic_from_metadata_or_text(metadata, context)
|
| 1022 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1023 |
is_physics = _is_physics_topic(subject=subject, topic=topic, context=context)
|
|
|
|
| 1024 |
questions = [
|
| 1025 |
{
|
| 1026 |
-
"question": f"Which keyword best matches this
|
| 1027 |
"type": "mcq",
|
| 1028 |
"options": [keywords[0], keywords[1], keywords[2], keywords[3]],
|
| 1029 |
"answer": keywords[0],
|
| 1030 |
-
"explanation": "This keyword
|
| 1031 |
"difficulty": difficulty,
|
| 1032 |
"skill": "recall",
|
| 1033 |
"topic": keywords[0],
|
| 1034 |
},
|
| 1035 |
{
|
| 1036 |
-
"question": "Write one exam keyword
|
| 1037 |
"type": "short",
|
| 1038 |
"options": [],
|
| 1039 |
"answer": keywords[1],
|
| 1040 |
-
"explanation": "Short answers should use exact
|
| 1041 |
"difficulty": difficulty,
|
| 1042 |
"skill": "understanding",
|
| 1043 |
"topic": keywords[1],
|
| 1044 |
},
|
| 1045 |
{
|
| 1046 |
-
"question": f"True or false:
|
| 1047 |
"type": "true_false",
|
| 1048 |
"options": ["True", "False"],
|
| 1049 |
"answer": "True",
|
| 1050 |
-
"explanation": "This is checked
|
| 1051 |
"difficulty": difficulty,
|
| 1052 |
"skill": "tricky mistake",
|
| 1053 |
"topic": keywords[2],
|
| 1054 |
},
|
| 1055 |
]
|
| 1056 |
-
|
| 1057 |
-
|
| 1058 |
-
|
| 1059 |
-
"
|
| 1060 |
-
|
| 1061 |
-
|
| 1062 |
-
|
| 1063 |
-
|
| 1064 |
-
|
| 1065 |
-
|
| 1066 |
-
|
| 1067 |
-
|
| 1068 |
-
|
| 1069 |
-
|
| 1070 |
-
|
| 1071 |
-
|
| 1072 |
-
|
| 1073 |
-
|
| 1074 |
-
|
| 1075 |
-
|
| 1076 |
-
|
| 1077 |
-
|
| 1078 |
-
|
| 1079 |
-
|
| 1080 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1081 |
if is_physics:
|
| 1082 |
if question_count <= 3:
|
| 1083 |
questions = [questions[0], *physics_questions]
|
|
@@ -1090,8 +1162,8 @@ class MockAIProvider(BaseAIProvider):
|
|
| 1090 |
"question": f"Why is {keyword} important for exam answers?",
|
| 1091 |
"type": "short",
|
| 1092 |
"options": [],
|
| 1093 |
-
"answer": f"{keyword} is an important
|
| 1094 |
-
"explanation": "Use the exact term
|
| 1095 |
"difficulty": difficulty,
|
| 1096 |
"skill": "exam",
|
| 1097 |
"topic": keyword,
|
|
|
|
| 529 |
)
|
| 530 |
|
| 531 |
|
| 532 |
+
def _is_electromagnetic_induction_topic(
|
| 533 |
+
*, topic: str | None, context: str | None
|
| 534 |
+
) -> bool:
|
| 535 |
+
haystack = " ".join([topic or "", context or ""]).lower()
|
| 536 |
+
return any(
|
| 537 |
+
token in haystack
|
| 538 |
+
for token in (
|
| 539 |
+
"electromagnetic induction",
|
| 540 |
+
"faraday",
|
| 541 |
+
"lenz",
|
| 542 |
+
"magnetic flux",
|
| 543 |
+
"induced emf",
|
| 544 |
+
)
|
| 545 |
+
)
|
| 546 |
+
|
| 547 |
+
|
| 548 |
def _long_answer_item(topic: str, snippet: str, keywords: list[str]) -> dict[str, Any]:
|
| 549 |
+
is_electromagnetic_induction = _is_electromagnetic_induction_topic(
|
| 550 |
+
topic=topic,
|
| 551 |
+
context=snippet,
|
| 552 |
+
)
|
| 553 |
+
formula = (
|
| 554 |
+
"epsilon = -N(dPhi/dt) [SI unit: Volt (V)]"
|
| 555 |
+
if is_electromagnetic_induction
|
| 556 |
+
else "Use the governing formula or labelled process for this topic, with SI units where applicable."
|
| 557 |
+
)
|
| 558 |
+
diagram_guidance = (
|
| 559 |
+
"Diagram guidance: label the magnet, coil, galvanometer, field lines, motion arrow, and induced current."
|
| 560 |
+
if is_electromagnetic_induction
|
| 561 |
+
else f"Diagram guidance: use a labelled {topic} diagram only when the question requires one."
|
| 562 |
+
)
|
| 563 |
+
application_guidance = (
|
| 564 |
+
"Application: mention one use such as a generator or transformer."
|
| 565 |
+
if is_electromagnetic_induction
|
| 566 |
+
else "Application: connect the idea to one relevant chapter example."
|
| 567 |
+
)
|
| 568 |
return {
|
| 569 |
"question": f"Write a 5/6-mark long answer on {topic}.",
|
| 570 |
"answer": (
|
| 571 |
f"Introduction: {topic} is a board-level scoring concept. "
|
| 572 |
f"Law/principle: state the central principle clearly. Formula: {formula}. "
|
| 573 |
"Explanation: define each symbol, show how the change/process produces the result, "
|
| 574 |
+
f"and connect it to the exam keyword. {diagram_guidance} "
|
| 575 |
+
f"{application_guidance} Conclusion: end with the key principle."
|
|
|
|
| 576 |
),
|
| 577 |
"intro": f"{topic} is an important concept for long-answer board questions.",
|
| 578 |
"main_points": [
|
|
|
|
| 580 |
"Law/principle stated exactly",
|
| 581 |
f"Formula with units: {formula}",
|
| 582 |
"Explanation of each symbol and physical meaning",
|
| 583 |
+
diagram_guidance,
|
| 584 |
+
f"{application_guidance} Add a conclusion.",
|
| 585 |
],
|
| 586 |
"conclusion": f"Underline: {', '.join(keywords[:5])}. Avoid missing the sign/unit/diagram labels.",
|
| 587 |
+
"diagram_needed": is_electromagnetic_induction,
|
| 588 |
}
|
| 589 |
|
| 590 |
|
|
|
|
| 729 |
)
|
| 730 |
if not any(token in joined for token in ("numerical", "calculate", "diagram", "process", "draw")):
|
| 731 |
questions[-1] = {
|
| 732 |
+
"question": f"Draw or describe one labelled representation that helps explain {topic}.",
|
| 733 |
+
"type": "application/process",
|
| 734 |
"options": [],
|
| 735 |
+
"answer": (
|
| 736 |
+
f"Use only labels that belong to {topic}, and show the relevant direction, change, or relationship. "
|
| 737 |
+
"If the topic has no useful diagram, describe one observable application instead."
|
| 738 |
+
),
|
| 739 |
+
"explanation": "The representation must stay scoped to the requested topic.",
|
| 740 |
"difficulty": difficulty,
|
| 741 |
"skill": "diagram/process",
|
| 742 |
"topic": topic,
|
|
|
|
| 782 |
combined = " ".join(str(value) for value in item.values()).lower()
|
| 783 |
answer = str(item.get("answer") or "")
|
| 784 |
additions: list[str] = []
|
| 785 |
+
is_electromagnetic_induction = _is_electromagnetic_induction_topic(
|
| 786 |
+
topic=topic,
|
| 787 |
+
context=context,
|
| 788 |
+
)
|
| 789 |
if "formula" not in combined:
|
| 790 |
+
additions.append(
|
| 791 |
+
"Formula: epsilon = -N(dPhi/dt) [Volt]."
|
| 792 |
+
if is_electromagnetic_induction
|
| 793 |
+
else "Formula: state the governing relationship for this topic and give SI units where applicable."
|
| 794 |
+
)
|
| 795 |
if "keyword" not in combined:
|
| 796 |
additions.append(f"Keywords to underline: {', '.join(keywords[:5])}.")
|
| 797 |
if "diagram" not in combined and _is_physics_topic(
|
|
|
|
| 801 |
):
|
| 802 |
additions.append(
|
| 803 |
"Diagram guidance: draw and label magnet, coil, galvanometer, field lines, motion arrow, and induced current."
|
| 804 |
+
if is_electromagnetic_induction
|
| 805 |
+
else f"Diagram guidance: include a topic-relevant labelled diagram for {topic} only when it helps answer the question."
|
| 806 |
)
|
| 807 |
item.setdefault("diagram_needed", True)
|
| 808 |
if additions:
|
|
|
|
| 1060 |
question_count: int,
|
| 1061 |
metadata: dict[str, Any] | None = None,
|
| 1062 |
) -> dict[str, Any]:
|
| 1063 |
+
metadata = metadata or {}
|
|
|
|
| 1064 |
topic = _topic_from_metadata_or_text(metadata, context)
|
| 1065 |
+
has_source = bool(metadata.get("source_title"))
|
| 1066 |
+
quiz_context = context if has_source else topic
|
| 1067 |
+
snippet = _study_snippet(quiz_context)
|
| 1068 |
+
keywords = _keyword_candidates(quiz_context)
|
| 1069 |
+
subject = str(metadata.get("subject") or "")
|
| 1070 |
is_physics = _is_physics_topic(subject=subject, topic=topic, context=context)
|
| 1071 |
+
scope_label = "selected material" if has_source else "requested topic"
|
| 1072 |
questions = [
|
| 1073 |
{
|
| 1074 |
+
"question": f"Which keyword best matches this {scope_label}: {snippet[:90]}?",
|
| 1075 |
"type": "mcq",
|
| 1076 |
"options": [keywords[0], keywords[1], keywords[2], keywords[3]],
|
| 1077 |
"answer": keywords[0],
|
| 1078 |
+
"explanation": f"This keyword anchors the {scope_label}.",
|
| 1079 |
"difficulty": difficulty,
|
| 1080 |
"skill": "recall",
|
| 1081 |
"topic": keywords[0],
|
| 1082 |
},
|
| 1083 |
{
|
| 1084 |
+
"question": f"Write one exam keyword for {topic}.",
|
| 1085 |
"type": "short",
|
| 1086 |
"options": [],
|
| 1087 |
"answer": keywords[1],
|
| 1088 |
+
"explanation": "Short answers should use exact topic keywords.",
|
| 1089 |
"difficulty": difficulty,
|
| 1090 |
"skill": "understanding",
|
| 1091 |
"topic": keywords[1],
|
| 1092 |
},
|
| 1093 |
{
|
| 1094 |
+
"question": f"True or false: {keywords[2]} is included in this {scope_label}.",
|
| 1095 |
"type": "true_false",
|
| 1096 |
"options": ["True", "False"],
|
| 1097 |
"answer": "True",
|
| 1098 |
+
"explanation": f"This is checked against the {scope_label}.",
|
| 1099 |
"difficulty": difficulty,
|
| 1100 |
"skill": "tricky mistake",
|
| 1101 |
"topic": keywords[2],
|
| 1102 |
},
|
| 1103 |
]
|
| 1104 |
+
if _is_electromagnetic_induction_topic(topic=topic, context=quiz_context):
|
| 1105 |
+
physics_questions = [
|
| 1106 |
+
{
|
| 1107 |
+
"question": (
|
| 1108 |
+
"A coil has 50 turns and magnetic flux changes from 0.04 Wb "
|
| 1109 |
+
"to 0 Wb in 0.2 s. Calculate the induced EMF."
|
| 1110 |
+
),
|
| 1111 |
+
"type": "numerical",
|
| 1112 |
+
"options": [],
|
| 1113 |
+
"answer": "10 V using epsilon = -N(delta phi / delta t) [SI unit: Volt].",
|
| 1114 |
+
"explanation": "Use Faraday's law: epsilon = -50 x (0 - 0.04) / 0.2 = 10 V.",
|
| 1115 |
+
"difficulty": difficulty,
|
| 1116 |
+
"skill": "numerical/application",
|
| 1117 |
+
"topic": topic,
|
| 1118 |
+
},
|
| 1119 |
+
{
|
| 1120 |
+
"question": "Draw the labelled magnet-coil-galvanometer setup for electromagnetic induction.",
|
| 1121 |
+
"type": "diagram/process",
|
| 1122 |
+
"options": [],
|
| 1123 |
+
"answer": "Show bar magnet, coil, galvanometer, field lines, motion arrow, and induced current arrow.",
|
| 1124 |
+
"explanation": "The labels show how changing magnetic flux produces an induced current.",
|
| 1125 |
+
"difficulty": difficulty,
|
| 1126 |
+
"skill": "diagram/process",
|
| 1127 |
+
"topic": topic,
|
| 1128 |
+
},
|
| 1129 |
+
]
|
| 1130 |
+
else:
|
| 1131 |
+
physics_questions = [
|
| 1132 |
+
{
|
| 1133 |
+
"question": f"Set up one numerical or quantitative application of {topic}. What must be written before substitution?",
|
| 1134 |
+
"type": "numerical method",
|
| 1135 |
+
"options": [],
|
| 1136 |
+
"answer": "Write the given values, the required quantity, the topic-specific formula or relationship, and convert every value to SI units before substituting.",
|
| 1137 |
+
"explanation": f"This checks a safe solving method without importing a formula from an unrelated Physics chapter into {topic}.",
|
| 1138 |
+
"difficulty": difficulty,
|
| 1139 |
+
"skill": "numerical/application",
|
| 1140 |
+
"topic": topic,
|
| 1141 |
+
},
|
| 1142 |
+
{
|
| 1143 |
+
"question": f"Draw or describe one labelled representation that helps explain {topic}.",
|
| 1144 |
+
"type": "application/process",
|
| 1145 |
+
"options": [],
|
| 1146 |
+
"answer": f"Use only quantities, directions, or parts that belong to {topic}; if no diagram is useful, describe one observable application instead.",
|
| 1147 |
+
"explanation": "Every label and step must stay relevant to the requested topic.",
|
| 1148 |
+
"difficulty": difficulty,
|
| 1149 |
+
"skill": "application/process",
|
| 1150 |
+
"topic": topic,
|
| 1151 |
+
},
|
| 1152 |
+
]
|
| 1153 |
if is_physics:
|
| 1154 |
if question_count <= 3:
|
| 1155 |
questions = [questions[0], *physics_questions]
|
|
|
|
| 1162 |
"question": f"Why is {keyword} important for exam answers?",
|
| 1163 |
"type": "short",
|
| 1164 |
"options": [],
|
| 1165 |
+
"answer": f"{keyword} is an important keyword for {topic}.",
|
| 1166 |
+
"explanation": "Use the exact topic term in the answer.",
|
| 1167 |
"difficulty": difficulty,
|
| 1168 |
"skill": "exam",
|
| 1169 |
"topic": keyword,
|
{backend/app → app}/services/chemistry_curriculum_repository.py
RENAMED
|
File without changes
|
{backend/app → app}/services/document_deletion.py
RENAMED
|
File without changes
|
{backend/app → app}/services/email_service.py
RENAMED
|
File without changes
|
app/services/file_storage.py
CHANGED
|
@@ -15,11 +15,6 @@ _MAX_UPLOAD_BYTES = 20 * 1024 * 1024
|
|
| 15 |
_ALLOWED_MIME_TYPES: frozenset[str] = frozenset(
|
| 16 |
{
|
| 17 |
"application/pdf",
|
| 18 |
-
"image/jpeg",
|
| 19 |
-
"image/jpg",
|
| 20 |
-
"image/png",
|
| 21 |
-
"image/webp",
|
| 22 |
-
"image/gif",
|
| 23 |
"text/plain",
|
| 24 |
}
|
| 25 |
)
|
|
@@ -48,7 +43,8 @@ def save_upload_file(upload_file: UploadFile, upload_dir: Path) -> StoredFile:
|
|
| 48 |
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
| 49 |
detail=(
|
| 50 |
f"File type '{content_type}' is not supported. "
|
| 51 |
-
"Please upload a PDF
|
|
|
|
| 52 |
),
|
| 53 |
)
|
| 54 |
|
|
@@ -82,7 +78,7 @@ def save_upload_file(upload_file: UploadFile, upload_dir: Path) -> StoredFile:
|
|
| 82 |
if too_large:
|
| 83 |
destination.unlink(missing_ok=True)
|
| 84 |
raise HTTPException(
|
| 85 |
-
status_code=status.
|
| 86 |
detail=(
|
| 87 |
f"File exceeds the 20 MB limit "
|
| 88 |
f"({total_bytes / (1024 * 1024):.1f} MB uploaded so far). "
|
|
|
|
| 15 |
_ALLOWED_MIME_TYPES: frozenset[str] = frozenset(
|
| 16 |
{
|
| 17 |
"application/pdf",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
"text/plain",
|
| 19 |
}
|
| 20 |
)
|
|
|
|
| 43 |
status_code=status.HTTP_415_UNSUPPORTED_MEDIA_TYPE,
|
| 44 |
detail=(
|
| 45 |
f"File type '{content_type}' is not supported. "
|
| 46 |
+
"Please upload a selectable-text PDF or plain text file. "
|
| 47 |
+
"Image OCR and Word document extraction are not available yet."
|
| 48 |
),
|
| 49 |
)
|
| 50 |
|
|
|
|
| 78 |
if too_large:
|
| 79 |
destination.unlink(missing_ok=True)
|
| 80 |
raise HTTPException(
|
| 81 |
+
status_code=status.HTTP_413_CONTENT_TOO_LARGE,
|
| 82 |
detail=(
|
| 83 |
f"File exceeds the 20 MB limit "
|
| 84 |
f"({total_bytes / (1024 * 1024):.1f} MB uploaded so far). "
|
app/services/learn_lesson_builder.py
CHANGED
|
@@ -25,18 +25,23 @@ import logging
|
|
| 25 |
import os
|
| 26 |
import re
|
| 27 |
import subprocess
|
|
|
|
| 28 |
import urllib.error
|
| 29 |
import urllib.request
|
| 30 |
from dataclasses import dataclass, field
|
| 31 |
from pathlib import Path
|
| 32 |
from typing import Any
|
| 33 |
|
| 34 |
-
from app.core.config import PROJECT_ROOT, get_settings
|
| 35 |
|
| 36 |
logger = logging.getLogger(__name__)
|
| 37 |
|
| 38 |
-
#
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
GROQ_URL = "https://api.groq.com/openai/v1/chat/completions"
|
| 42 |
# llama-4-scout was retired from Groq; 3.3-70b is the strongest current chat model.
|
|
@@ -49,6 +54,11 @@ TARGET_BEATS = 16
|
|
| 49 |
MIN_BEATS = 12
|
| 50 |
MAX_BEATS = 20
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
class LessonBuildError(RuntimeError):
|
| 54 |
pass
|
|
@@ -78,13 +88,19 @@ def _env(name: str) -> str:
|
|
| 78 |
path = PROJECT_ROOT / filename
|
| 79 |
if not path.exists():
|
| 80 |
continue
|
| 81 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 82 |
if line.startswith(f"{name}="):
|
| 83 |
return _clean_env_value(line.split("=", 1)[1])
|
| 84 |
return ""
|
| 85 |
|
| 86 |
|
| 87 |
-
def lesson_hash(
|
|
|
|
|
|
|
| 88 |
payload = json.dumps(
|
| 89 |
{
|
| 90 |
"topic": topic.strip().lower(),
|
|
@@ -99,75 +115,149 @@ def lesson_hash(topic: str, lesson_title: str, level: str, medium: str, voice: s
|
|
| 99 |
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:20]
|
| 100 |
|
| 101 |
|
| 102 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
payload = {
|
| 104 |
-
"model":
|
| 105 |
"messages": [{"role": "user", "content": prompt}],
|
| 106 |
"temperature": 0.35,
|
| 107 |
"max_tokens": max_tokens,
|
| 108 |
"response_format": {"type": "json_object"},
|
| 109 |
}
|
| 110 |
request = urllib.request.Request(
|
| 111 |
-
|
| 112 |
data=json.dumps(payload).encode("utf-8"),
|
| 113 |
headers={
|
| 114 |
"Content-Type": "application/json",
|
| 115 |
"Authorization": f"Bearer {api_key}",
|
| 116 |
-
#
|
| 117 |
-
"User-Agent": "
|
| 118 |
},
|
| 119 |
method="POST",
|
| 120 |
)
|
| 121 |
-
|
| 122 |
-
|
| 123 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
|
| 126 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
medium_rule = (
|
| 128 |
"Write the spoken narration in natural spoken Malayalam mixed with English technical terms "
|
| 129 |
"(the way a Kerala tuition teacher actually talks: Malayalam sentences, English kept for "
|
| 130 |
-
"technical vocabulary). Keep board_heading and
|
| 131 |
if medium.strip().lower() in {"malayalam", "manglish", "ml"}
|
| 132 |
else "Write the spoken narration in clear, simple spoken Indian English."
|
| 133 |
)
|
| 134 |
-
return f"""You are
|
| 135 |
|
| 136 |
Lesson: "{lesson_title}"
|
| 137 |
Part of learning: "{topic}"
|
| 138 |
Learner level: {level or "beginner"}
|
| 139 |
{medium_rule}
|
| 140 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
TEACHING RULES
|
| 142 |
-
-
|
| 143 |
-
-
|
| 144 |
-
- CRITICAL LENGTH RULE: each beat's narration MUST be 90-
|
| 145 |
-
- The board is what appears on screen
|
| 146 |
-
- Total narration across all beats MUST be about
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 147 |
|
| 148 |
Return ONE JSON object, no markdown:
|
| 149 |
{{
|
| 150 |
"lesson_title": "{lesson_title}",
|
|
|
|
|
|
|
| 151 |
"beats": [
|
| 152 |
-
{{"kind": "hook|explain|example|analogy|checkpoint|recap", "narration": "what the teacher says", "board_heading": "short title", "board_lines": ["tight
|
| 153 |
],
|
| 154 |
-
"notes": ["
|
| 155 |
-
"flashcards": [{{"front": "question", "back": "answer"}}]
|
| 156 |
}}
|
| 157 |
|
| 158 |
-
Make {MIN_BEATS}-{MAX_BEATS} beats (aim for {TARGET_BEATS}) and 6-10 flashcards.
|
| 159 |
-
{f
|
| 160 |
"""
|
| 161 |
|
| 162 |
|
| 163 |
-
def _continue_prompt(
|
|
|
|
|
|
|
| 164 |
medium_rule = (
|
| 165 |
"Continue in natural spoken Malayalam mixed with English technical terms; keep board text in English."
|
| 166 |
if medium.strip().lower() in {"malayalam", "manglish", "ml"}
|
| 167 |
else "Continue in clear, simple spoken Indian English."
|
| 168 |
)
|
| 169 |
already = "; ".join(taught_headings)
|
| 170 |
-
return f"""You are continuing a live ~10-minute class on "{lesson_title}" (part of "{topic}", learner level {level or
|
| 171 |
{medium_rule}
|
| 172 |
|
| 173 |
So far you have already taught these beats: {already}.
|
|
@@ -188,14 +278,266 @@ Write 6-9 more beats and 6-10 flashcards.
|
|
| 188 |
"""
|
| 189 |
|
| 190 |
|
| 191 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 192 |
last_error: Exception | None = None
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 199 |
raise LessonBuildError(f"Lesson {label} generation failed: {last_error}")
|
| 200 |
|
| 201 |
|
|
@@ -211,23 +553,47 @@ def generate_lesson_script(
|
|
| 211 |
medium: str = "english",
|
| 212 |
context: str = "",
|
| 213 |
) -> dict[str, Any]:
|
| 214 |
-
|
| 215 |
-
if not
|
| 216 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 217 |
|
| 218 |
-
data = _generate_with_retries(_lesson_prompt(topic, lesson_title, level, medium, context), api_key, "script")
|
| 219 |
beats = list(data.get("beats") or [])
|
| 220 |
if not beats:
|
| 221 |
-
|
|
|
|
|
|
|
| 222 |
|
| 223 |
# The model reliably writes ~4-5 minutes in one call, then stops. To reach a
|
| 224 |
# real ~10-minute class, ask it to continue from what it already taught.
|
| 225 |
# One continuation is enough; this stays a cheap 2-call generation.
|
| 226 |
if _narration_words(beats) < 950:
|
| 227 |
try:
|
| 228 |
-
taught = [
|
|
|
|
|
|
|
| 229 |
more = _generate_with_retries(
|
| 230 |
-
_continue_prompt(topic, lesson_title, level, medium, taught),
|
|
|
|
|
|
|
| 231 |
)
|
| 232 |
beats.extend(more.get("beats") or [])
|
| 233 |
# Prefer the fuller notes/flashcards from whichever pass gave more.
|
|
@@ -246,7 +612,10 @@ def _deepgram_synthesize(text: str, out_path: Path, api_key: str, model: str) ->
|
|
| 246 |
request = urllib.request.Request(
|
| 247 |
url,
|
| 248 |
data=json.dumps({"text": text}).encode("utf-8"),
|
| 249 |
-
headers={
|
|
|
|
|
|
|
|
|
|
| 250 |
method="POST",
|
| 251 |
)
|
| 252 |
with urllib.request.urlopen(request, timeout=120) as response:
|
|
@@ -257,7 +626,19 @@ def _deepgram_synthesize(text: str, out_path: Path, api_key: str, model: str) ->
|
|
| 257 |
pcm_path = out_path.with_suffix(".pcm")
|
| 258 |
pcm_path.write_bytes(audio)
|
| 259 |
subprocess.run(
|
| 260 |
-
[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
check=True,
|
| 262 |
capture_output=True,
|
| 263 |
)
|
|
@@ -266,7 +647,16 @@ def _deepgram_synthesize(text: str, out_path: Path, api_key: str, model: str) ->
|
|
| 266 |
|
| 267 |
def _probe_duration(path: Path) -> float:
|
| 268 |
completed = subprocess.run(
|
| 269 |
-
[
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 270 |
capture_output=True,
|
| 271 |
check=True,
|
| 272 |
text=True,
|
|
@@ -274,9 +664,10 @@ def _probe_duration(path: Path) -> float:
|
|
| 274 |
return round(float(completed.stdout.strip()), 3)
|
| 275 |
|
| 276 |
|
| 277 |
-
def synthesize_beats(
|
|
|
|
|
|
|
| 278 |
"""Voice each beat. Deepgram for English; AI4Bharat for Malayalam medium."""
|
| 279 |
-
settings = get_settings()
|
| 280 |
use_malayalam = medium.strip().lower() in {"malayalam", "ml"}
|
| 281 |
result: list[LessonBeat] = []
|
| 282 |
cursor = 0.0
|
|
@@ -291,7 +682,9 @@ def synthesize_beats(beats: list[dict[str, Any]], out_dir: Path, medium: str) ->
|
|
| 291 |
api_key = _env("DOCDOE_TTS_API_KEY")
|
| 292 |
model = _env("DOCDOE_TTS_MODEL") or "aura-luna-en"
|
| 293 |
if not api_key:
|
| 294 |
-
raise LessonBuildError(
|
|
|
|
|
|
|
| 295 |
|
| 296 |
for index, beat in enumerate(beats, start=1):
|
| 297 |
narration = str(beat.get("narration", "")).strip()
|
|
@@ -300,9 +693,18 @@ def synthesize_beats(beats: list[dict[str, Any]], out_dir: Path, medium: str) ->
|
|
| 300 |
audio_path = out_dir / f"beat-{index:02d}.wav"
|
| 301 |
if use_malayalam:
|
| 302 |
provider, scene_cls = ai4bharat_provider
|
| 303 |
-
scene = scene_cls(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 304 |
synth = provider.generate_scene_audio(
|
| 305 |
-
scene=scene,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
)
|
| 307 |
if synth.file_path != audio_path:
|
| 308 |
Path(synth.file_path).replace(audio_path)
|
|
@@ -325,6 +727,97 @@ def synthesize_beats(beats: list[dict[str, Any]], out_dir: Path, medium: str) ->
|
|
| 325 |
return result
|
| 326 |
|
| 327 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 328 |
def build_lesson(
|
| 329 |
*,
|
| 330 |
topic: str,
|
|
@@ -334,42 +827,127 @@ def build_lesson(
|
|
| 334 |
context: str = "",
|
| 335 |
force: bool = False,
|
| 336 |
) -> dict[str, Any]:
|
| 337 |
-
"""Full pipeline: script -> audio -> playable manifest, cached by content hash.
|
| 338 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 339 |
key = lesson_hash(topic, lesson_title, level, medium, voice)
|
| 340 |
-
|
|
|
|
| 341 |
manifest_path = out_dir / "lesson.json"
|
| 342 |
|
| 343 |
if manifest_path.exists() and not force:
|
| 344 |
return json.loads(manifest_path.read_text(encoding="utf-8"))
|
| 345 |
|
| 346 |
-
|
| 347 |
-
|
| 348 |
-
|
| 349 |
-
|
| 350 |
-
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
| 366 |
-
|
| 367 |
-
|
| 368 |
-
|
| 369 |
-
|
| 370 |
-
|
| 371 |
-
|
| 372 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 373 |
|
| 374 |
|
| 375 |
def _slugify(value: str) -> str:
|
|
@@ -379,7 +957,9 @@ def _slugify(value: str) -> str:
|
|
| 379 |
if __name__ == "__main__":
|
| 380 |
import argparse
|
| 381 |
|
| 382 |
-
parser = argparse.ArgumentParser(
|
|
|
|
|
|
|
| 383 |
parser.add_argument("--topic", required=True)
|
| 384 |
parser.add_argument("--lesson", required=True)
|
| 385 |
parser.add_argument("--level", default="beginner")
|
|
|
|
| 25 |
import os
|
| 26 |
import re
|
| 27 |
import subprocess
|
| 28 |
+
import threading
|
| 29 |
import urllib.error
|
| 30 |
import urllib.request
|
| 31 |
from dataclasses import dataclass, field
|
| 32 |
from pathlib import Path
|
| 33 |
from typing import Any
|
| 34 |
|
| 35 |
+
from app.core.config import BACKEND_DIR, PROJECT_ROOT, get_settings
|
| 36 |
|
| 37 |
logger = logging.getLogger(__name__)
|
| 38 |
|
| 39 |
+
# Writable cache for lesson manifests + audio.
|
| 40 |
+
# NEVER use PROJECT_ROOT/"public" — on HF Docker WORKDIR=/app, that resolves to
|
| 41 |
+
# /public which is not creatable (Permission denied → 500).
|
| 42 |
+
# Prefer: LEARN_LESSON_CACHE_DIR → /app/generated/... → backend/generated/...
|
| 43 |
+
# Tests may monkeypatch PUBLIC_ROOT.
|
| 44 |
+
PUBLIC_ROOT = BACKEND_DIR / "generated" / "learn-anything"
|
| 45 |
|
| 46 |
GROQ_URL = "https://api.groq.com/openai/v1/chat/completions"
|
| 47 |
# llama-4-scout was retired from Groq; 3.3-70b is the strongest current chat model.
|
|
|
|
| 54 |
MIN_BEATS = 12
|
| 55 |
MAX_BEATS = 20
|
| 56 |
|
| 57 |
+
# Prevent stampede: many students opening the same uncached lesson at once
|
| 58 |
+
# should share one authoring job, not N parallel LLM bills.
|
| 59 |
+
_lesson_build_locks: dict[str, threading.Lock] = {}
|
| 60 |
+
_lesson_build_locks_guard = threading.Lock()
|
| 61 |
+
|
| 62 |
|
| 63 |
class LessonBuildError(RuntimeError):
|
| 64 |
pass
|
|
|
|
| 88 |
path = PROJECT_ROOT / filename
|
| 89 |
if not path.exists():
|
| 90 |
continue
|
| 91 |
+
try:
|
| 92 |
+
raw = path.read_text(encoding="utf-8")
|
| 93 |
+
except UnicodeDecodeError:
|
| 94 |
+
raw = path.read_text(encoding="utf-8", errors="replace")
|
| 95 |
+
for line in raw.splitlines():
|
| 96 |
if line.startswith(f"{name}="):
|
| 97 |
return _clean_env_value(line.split("=", 1)[1])
|
| 98 |
return ""
|
| 99 |
|
| 100 |
|
| 101 |
+
def lesson_hash(
|
| 102 |
+
topic: str, lesson_title: str, level: str, medium: str, voice: str
|
| 103 |
+
) -> str:
|
| 104 |
payload = json.dumps(
|
| 105 |
{
|
| 106 |
"topic": topic.strip().lower(),
|
|
|
|
| 115 |
return hashlib.sha256(payload.encode("utf-8")).hexdigest()[:20]
|
| 116 |
|
| 117 |
|
| 118 |
+
def _call_openai_compatible_json(
|
| 119 |
+
*,
|
| 120 |
+
url: str,
|
| 121 |
+
api_key: str,
|
| 122 |
+
model: str,
|
| 123 |
+
prompt: str,
|
| 124 |
+
max_tokens: int = 8000,
|
| 125 |
+
provider_label: str,
|
| 126 |
+
) -> dict[str, Any]:
|
| 127 |
+
"""OpenAI-shaped chat completions that return a JSON object body."""
|
| 128 |
payload = {
|
| 129 |
+
"model": model,
|
| 130 |
"messages": [{"role": "user", "content": prompt}],
|
| 131 |
"temperature": 0.35,
|
| 132 |
"max_tokens": max_tokens,
|
| 133 |
"response_format": {"type": "json_object"},
|
| 134 |
}
|
| 135 |
request = urllib.request.Request(
|
| 136 |
+
url,
|
| 137 |
data=json.dumps(payload).encode("utf-8"),
|
| 138 |
headers={
|
| 139 |
"Content-Type": "application/json",
|
| 140 |
"Authorization": f"Bearer {api_key}",
|
| 141 |
+
# Some CDN edges 403 urllib's default UA.
|
| 142 |
+
"User-Agent": "DocDoe-LearnLesson/1.0",
|
| 143 |
},
|
| 144 |
method="POST",
|
| 145 |
)
|
| 146 |
+
try:
|
| 147 |
+
with urllib.request.urlopen(request, timeout=180) as response:
|
| 148 |
+
result = json.loads(response.read().decode("utf-8"))
|
| 149 |
+
except urllib.error.HTTPError as exc:
|
| 150 |
+
body = ""
|
| 151 |
+
try:
|
| 152 |
+
body = exc.read().decode("utf-8", errors="replace")[:400]
|
| 153 |
+
except Exception:
|
| 154 |
+
body = ""
|
| 155 |
+
raise LessonBuildError(
|
| 156 |
+
f"{provider_label} HTTP {exc.code}: {body or exc.reason}"
|
| 157 |
+
) from exc
|
| 158 |
+
content = result["choices"][0]["message"]["content"]
|
| 159 |
+
if isinstance(content, list):
|
| 160 |
+
# Some providers return content parts; join text pieces.
|
| 161 |
+
content = "".join(
|
| 162 |
+
part.get("text", "") if isinstance(part, dict) else str(part)
|
| 163 |
+
for part in content
|
| 164 |
+
)
|
| 165 |
+
return json.loads(content)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
def _call_groq(prompt: str, api_key: str, max_tokens: int = 8000) -> dict[str, Any]:
|
| 169 |
+
return _call_openai_compatible_json(
|
| 170 |
+
url=GROQ_URL,
|
| 171 |
+
api_key=api_key,
|
| 172 |
+
model=GROQ_MODEL,
|
| 173 |
+
prompt=prompt,
|
| 174 |
+
max_tokens=max_tokens,
|
| 175 |
+
provider_label="Groq",
|
| 176 |
+
)
|
| 177 |
|
| 178 |
|
| 179 |
+
def _script_llm_providers() -> list[dict[str, Any]]:
|
| 180 |
+
"""Lesson-script providers: Groq OpenAI-compatible chat."""
|
| 181 |
+
providers: list[dict[str, Any]] = []
|
| 182 |
+
groq = _env("GROQ_API_KEY")
|
| 183 |
+
if groq.startswith(("gsk-", "gsk_")) and len(groq) >= 20:
|
| 184 |
+
providers.append(
|
| 185 |
+
{
|
| 186 |
+
"name": "groq",
|
| 187 |
+
"key": groq,
|
| 188 |
+
"call": lambda prompt, key=groq: _call_groq(prompt, key),
|
| 189 |
+
}
|
| 190 |
+
)
|
| 191 |
+
return providers
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def _lesson_prompt(
|
| 195 |
+
topic: str, lesson_title: str, level: str, medium: str, context: str
|
| 196 |
+
) -> str:
|
| 197 |
medium_rule = (
|
| 198 |
"Write the spoken narration in natural spoken Malayalam mixed with English technical terms "
|
| 199 |
"(the way a Kerala tuition teacher actually talks: Malayalam sentences, English kept for "
|
| 200 |
+
"technical vocabulary). Keep board_heading, board_lines, objectives, notes and flashcards in English."
|
| 201 |
if medium.strip().lower() in {"malayalam", "manglish", "ml"}
|
| 202 |
else "Write the spoken narration in clear, simple spoken Indian English."
|
| 203 |
)
|
| 204 |
+
return f"""You are one of the best teachers in the world making ONE ~10-minute lesson that a student experiences as a continuous, spoken class (not slides). Your goal: the student truly UNDERSTANDS, not just hears facts.
|
| 205 |
|
| 206 |
Lesson: "{lesson_title}"
|
| 207 |
Part of learning: "{topic}"
|
| 208 |
Learner level: {level or "beginner"}
|
| 209 |
{medium_rule}
|
| 210 |
|
| 211 |
+
HOW A GREAT LESSON IS BUILT (follow this arc across the beats)
|
| 212 |
+
1. HOOK — open with a real question, surprising fact, or everyday situation that makes the student curious about THIS lesson. No throat-clearing.
|
| 213 |
+
2. GROUND IT — connect to something the student already knows before introducing anything new.
|
| 214 |
+
3. EXPLAIN — teach the core idea. ALWAYS give the reason BEFORE the rule ("here's why, so the rule makes sense"). Build up, never dump.
|
| 215 |
+
4. WORKED EXAMPLE — walk through ONE concrete, specific example with real numbers/specifics, step by step, thinking out loud. This is the heart of the lesson — make it vivid and complete.
|
| 216 |
+
5. ANALOGY — at most one, and only if it genuinely makes the idea click.
|
| 217 |
+
6. MISCONCEPTION — name the exact mistake students usually make here and correct it directly ("A lot of students think X — but actually Y, because...").
|
| 218 |
+
7. CHECKPOINT — ask the student a question and give them a beat to think, then reveal and explain the answer. Make them do the thinking.
|
| 219 |
+
8. RECAP — warm, tight summary of what they can now do, and how it connects to the next thing.
|
| 220 |
+
|
| 221 |
TEACHING RULES
|
| 222 |
+
- Talk TO the student ("you"), warm, human, and encouraging. Sound like a person who loves this subject, not a textbook.
|
| 223 |
+
- Reason before rule, concrete before abstract, one idea per beat fully developed.
|
| 224 |
+
- CRITICAL LENGTH RULE: each beat's narration MUST be 90-140 words of what the teacher actually SAYS. Never write one- or two-sentence beats. A real teacher talks for 40-60 seconds per beat.
|
| 225 |
+
- The board is what appears on screen WHILE they speak — a short heading and 2-5 tight bullet fragments (not full sentences, not read aloud verbatim). The narration teaches; the board reinforces the keywords, the formula, or the example's steps.
|
| 226 |
+
- Total narration across all beats MUST be about 1300-1600 words — a full ~10 minute class, not a summary.
|
| 227 |
+
- Be accurate. If the lesson has a formula, definition, or process, state it precisely and correctly.
|
| 228 |
+
|
| 229 |
+
QUALITY OF SUPPORTING MATERIAL
|
| 230 |
+
- objectives: 3-5 crisp "By the end you can…" statements — the concrete skills this lesson delivers.
|
| 231 |
+
- notes: 6-10 revision notes a student writes in their notebook — self-contained, exam-ready, each a complete useful fact (include the key formula/definition/steps, not vague reminders).
|
| 232 |
+
- flashcards: 6-10 real question→answer pairs that test the hardest/most testable points (definitions, why-questions, one small applied problem). Answers must be correct and specific.
|
| 233 |
|
| 234 |
Return ONE JSON object, no markdown:
|
| 235 |
{{
|
| 236 |
"lesson_title": "{lesson_title}",
|
| 237 |
+
"summary": "one warm sentence describing what this class teaches",
|
| 238 |
+
"objectives": ["By the end you can …", "By the end you can ��"],
|
| 239 |
"beats": [
|
| 240 |
+
{{"kind": "hook|explain|example|analogy|checkpoint|recap", "narration": "what the teacher says (90-140 words)", "board_heading": "short title", "board_lines": ["tight fragment", "tight fragment"], "visual_hint": "optional: a simple diagram/idea to draw, or empty string"}}
|
| 241 |
],
|
| 242 |
+
"notes": ["exam-ready revision note", "..."],
|
| 243 |
+
"flashcards": [{{"front": "question", "back": "correct, specific answer"}}]
|
| 244 |
}}
|
| 245 |
|
| 246 |
+
Make {MIN_BEATS}-{MAX_BEATS} beats (aim for {TARGET_BEATS}), 3-5 objectives, 6-10 notes and 6-10 flashcards.
|
| 247 |
+
{f"Use this source material where relevant (stay faithful to it):{chr(10)}{context[:4000]}" if context.strip() else ""}
|
| 248 |
"""
|
| 249 |
|
| 250 |
|
| 251 |
+
def _continue_prompt(
|
| 252 |
+
topic: str, lesson_title: str, level: str, medium: str, taught_headings: list[str]
|
| 253 |
+
) -> str:
|
| 254 |
medium_rule = (
|
| 255 |
"Continue in natural spoken Malayalam mixed with English technical terms; keep board text in English."
|
| 256 |
if medium.strip().lower() in {"malayalam", "manglish", "ml"}
|
| 257 |
else "Continue in clear, simple spoken Indian English."
|
| 258 |
)
|
| 259 |
already = "; ".join(taught_headings)
|
| 260 |
+
return f"""You are continuing a live ~10-minute class on "{lesson_title}" (part of "{topic}", learner level {level or "beginner"}).
|
| 261 |
{medium_rule}
|
| 262 |
|
| 263 |
So far you have already taught these beats: {already}.
|
|
|
|
| 278 |
"""
|
| 279 |
|
| 280 |
|
| 281 |
+
def _starter_reading_script(
|
| 282 |
+
*, topic: str, lesson_title: str, level: str
|
| 283 |
+
) -> dict[str, Any]:
|
| 284 |
+
"""Return an honest, deterministic lesson for local/mock development.
|
| 285 |
+
|
| 286 |
+
The direct Groq/Deepgram authoring pipeline must not run when the configured
|
| 287 |
+
AI provider is ``mock``. This starter is deliberately labelled as reading
|
| 288 |
+
mode by ``build_lesson``; it keeps roadmap study and resume flows usable
|
| 289 |
+
without presenting generated audio as real.
|
| 290 |
+
"""
|
| 291 |
+
normalized = " ".join([topic, lesson_title]).lower()
|
| 292 |
+
if "python" in normalized:
|
| 293 |
+
return {
|
| 294 |
+
"lesson_title": lesson_title,
|
| 295 |
+
"summary": "A beginner reading class on how Python instructions, values, decisions, repetition, and functions fit together.",
|
| 296 |
+
"objectives": [
|
| 297 |
+
"Explain what a Python program does",
|
| 298 |
+
"Use variables and basic value types",
|
| 299 |
+
"Recognise decisions, loops, and functions",
|
| 300 |
+
"Trace a short program before running it",
|
| 301 |
+
],
|
| 302 |
+
"beats": [
|
| 303 |
+
{
|
| 304 |
+
"kind": "hook",
|
| 305 |
+
"narration": "A computer does not guess what you mean. It follows instructions in order. Python gives you a readable way to write those instructions. In this class, treat every line as a small command: store a value, make a decision, repeat an action, or reuse a group of instructions. That simple model is enough to begin reading real Python without memorising a long list of rules.",
|
| 306 |
+
"board_heading": "Code is a sequence of instructions",
|
| 307 |
+
"board_lines": [
|
| 308 |
+
"Read top to bottom",
|
| 309 |
+
"One clear action per line",
|
| 310 |
+
"Predict before you run",
|
| 311 |
+
],
|
| 312 |
+
"visual_hint": "Draw three boxes labelled input, process, output.",
|
| 313 |
+
},
|
| 314 |
+
{
|
| 315 |
+
"kind": "explain",
|
| 316 |
+
"narration": "A variable is a name that refers to a value. For example, score = 5 gives the name score the integer value 5, while name = 'Asha' gives name a text value. Common beginner types are int for whole numbers, float for decimal numbers, str for text, and bool for True or False. The equals sign assigns a value here; it does not ask whether two values are equal.",
|
| 317 |
+
"board_heading": "Names and values",
|
| 318 |
+
"board_lines": [
|
| 319 |
+
"score = 5",
|
| 320 |
+
"name = 'Asha'",
|
| 321 |
+
"int, float, str, bool",
|
| 322 |
+
],
|
| 323 |
+
"visual_hint": "Connect each variable name to its current value.",
|
| 324 |
+
},
|
| 325 |
+
{
|
| 326 |
+
"kind": "example",
|
| 327 |
+
"narration": "Trace this example: name = 'Asha', marks = 8, then print(name, marks). The first line stores text, the second stores a whole number, and print sends both values to the output. Change marks to 9 and only the printed number changes. This is a useful study habit: say what each line changes before you press Run. Tracing catches many mistakes faster than rereading the whole program.",
|
| 328 |
+
"board_heading": "Worked example",
|
| 329 |
+
"board_lines": [
|
| 330 |
+
"name = 'Asha'",
|
| 331 |
+
"marks = 8",
|
| 332 |
+
"print(name, marks)",
|
| 333 |
+
"Output: Asha 8",
|
| 334 |
+
],
|
| 335 |
+
"visual_hint": "Use a two-column trace table: variable and value.",
|
| 336 |
+
},
|
| 337 |
+
{
|
| 338 |
+
"kind": "explain",
|
| 339 |
+
"narration": "Programs become useful when they can choose and repeat. An if statement runs a block only when its condition is True. A for loop repeats a block for each item in a sequence. Python uses indentation to show which lines belong inside that block, so spacing changes meaning. Read the condition first, then follow only the indented lines that should run.",
|
| 340 |
+
"board_heading": "Decide and repeat",
|
| 341 |
+
"board_lines": [
|
| 342 |
+
"if condition:",
|
| 343 |
+
" run this block",
|
| 344 |
+
"for item in sequence:",
|
| 345 |
+
" repeat this block",
|
| 346 |
+
],
|
| 347 |
+
"visual_hint": "Draw a decision diamond leading to an indented block.",
|
| 348 |
+
},
|
| 349 |
+
{
|
| 350 |
+
"kind": "explain",
|
| 351 |
+
"narration": "A function gives a reusable name to a group of instructions. You define it with def, pass information through parameters, and use return when the function must send a result back. For example, def double(number): return number * 2 describes one job clearly. Calling double(4) produces 8. Functions reduce repetition and make each part of a program easier to test.",
|
| 352 |
+
"board_heading": "Reuse with functions",
|
| 353 |
+
"board_lines": [
|
| 354 |
+
"def double(number):",
|
| 355 |
+
" return number * 2",
|
| 356 |
+
"double(4) -> 8",
|
| 357 |
+
],
|
| 358 |
+
"visual_hint": "Show input 4 entering a function box and output 8 leaving it.",
|
| 359 |
+
},
|
| 360 |
+
{
|
| 361 |
+
"kind": "checkpoint",
|
| 362 |
+
"narration": "Pause and predict this without running it: total = 2, then for number in [1, 2, 3], total = total + number. The loop adds 1, then 2, then 3 to the starting value 2, so total becomes 8. If your answer differed, write the value after every pass. A trace table is the correction tool: it makes the changing state visible instead of asking you to hold every step in memory.",
|
| 363 |
+
"board_heading": "Checkpoint",
|
| 364 |
+
"board_lines": [
|
| 365 |
+
"Start: total = 2",
|
| 366 |
+
"+1 -> 3",
|
| 367 |
+
"+2 -> 5",
|
| 368 |
+
"+3 -> 8",
|
| 369 |
+
],
|
| 370 |
+
"visual_hint": "Make one row for each loop pass.",
|
| 371 |
+
},
|
| 372 |
+
{
|
| 373 |
+
"kind": "recap",
|
| 374 |
+
"narration": "You now have a map for beginner Python. Values are stored behind variable names. If chooses a path, for repeats a block, and def creates a reusable function. Your next move is small: type the worked example, change one value, and predict the new output before running it. Learning programming comes from this short loop of predict, run, compare, and correct.",
|
| 375 |
+
"board_heading": "Your Python map",
|
| 376 |
+
"board_lines": [
|
| 377 |
+
"Variables store values",
|
| 378 |
+
"if chooses",
|
| 379 |
+
"for repeats",
|
| 380 |
+
"def reuses",
|
| 381 |
+
"Predict -> run -> correct",
|
| 382 |
+
],
|
| 383 |
+
"visual_hint": "Keep this map beside your first practice program.",
|
| 384 |
+
},
|
| 385 |
+
],
|
| 386 |
+
"notes": [
|
| 387 |
+
"Python executes instructions in a defined order.",
|
| 388 |
+
"A variable name refers to a value; assignment uses =.",
|
| 389 |
+
"Basic beginner types include int, float, str, and bool.",
|
| 390 |
+
"An if statement runs its indented block when the condition is True.",
|
| 391 |
+
"A for loop repeats its indented block for items in a sequence.",
|
| 392 |
+
"A function is defined with def and can return a result.",
|
| 393 |
+
"Trace changing variable values to debug a short program.",
|
| 394 |
+
],
|
| 395 |
+
"flashcards": [
|
| 396 |
+
{
|
| 397 |
+
"front": "What does = do in score = 5?",
|
| 398 |
+
"back": "It assigns the integer value 5 to the name score.",
|
| 399 |
+
},
|
| 400 |
+
{"front": "Which type stores text?", "back": "str"},
|
| 401 |
+
{"front": "What controls a Python code block?", "back": "Indentation."},
|
| 402 |
+
{
|
| 403 |
+
"front": "What does an if statement do?",
|
| 404 |
+
"back": "It runs a block when its condition is True.",
|
| 405 |
+
},
|
| 406 |
+
{
|
| 407 |
+
"front": "What does a for loop do?",
|
| 408 |
+
"back": "It repeats a block for each item in a sequence.",
|
| 409 |
+
},
|
| 410 |
+
{
|
| 411 |
+
"front": "Why use a function?",
|
| 412 |
+
"back": "To name and reuse a focused group of instructions.",
|
| 413 |
+
},
|
| 414 |
+
],
|
| 415 |
+
}
|
| 416 |
+
|
| 417 |
+
clean_title = lesson_title.strip() or topic.strip() or "this topic"
|
| 418 |
+
clean_level = level.strip() or "beginner"
|
| 419 |
+
return {
|
| 420 |
+
"lesson_title": clean_title,
|
| 421 |
+
"summary": f"A {clean_level} starter reading class that turns {clean_title} into a definition, example, check, and next practice move.",
|
| 422 |
+
"objectives": [
|
| 423 |
+
f"State what {clean_title} means",
|
| 424 |
+
"Identify the central terms",
|
| 425 |
+
"Work through one concrete example",
|
| 426 |
+
"Check your understanding without notes",
|
| 427 |
+
],
|
| 428 |
+
"beats": [
|
| 429 |
+
{
|
| 430 |
+
"kind": "hook",
|
| 431 |
+
"narration": f"Before collecting facts about {clean_title}, write one question you want this lesson to answer. That question gives the topic a purpose and makes it easier to notice which ideas matter.",
|
| 432 |
+
"board_heading": "Start with one question",
|
| 433 |
+
"board_lines": [
|
| 434 |
+
f"Topic: {clean_title}",
|
| 435 |
+
"What must I understand?",
|
| 436 |
+
"What can I explain after this?",
|
| 437 |
+
],
|
| 438 |
+
"visual_hint": "Write your question at the top of the page.",
|
| 439 |
+
},
|
| 440 |
+
{
|
| 441 |
+
"kind": "explain",
|
| 442 |
+
"narration": f"Build a precise definition of {clean_title}: name the larger idea it belongs to, the feature that makes it distinct, and one boundary or condition. Keep this as a working definition and verify subject-specific facts against a trusted lesson or source.",
|
| 443 |
+
"board_heading": "Build the definition",
|
| 444 |
+
"board_lines": [
|
| 445 |
+
"Category",
|
| 446 |
+
"Distinct feature",
|
| 447 |
+
"Boundary or condition",
|
| 448 |
+
],
|
| 449 |
+
"visual_hint": "Use a three-part definition box.",
|
| 450 |
+
},
|
| 451 |
+
{
|
| 452 |
+
"kind": "example",
|
| 453 |
+
"narration": f"Choose one concrete example of {clean_title}. Label which part of the definition appears in the example and which details are only background. A useful example should let you explain why it belongs, not merely name it.",
|
| 454 |
+
"board_heading": "Test with an example",
|
| 455 |
+
"board_lines": [
|
| 456 |
+
"Name the example",
|
| 457 |
+
"Match it to the definition",
|
| 458 |
+
"Explain why it fits",
|
| 459 |
+
],
|
| 460 |
+
"visual_hint": "Draw arrows from example details to definition terms.",
|
| 461 |
+
},
|
| 462 |
+
{
|
| 463 |
+
"kind": "checkpoint",
|
| 464 |
+
"narration": f"Close your notes and explain {clean_title} in two sentences: one definition and one example with a reason. If you cannot connect the example to the definition, mark that exact missing link for revision instead of restarting the whole topic.",
|
| 465 |
+
"board_heading": "Quick check",
|
| 466 |
+
"board_lines": [
|
| 467 |
+
"Sentence 1: definition",
|
| 468 |
+
"Sentence 2: example + why",
|
| 469 |
+
"Mark the missing link",
|
| 470 |
+
],
|
| 471 |
+
"visual_hint": "Answer aloud before reopening your notes.",
|
| 472 |
+
},
|
| 473 |
+
{
|
| 474 |
+
"kind": "recap",
|
| 475 |
+
"narration": f"Your next move for {clean_title} is now specific: verify the working definition, add one subject-correct example, then retry the two-sentence explanation tomorrow. That short retrieval step creates evidence of learning and gives the roadmap a real place to resume.",
|
| 476 |
+
"board_heading": "Next move",
|
| 477 |
+
"board_lines": ["Verify", "Add one example", "Recall tomorrow"],
|
| 478 |
+
"visual_hint": "Schedule the two-sentence recall for tomorrow.",
|
| 479 |
+
},
|
| 480 |
+
],
|
| 481 |
+
"notes": [
|
| 482 |
+
f"Working topic: {clean_title}.",
|
| 483 |
+
"A strong definition gives category, distinct feature, and boundary.",
|
| 484 |
+
"An example is useful only when you can explain why it fits.",
|
| 485 |
+
"Mark the exact missing link instead of restarting everything.",
|
| 486 |
+
"Retry the definition and example from memory the next day.",
|
| 487 |
+
],
|
| 488 |
+
"flashcards": [
|
| 489 |
+
{
|
| 490 |
+
"front": f"What is your working definition of {clean_title}?",
|
| 491 |
+
"back": "Give category, distinct feature, and boundary; verify subject facts against a trusted source.",
|
| 492 |
+
},
|
| 493 |
+
{
|
| 494 |
+
"front": "What makes an example useful?",
|
| 495 |
+
"back": "You can connect its details to the definition and explain why it fits.",
|
| 496 |
+
},
|
| 497 |
+
{
|
| 498 |
+
"front": "What should you revise after a failed recall?",
|
| 499 |
+
"back": "The exact missing link, not the entire topic.",
|
| 500 |
+
},
|
| 501 |
+
],
|
| 502 |
+
}
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
def _generate_with_retries(
|
| 506 |
+
prompt: str,
|
| 507 |
+
label: str,
|
| 508 |
+
providers: list[dict[str, Any]] | None = None,
|
| 509 |
+
) -> dict[str, Any]:
|
| 510 |
last_error: Exception | None = None
|
| 511 |
+
chain = providers if providers is not None else _script_llm_providers()
|
| 512 |
+
if not chain:
|
| 513 |
+
raise LessonBuildError(
|
| 514 |
+
"No lesson LLM key configured. Set GROQ_API_KEY for Learn Anything authoring."
|
| 515 |
+
)
|
| 516 |
+
for provider in chain:
|
| 517 |
+
name = str(provider.get("name") or "llm")
|
| 518 |
+
call = provider["call"]
|
| 519 |
+
for attempt in range(1, 3):
|
| 520 |
+
try:
|
| 521 |
+
data = call(prompt)
|
| 522 |
+
logger.info("Lesson %s authored via %s (attempt %s)", label, name, attempt)
|
| 523 |
+
return data
|
| 524 |
+
except (
|
| 525 |
+
urllib.error.URLError,
|
| 526 |
+
TimeoutError,
|
| 527 |
+
KeyError,
|
| 528 |
+
json.JSONDecodeError,
|
| 529 |
+
LessonBuildError,
|
| 530 |
+
ValueError,
|
| 531 |
+
TypeError,
|
| 532 |
+
) as exc:
|
| 533 |
+
last_error = exc
|
| 534 |
+
logger.warning(
|
| 535 |
+
"Lesson %s via %s attempt %s failed: %s",
|
| 536 |
+
label,
|
| 537 |
+
name,
|
| 538 |
+
attempt,
|
| 539 |
+
type(exc).__name__,
|
| 540 |
+
)
|
| 541 |
raise LessonBuildError(f"Lesson {label} generation failed: {last_error}")
|
| 542 |
|
| 543 |
|
|
|
|
| 553 |
medium: str = "english",
|
| 554 |
context: str = "",
|
| 555 |
) -> dict[str, Any]:
|
| 556 |
+
providers = _script_llm_providers()
|
| 557 |
+
if not providers:
|
| 558 |
+
logger.warning(
|
| 559 |
+
"GROQ_API_KEY missing/invalid; serving starter reading class."
|
| 560 |
+
)
|
| 561 |
+
return _starter_reading_script(
|
| 562 |
+
topic=topic, lesson_title=lesson_title, level=level
|
| 563 |
+
)
|
| 564 |
+
|
| 565 |
+
try:
|
| 566 |
+
data = _generate_with_retries(
|
| 567 |
+
_lesson_prompt(topic, lesson_title, level, medium, context),
|
| 568 |
+
"script",
|
| 569 |
+
providers,
|
| 570 |
+
)
|
| 571 |
+
except LessonBuildError as exc:
|
| 572 |
+
# Never leave 1000 students on a hard error when providers are out of quota:
|
| 573 |
+
# fall back to a complete starter reading class and keep the product usable.
|
| 574 |
+
logger.warning("Lesson script providers failed (%s); using starter class.", exc)
|
| 575 |
+
return _starter_reading_script(
|
| 576 |
+
topic=topic, lesson_title=lesson_title, level=level
|
| 577 |
+
)
|
| 578 |
|
|
|
|
| 579 |
beats = list(data.get("beats") or [])
|
| 580 |
if not beats:
|
| 581 |
+
return _starter_reading_script(
|
| 582 |
+
topic=topic, lesson_title=lesson_title, level=level
|
| 583 |
+
)
|
| 584 |
|
| 585 |
# The model reliably writes ~4-5 minutes in one call, then stops. To reach a
|
| 586 |
# real ~10-minute class, ask it to continue from what it already taught.
|
| 587 |
# One continuation is enough; this stays a cheap 2-call generation.
|
| 588 |
if _narration_words(beats) < 950:
|
| 589 |
try:
|
| 590 |
+
taught = [
|
| 591 |
+
str(beat.get("board_heading") or beat.get("kind")) for beat in beats
|
| 592 |
+
]
|
| 593 |
more = _generate_with_retries(
|
| 594 |
+
_continue_prompt(topic, lesson_title, level, medium, taught),
|
| 595 |
+
"continuation",
|
| 596 |
+
providers,
|
| 597 |
)
|
| 598 |
beats.extend(more.get("beats") or [])
|
| 599 |
# Prefer the fuller notes/flashcards from whichever pass gave more.
|
|
|
|
| 612 |
request = urllib.request.Request(
|
| 613 |
url,
|
| 614 |
data=json.dumps({"text": text}).encode("utf-8"),
|
| 615 |
+
headers={
|
| 616 |
+
"Authorization": f"Token {api_key}",
|
| 617 |
+
"Content-Type": "application/json",
|
| 618 |
+
},
|
| 619 |
method="POST",
|
| 620 |
)
|
| 621 |
with urllib.request.urlopen(request, timeout=120) as response:
|
|
|
|
| 626 |
pcm_path = out_path.with_suffix(".pcm")
|
| 627 |
pcm_path.write_bytes(audio)
|
| 628 |
subprocess.run(
|
| 629 |
+
[
|
| 630 |
+
"ffmpeg",
|
| 631 |
+
"-y",
|
| 632 |
+
"-f",
|
| 633 |
+
"s16le",
|
| 634 |
+
"-ar",
|
| 635 |
+
"24000",
|
| 636 |
+
"-ac",
|
| 637 |
+
"1",
|
| 638 |
+
"-i",
|
| 639 |
+
str(pcm_path),
|
| 640 |
+
str(out_path),
|
| 641 |
+
],
|
| 642 |
check=True,
|
| 643 |
capture_output=True,
|
| 644 |
)
|
|
|
|
| 647 |
|
| 648 |
def _probe_duration(path: Path) -> float:
|
| 649 |
completed = subprocess.run(
|
| 650 |
+
[
|
| 651 |
+
"ffprobe",
|
| 652 |
+
"-v",
|
| 653 |
+
"error",
|
| 654 |
+
"-show_entries",
|
| 655 |
+
"format=duration",
|
| 656 |
+
"-of",
|
| 657 |
+
"default=nw=1:nk=1",
|
| 658 |
+
str(path),
|
| 659 |
+
],
|
| 660 |
capture_output=True,
|
| 661 |
check=True,
|
| 662 |
text=True,
|
|
|
|
| 664 |
return round(float(completed.stdout.strip()), 3)
|
| 665 |
|
| 666 |
|
| 667 |
+
def synthesize_beats(
|
| 668 |
+
beats: list[dict[str, Any]], out_dir: Path, medium: str
|
| 669 |
+
) -> list[LessonBeat]:
|
| 670 |
"""Voice each beat. Deepgram for English; AI4Bharat for Malayalam medium."""
|
|
|
|
| 671 |
use_malayalam = medium.strip().lower() in {"malayalam", "ml"}
|
| 672 |
result: list[LessonBeat] = []
|
| 673 |
cursor = 0.0
|
|
|
|
| 682 |
api_key = _env("DOCDOE_TTS_API_KEY")
|
| 683 |
model = _env("DOCDOE_TTS_MODEL") or "aura-luna-en"
|
| 684 |
if not api_key:
|
| 685 |
+
raise LessonBuildError(
|
| 686 |
+
"DOCDOE_TTS_API_KEY (Deepgram) missing; cannot voice English lesson."
|
| 687 |
+
)
|
| 688 |
|
| 689 |
for index, beat in enumerate(beats, start=1):
|
| 690 |
narration = str(beat.get("narration", "")).strip()
|
|
|
|
| 693 |
audio_path = out_dir / f"beat-{index:02d}.wav"
|
| 694 |
if use_malayalam:
|
| 695 |
provider, scene_cls = ai4bharat_provider
|
| 696 |
+
scene = scene_cls(
|
| 697 |
+
scene_id=index,
|
| 698 |
+
type="concept",
|
| 699 |
+
duration_seconds=20,
|
| 700 |
+
voice_text=narration,
|
| 701 |
+
)
|
| 702 |
synth = provider.generate_scene_audio(
|
| 703 |
+
scene=scene,
|
| 704 |
+
output_file=audio_path,
|
| 705 |
+
voice_mode="malayalam_soft",
|
| 706 |
+
voice="Anjali",
|
| 707 |
+
language="ml",
|
| 708 |
)
|
| 709 |
if synth.file_path != audio_path:
|
| 710 |
Path(synth.file_path).replace(audio_path)
|
|
|
|
| 727 |
return result
|
| 728 |
|
| 729 |
|
| 730 |
+
def _reading_beats(beats: list[dict[str, Any]]) -> list[LessonBeat]:
|
| 731 |
+
"""Turn authored beats into a fully navigable reading class."""
|
| 732 |
+
result: list[LessonBeat] = []
|
| 733 |
+
cursor = 0.0
|
| 734 |
+
for beat in beats:
|
| 735 |
+
narration = str(beat.get("narration", "")).strip()
|
| 736 |
+
if not narration:
|
| 737 |
+
continue
|
| 738 |
+
duration = round(max(20.0, len(narration.split()) / 180 * 60), 3)
|
| 739 |
+
result.append(
|
| 740 |
+
LessonBeat(
|
| 741 |
+
kind=str(beat.get("kind", "explain")),
|
| 742 |
+
narration=narration,
|
| 743 |
+
board_heading=str(beat.get("board_heading", "")),
|
| 744 |
+
board_lines=[str(line) for line in (beat.get("board_lines") or [])],
|
| 745 |
+
visual_hint=str(beat.get("visual_hint", "")),
|
| 746 |
+
audio_src="",
|
| 747 |
+
start_second=round(cursor, 3),
|
| 748 |
+
duration_seconds=duration,
|
| 749 |
+
)
|
| 750 |
+
)
|
| 751 |
+
cursor += duration
|
| 752 |
+
return result
|
| 753 |
+
|
| 754 |
+
|
| 755 |
+
def _lesson_lock_for(key: str) -> threading.Lock:
|
| 756 |
+
with _lesson_build_locks_guard:
|
| 757 |
+
lock = _lesson_build_locks.get(key)
|
| 758 |
+
if lock is None:
|
| 759 |
+
lock = threading.Lock()
|
| 760 |
+
_lesson_build_locks[key] = lock
|
| 761 |
+
return lock
|
| 762 |
+
|
| 763 |
+
|
| 764 |
+
def _is_forbidden_cache_path(path: Path) -> bool:
|
| 765 |
+
"""Block the HF /public tree that resolves outside the writable container."""
|
| 766 |
+
normalized = path.as_posix().replace("\\", "/")
|
| 767 |
+
# Unix absolute
|
| 768 |
+
if normalized == "/public" or normalized.startswith("/public/"):
|
| 769 |
+
return True
|
| 770 |
+
# Windows oddities if someone sets PUBLIC_ROOT = Path("/public/...")
|
| 771 |
+
if normalized.lower().endswith(":/public") or "/public/generated" in normalized and normalized.startswith("/"):
|
| 772 |
+
return True
|
| 773 |
+
return False
|
| 774 |
+
|
| 775 |
+
|
| 776 |
+
def ensure_public_root() -> Path:
|
| 777 |
+
"""Ensure the lesson cache directory exists and is writable on HF + local."""
|
| 778 |
+
candidates: list[Path] = []
|
| 779 |
+
env = _env("LEARN_LESSON_CACHE_DIR")
|
| 780 |
+
if env:
|
| 781 |
+
candidates.append(Path(env))
|
| 782 |
+
# Tests monkeypatch PUBLIC_ROOT to a temp path — prefer that when safe.
|
| 783 |
+
if not _is_forbidden_cache_path(PUBLIC_ROOT):
|
| 784 |
+
candidates.append(PUBLIC_ROOT)
|
| 785 |
+
# HF Docker WORKDIR is /app (backend tree).
|
| 786 |
+
candidates.append(Path("/app/generated/learn-anything"))
|
| 787 |
+
# Monorepo / local backend package root.
|
| 788 |
+
candidates.append(BACKEND_DIR / "generated" / "learn-anything")
|
| 789 |
+
# Last-resort temp (always writable for reading-mode fallbacks).
|
| 790 |
+
candidates.append(
|
| 791 |
+
Path(os.getenv("TMPDIR") or os.getenv("TEMP") or "/tmp")
|
| 792 |
+
/ "docdoe-learn-lessons"
|
| 793 |
+
)
|
| 794 |
+
|
| 795 |
+
errors: list[str] = []
|
| 796 |
+
seen: set[str] = set()
|
| 797 |
+
for root in candidates:
|
| 798 |
+
key = root.as_posix()
|
| 799 |
+
if key in seen:
|
| 800 |
+
continue
|
| 801 |
+
seen.add(key)
|
| 802 |
+
if _is_forbidden_cache_path(root):
|
| 803 |
+
errors.append(f"skip forbidden path {root}")
|
| 804 |
+
continue
|
| 805 |
+
try:
|
| 806 |
+
root.mkdir(parents=True, exist_ok=True)
|
| 807 |
+
probe = root / ".write_probe"
|
| 808 |
+
probe.write_text("ok", encoding="utf-8")
|
| 809 |
+
probe.unlink(missing_ok=True)
|
| 810 |
+
logger.info("Lesson cache using %s", root)
|
| 811 |
+
return root
|
| 812 |
+
except OSError as exc:
|
| 813 |
+
errors.append(f"{root}: {exc}")
|
| 814 |
+
continue
|
| 815 |
+
|
| 816 |
+
raise LessonBuildError(
|
| 817 |
+
"Lesson cache directory is not writable. Tried: " + "; ".join(errors)
|
| 818 |
+
)
|
| 819 |
+
|
| 820 |
+
|
| 821 |
def build_lesson(
|
| 822 |
*,
|
| 823 |
topic: str,
|
|
|
|
| 827 |
context: str = "",
|
| 828 |
force: bool = False,
|
| 829 |
) -> dict[str, Any]:
|
| 830 |
+
"""Full pipeline: script -> audio -> playable manifest, cached by content hash.
|
| 831 |
+
|
| 832 |
+
Cache is shared across all students: the first open pays for authoring;
|
| 833 |
+
the next 999+ hits serve the same lesson.json + audio instantly.
|
| 834 |
+
Concurrent first opens for the same hash serialize on a per-hash lock so
|
| 835 |
+
we do not fan out N identical LLM bills under load.
|
| 836 |
+
"""
|
| 837 |
+
settings = get_settings()
|
| 838 |
+
mock_mode = str(settings.ai_provider).strip().lower() == "mock"
|
| 839 |
+
voice = (
|
| 840 |
+
"reading-preview"
|
| 841 |
+
if mock_mode
|
| 842 |
+
else "ai4bharat-anjali"
|
| 843 |
+
if medium.strip().lower() in {"malayalam", "ml"}
|
| 844 |
+
else (_env("DOCDOE_TTS_MODEL") or "aura-luna-en")
|
| 845 |
+
)
|
| 846 |
key = lesson_hash(topic, lesson_title, level, medium, voice)
|
| 847 |
+
cache_root = ensure_public_root()
|
| 848 |
+
out_dir = cache_root / key
|
| 849 |
manifest_path = out_dir / "lesson.json"
|
| 850 |
|
| 851 |
if manifest_path.exists() and not force:
|
| 852 |
return json.loads(manifest_path.read_text(encoding="utf-8"))
|
| 853 |
|
| 854 |
+
lock = _lesson_lock_for(key)
|
| 855 |
+
with lock:
|
| 856 |
+
# Re-check inside the lock: another student may have finished while we waited.
|
| 857 |
+
if manifest_path.exists() and not force:
|
| 858 |
+
return json.loads(manifest_path.read_text(encoding="utf-8"))
|
| 859 |
+
|
| 860 |
+
script = (
|
| 861 |
+
_starter_reading_script(topic=topic, lesson_title=lesson_title, level=level)
|
| 862 |
+
if mock_mode
|
| 863 |
+
else generate_lesson_script(
|
| 864 |
+
topic=topic,
|
| 865 |
+
lesson_title=lesson_title,
|
| 866 |
+
level=level,
|
| 867 |
+
medium=medium,
|
| 868 |
+
context=context,
|
| 869 |
+
)
|
| 870 |
+
)
|
| 871 |
+
try:
|
| 872 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 873 |
+
except OSError as exc:
|
| 874 |
+
# Hard fallback: rebuild under /tmp so students still get a class.
|
| 875 |
+
emergency = (
|
| 876 |
+
Path(os.getenv("TMPDIR") or os.getenv("TEMP") or "/tmp")
|
| 877 |
+
/ "docdoe-learn-lessons"
|
| 878 |
+
/ key
|
| 879 |
+
)
|
| 880 |
+
try:
|
| 881 |
+
emergency.mkdir(parents=True, exist_ok=True)
|
| 882 |
+
out_dir = emergency
|
| 883 |
+
manifest_path = out_dir / "lesson.json"
|
| 884 |
+
logger.warning(
|
| 885 |
+
"Lesson cache mkdir failed (%s); using emergency path %s",
|
| 886 |
+
exc,
|
| 887 |
+
out_dir,
|
| 888 |
+
)
|
| 889 |
+
except OSError as exc2:
|
| 890 |
+
raise LessonBuildError(
|
| 891 |
+
f"Could not create lesson cache folder ({out_dir}): {exc}; "
|
| 892 |
+
f"emergency also failed: {exc2}"
|
| 893 |
+
) from exc2
|
| 894 |
+
delivery_mode = "reading" if mock_mode else "audio"
|
| 895 |
+
delivery_notice = (
|
| 896 |
+
"Local preview: this complete starter class is available in reading mode; no generated voice is being presented as real audio."
|
| 897 |
+
if mock_mode
|
| 898 |
+
else ""
|
| 899 |
+
)
|
| 900 |
+
if mock_mode:
|
| 901 |
+
beats = _reading_beats(script["beats"])
|
| 902 |
+
else:
|
| 903 |
+
try:
|
| 904 |
+
beats = synthesize_beats(script["beats"], out_dir, medium)
|
| 905 |
+
except Exception as exc:
|
| 906 |
+
logger.exception(
|
| 907 |
+
"Lesson voice generation failed; serving the authored reading class: %s",
|
| 908 |
+
type(exc).__name__,
|
| 909 |
+
)
|
| 910 |
+
beats = _reading_beats(script["beats"])
|
| 911 |
+
delivery_mode = "reading"
|
| 912 |
+
delivery_notice = "Audio is temporarily unavailable. The complete authored class is ready in reading mode, and your lesson position still saves."
|
| 913 |
+
if not beats:
|
| 914 |
+
raise LessonBuildError("No audible beats were produced for the lesson.")
|
| 915 |
+
|
| 916 |
+
total_seconds = round(beats[-1].start_second + beats[-1].duration_seconds, 3)
|
| 917 |
+
manifest = {
|
| 918 |
+
"schema": "learn-lesson-v1",
|
| 919 |
+
"lessonHash": key,
|
| 920 |
+
"topic": topic,
|
| 921 |
+
"lessonTitle": script.get("lesson_title", lesson_title),
|
| 922 |
+
"level": level,
|
| 923 |
+
"medium": medium,
|
| 924 |
+
"voice": voice,
|
| 925 |
+
"deliveryMode": delivery_mode,
|
| 926 |
+
"deliveryNotice": delivery_notice,
|
| 927 |
+
"isFallback": delivery_mode == "reading",
|
| 928 |
+
"totalSeconds": total_seconds,
|
| 929 |
+
"totalMinutes": round(total_seconds / 60, 2),
|
| 930 |
+
"summary": str(script.get("summary", "")).strip(),
|
| 931 |
+
"objectives": [
|
| 932 |
+
str(item).strip()
|
| 933 |
+
for item in (script.get("objectives") or [])
|
| 934 |
+
if str(item).strip()
|
| 935 |
+
],
|
| 936 |
+
"beats": [beat.__dict__ for beat in beats],
|
| 937 |
+
"notes": [str(note) for note in (script.get("notes") or [])],
|
| 938 |
+
"flashcards": [
|
| 939 |
+
{"front": str(card.get("front", "")), "back": str(card.get("back", ""))}
|
| 940 |
+
for card in (script.get("flashcards") or [])
|
| 941 |
+
if card.get("front") and card.get("back")
|
| 942 |
+
],
|
| 943 |
+
}
|
| 944 |
+
# Atomic-ish write: write temp then replace so readers never see half JSON.
|
| 945 |
+
tmp_path = manifest_path.with_suffix(".json.tmp")
|
| 946 |
+
tmp_path.write_text(
|
| 947 |
+
json.dumps(manifest, indent=2, ensure_ascii=False) + "\n", encoding="utf-8"
|
| 948 |
+
)
|
| 949 |
+
tmp_path.replace(manifest_path)
|
| 950 |
+
return manifest
|
| 951 |
|
| 952 |
|
| 953 |
def _slugify(value: str) -> str:
|
|
|
|
| 957 |
if __name__ == "__main__":
|
| 958 |
import argparse
|
| 959 |
|
| 960 |
+
parser = argparse.ArgumentParser(
|
| 961 |
+
description="Build one Learn Anything lesson end-to-end."
|
| 962 |
+
)
|
| 963 |
parser.add_argument("--topic", required=True)
|
| 964 |
parser.add_argument("--lesson", required=True)
|
| 965 |
parser.add_argument("--level", default="beginner")
|
app/services/learning_state_service.py
CHANGED
|
@@ -5,9 +5,12 @@ from typing import Iterable
|
|
| 5 |
|
| 6 |
from fastapi import HTTPException, status
|
| 7 |
from sqlalchemy import func, select
|
|
|
|
| 8 |
from sqlalchemy.orm import Session
|
| 9 |
|
| 10 |
from app.models.chat_session import ChatMessageRecord, ChatSession
|
|
|
|
|
|
|
| 11 |
from app.models.learning_state import (
|
| 12 |
Chapter,
|
| 13 |
DailyTask,
|
|
@@ -22,7 +25,9 @@ from app.models.learning_state import (
|
|
| 22 |
TopicMastery,
|
| 23 |
UsageEvent,
|
| 24 |
)
|
|
|
|
| 25 |
from app.services.adaptive_engine import repair_item_out
|
|
|
|
| 26 |
from app.schemas.learning_state import (
|
| 27 |
AssessmentConsequenceResponse,
|
| 28 |
AssessmentResultRequest,
|
|
@@ -102,7 +107,7 @@ def _task_maps(db: Session, user_id: str) -> tuple[dict[str, str], dict[str, str
|
|
| 102 |
return subjects, chapters
|
| 103 |
|
| 104 |
|
| 105 |
-
def _mastery_out(item: TopicMastery) -> TopicMasteryOut:
|
| 106 |
"""Expose the mastery engine's evidence-derived fields alongside the row."""
|
| 107 |
evidence = item.evidence or {}
|
| 108 |
last_correct = evidence.get("last_correct_at")
|
|
@@ -127,7 +132,7 @@ def _mastery_out(item: TopicMastery) -> TopicMasteryOut:
|
|
| 127 |
attempts_count=item.attempts_count,
|
| 128 |
last_result=item.last_result,
|
| 129 |
next_review_at=item.next_review_at,
|
| 130 |
-
state=item.last_result or "not_started",
|
| 131 |
consecutive_success=int(evidence.get("consecutive_success", 0) or 0),
|
| 132 |
error_categories={
|
| 133 |
str(key): int(value)
|
|
@@ -138,6 +143,74 @@ def _mastery_out(item: TopicMastery) -> TopicMasteryOut:
|
|
| 138 |
)
|
| 139 |
|
| 140 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 141 |
def create_onboarding_plan(
|
| 142 |
db: Session,
|
| 143 |
*,
|
|
@@ -154,7 +227,7 @@ def create_onboarding_plan(
|
|
| 154 |
available_days = (payload.exam_date - today).days
|
| 155 |
if available_days <= 0:
|
| 156 |
raise HTTPException(
|
| 157 |
-
status_code=status.
|
| 158 |
detail={"code": "EXAM_DATE_NOT_FUTURE", "message": "Choose an exam date after today."},
|
| 159 |
)
|
| 160 |
|
|
@@ -314,6 +387,12 @@ def create_onboarding_plan(
|
|
| 314 |
profile.current_subject_id = first_task.subject_id
|
| 315 |
profile.current_chapter_id = first_task.chapter_id
|
| 316 |
profile.current_mission_id = first_task.mission_id
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
db.add(
|
| 318 |
UsageEvent(
|
| 319 |
user_id=user_id,
|
|
@@ -376,6 +455,24 @@ def read_learning_state(db: Session, *, user_id: str) -> LearningStateSummary:
|
|
| 376 |
.where(GeneratedResource.user_id == user_id)
|
| 377 |
.order_by(GeneratedResource.created_at.desc())
|
| 378 |
).all()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 379 |
questions_asked = (
|
| 380 |
db.scalar(
|
| 381 |
select(func.count(ChatMessageRecord.id))
|
|
@@ -390,6 +487,31 @@ def read_learning_state(db: Session, *, user_id: str) -> LearningStateSummary:
|
|
| 390 |
subject_map = {item.id: item.name for item in subjects}
|
| 391 |
chapter_map = {item.id: item.title for item in chapters}
|
| 392 |
chapter_by_id = {item.id: item for item in chapters}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 393 |
return LearningStateSummary(
|
| 394 |
profile=LearningProfileOut.model_validate(profile, from_attributes=True) if profile else None,
|
| 395 |
subjects=[LearningSubjectOut.model_validate(item, from_attributes=True) for item in subjects],
|
|
@@ -412,7 +534,10 @@ def read_learning_state(db: Session, *, user_id: str) -> LearningStateSummary:
|
|
| 412 |
for item in lesson_progress
|
| 413 |
if (chapter := chapter_by_id.get(item.chapter_id)) is not None
|
| 414 |
],
|
| 415 |
-
mastery=[
|
|
|
|
|
|
|
|
|
|
| 416 |
repair_items=[repair_item_out(item) for item in repair_items],
|
| 417 |
quiz_attempts=[
|
| 418 |
LearningQuizAttemptOut(
|
|
@@ -456,6 +581,8 @@ def read_learning_state(db: Session, *, user_id: str) -> LearningStateSummary:
|
|
| 456 |
generated_resources=len(resources),
|
| 457 |
generated_notes=sum(item.resource_type == "notes" for item in resources),
|
| 458 |
questions_asked=int(questions_asked),
|
|
|
|
|
|
|
| 459 |
)
|
| 460 |
|
| 461 |
|
|
@@ -582,7 +709,7 @@ def record_lesson_progress(
|
|
| 582 |
)
|
| 583 |
if chapter is None:
|
| 584 |
raise HTTPException(
|
| 585 |
-
status_code=status.
|
| 586 |
detail={
|
| 587 |
"code": "CHAPTER_NOT_IN_STUDY_PLAN",
|
| 588 |
"message": "This chapter is not in the student's saved study plan.",
|
|
@@ -601,7 +728,7 @@ def record_lesson_progress(
|
|
| 601 |
raise HTTPException(status_code=404, detail="Study task not found.")
|
| 602 |
if task.chapter_id and task.chapter_id != chapter.id:
|
| 603 |
raise HTTPException(
|
| 604 |
-
status_code=status.
|
| 605 |
detail={
|
| 606 |
"code": "TASK_CHAPTER_MISMATCH",
|
| 607 |
"message": "This task belongs to a different chapter.",
|
|
@@ -718,6 +845,57 @@ def record_lesson_progress(
|
|
| 718 |
)
|
| 719 |
|
| 720 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 721 |
def record_assessment(
|
| 722 |
db: Session,
|
| 723 |
*,
|
|
@@ -726,6 +904,21 @@ def record_assessment(
|
|
| 726 |
) -> AssessmentConsequenceResponse:
|
| 727 |
from app.services.mastery_engine import EvidenceEvent, MasterySnapshot, apply_evidence
|
| 728 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 729 |
observed = max(0.0, min(100.0, payload.score / payload.max_score * 100.0))
|
| 730 |
now = datetime.now(timezone.utc)
|
| 731 |
profile = db.scalar(select(StudentProfileState).where(StudentProfileState.user_id == user_id))
|
|
@@ -792,6 +985,7 @@ def record_assessment(
|
|
| 792 |
|
| 793 |
attempt = QuizAttempt(
|
| 794 |
user_id=user_id,
|
|
|
|
| 795 |
quiz_id=payload.quiz_id,
|
| 796 |
daily_task_id=payload.daily_task_id,
|
| 797 |
subject_id=payload.subject_id,
|
|
@@ -805,7 +999,25 @@ def record_assessment(
|
|
| 805 |
completed_at=now,
|
| 806 |
)
|
| 807 |
db.add(attempt)
|
| 808 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 809 |
|
| 810 |
if payload.daily_task_id:
|
| 811 |
assessed_task = db.scalar(
|
|
@@ -910,13 +1122,45 @@ def adjust_plan_from_assistant(
|
|
| 910 |
)
|
| 911 |
if target is None:
|
| 912 |
raise HTTPException(
|
| 913 |
-
status_code=status.
|
| 914 |
detail={
|
| 915 |
"code": "SUBJECT_NOT_SELECTED",
|
| 916 |
"message": f"{payload.target_subject} is not in your selected subjects.",
|
| 917 |
},
|
| 918 |
)
|
| 919 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 920 |
replaced = next(
|
| 921 |
(
|
| 922 |
item
|
|
@@ -926,30 +1170,10 @@ def adjust_plan_from_assistant(
|
|
| 926 |
),
|
| 927 |
None,
|
| 928 |
)
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
| 932 |
-
|
| 933 |
-
DailyTask.study_plan_id == active_plan.id,
|
| 934 |
-
DailyTask.subject_id == replaced.id,
|
| 935 |
-
DailyTask.status == "pending",
|
| 936 |
-
DailyTask.scheduled_for <= datetime.now(timezone.utc) + timedelta(days=2),
|
| 937 |
-
)
|
| 938 |
-
).all()
|
| 939 |
-
for task in replaced_tasks:
|
| 940 |
-
task.status = "skipped"
|
| 941 |
-
task.task_metadata = {
|
| 942 |
-
**(task.task_metadata or {}),
|
| 943 |
-
"replaced_by_assistant": True,
|
| 944 |
-
"reason": payload.reason,
|
| 945 |
-
}
|
| 946 |
-
|
| 947 |
-
chapters = db.scalars(
|
| 948 |
-
select(Chapter).where(Chapter.user_id == user_id, Chapter.subject_id == target.id)
|
| 949 |
-
).all()
|
| 950 |
-
by_order = {str(item.order_index): item for item in chapters}
|
| 951 |
-
by_title = {item.title.casefold(): item for item in chapters}
|
| 952 |
-
duration = min(60, max(20, active_plan.daily_minutes // max(1, len(payload.chapters))))
|
| 953 |
start = datetime.now(timezone.utc)
|
| 954 |
existing_revisions = db.scalars(
|
| 955 |
select(DailyTask).where(
|
|
@@ -961,23 +1185,14 @@ def adjust_plan_from_assistant(
|
|
| 961 |
)
|
| 962 |
).all()
|
| 963 |
created: list[DailyTask] = []
|
| 964 |
-
for index, label in enumerate(
|
| 965 |
-
|
| 966 |
-
display = known.title if known else f"Chapter {label}"
|
| 967 |
# Sound Waves is the one fully curated beta chapter. A student asking
|
| 968 |
# DocDoe for a chapter revision should therefore reopen its complete
|
| 969 |
# chapter-test/revision mission in Tuition, not merely create a card
|
| 970 |
# that the Tuition planner cannot resolve to a real lesson.
|
| 971 |
-
curated_mission_id =
|
| 972 |
-
|
| 973 |
-
if known and known.curated and known.catalog_id == "phy-p1-c1"
|
| 974 |
-
else None
|
| 975 |
-
)
|
| 976 |
-
href = (
|
| 977 |
-
"/tuition"
|
| 978 |
-
if known and known.curated
|
| 979 |
-
else f"/study-chat?prompt=Help%20me%20revise%20{payload.target_subject.replace(' ', '%20')}%20{display.replace(' ', '%20')}%20from%20my%20material"
|
| 980 |
-
)
|
| 981 |
existing = next(
|
| 982 |
(
|
| 983 |
task
|
|
@@ -988,7 +1203,7 @@ def adjust_plan_from_assistant(
|
|
| 988 |
None,
|
| 989 |
)
|
| 990 |
if existing is not None:
|
| 991 |
-
existing.chapter_id = known.id
|
| 992 |
existing.title = f"{payload.target_subject} revision: {display}"
|
| 993 |
existing.scheduled_for = start + timedelta(minutes=index * duration)
|
| 994 |
existing.duration_minutes = duration
|
|
@@ -1000,8 +1215,8 @@ def adjust_plan_from_assistant(
|
|
| 1000 |
"assistant_requested": True,
|
| 1001 |
"reason": payload.reason,
|
| 1002 |
"student_chapter_label": label,
|
| 1003 |
-
"verified_chapter":
|
| 1004 |
-
"chapter_catalog_id": known.catalog_id
|
| 1005 |
}
|
| 1006 |
created.append(existing)
|
| 1007 |
continue
|
|
@@ -1009,7 +1224,7 @@ def adjust_plan_from_assistant(
|
|
| 1009 |
user_id=user_id,
|
| 1010 |
study_plan_id=active_plan.id,
|
| 1011 |
subject_id=target.id,
|
| 1012 |
-
chapter_id=known.id
|
| 1013 |
task_type="revision",
|
| 1014 |
title=f"{payload.target_subject} revision: {display}",
|
| 1015 |
status="pending",
|
|
@@ -1022,14 +1237,43 @@ def adjust_plan_from_assistant(
|
|
| 1022 |
"assistant_requested": True,
|
| 1023 |
"reason": payload.reason,
|
| 1024 |
"student_chapter_label": label,
|
| 1025 |
-
"verified_chapter":
|
| 1026 |
-
"chapter_catalog_id": known.catalog_id
|
| 1027 |
},
|
| 1028 |
)
|
| 1029 |
db.add(task)
|
| 1030 |
created.append(task)
|
| 1031 |
db.flush()
|
| 1032 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1033 |
profile.current_subject_id = target.id
|
| 1034 |
profile.current_chapter_id = created[0].chapter_id
|
| 1035 |
profile.current_mission_id = created[0].mission_id
|
|
@@ -1048,7 +1292,23 @@ def adjust_plan_from_assistant(
|
|
| 1048 |
)
|
| 1049 |
db.commit()
|
| 1050 |
subject_map, chapter_map = _task_maps(db, user_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1051 |
return PlanAdjustmentResponse(
|
| 1052 |
tasks=[_task_out(task, subject_map, chapter_map) for task in created],
|
| 1053 |
-
|
|
|
|
|
|
|
| 1054 |
)
|
|
|
|
| 5 |
|
| 6 |
from fastapi import HTTPException, status
|
| 7 |
from sqlalchemy import func, select
|
| 8 |
+
from sqlalchemy.exc import IntegrityError
|
| 9 |
from sqlalchemy.orm import Session
|
| 10 |
|
| 11 |
from app.models.chat_session import ChatMessageRecord, ChatSession
|
| 12 |
+
from app.models.class_session_progress import ClassSessionProgress
|
| 13 |
+
from app.models.learn_anything_roadmap import LearnAnythingRoadmap
|
| 14 |
from app.models.learning_state import (
|
| 15 |
Chapter,
|
| 16 |
DailyTask,
|
|
|
|
| 25 |
TopicMastery,
|
| 26 |
UsageEvent,
|
| 27 |
)
|
| 28 |
+
from app.models.study_profile import StudyProfile
|
| 29 |
from app.services.adaptive_engine import repair_item_out
|
| 30 |
+
from app.services.academic_state import build_academic_projection, current_mastery_state
|
| 31 |
from app.schemas.learning_state import (
|
| 32 |
AssessmentConsequenceResponse,
|
| 33 |
AssessmentResultRequest,
|
|
|
|
| 107 |
return subjects, chapters
|
| 108 |
|
| 109 |
|
| 110 |
+
def _mastery_out(item: TopicMastery, *, resolved_state: str | None = None) -> TopicMasteryOut:
|
| 111 |
"""Expose the mastery engine's evidence-derived fields alongside the row."""
|
| 112 |
evidence = item.evidence or {}
|
| 113 |
last_correct = evidence.get("last_correct_at")
|
|
|
|
| 132 |
attempts_count=item.attempts_count,
|
| 133 |
last_result=item.last_result,
|
| 134 |
next_review_at=item.next_review_at,
|
| 135 |
+
state=resolved_state or item.last_result or "not_started",
|
| 136 |
consecutive_success=int(evidence.get("consecutive_success", 0) or 0),
|
| 137 |
error_categories={
|
| 138 |
str(key): int(value)
|
|
|
|
| 143 |
)
|
| 144 |
|
| 145 |
|
| 146 |
+
def _mirror_onboarding_study_profile(
|
| 147 |
+
db: Session,
|
| 148 |
+
*,
|
| 149 |
+
user_id: str,
|
| 150 |
+
payload: LearningOnboardingRequest,
|
| 151 |
+
available_days: int,
|
| 152 |
+
) -> StudyProfile:
|
| 153 |
+
"""Keep the legacy tutor context in the onboarding transaction.
|
| 154 |
+
|
| 155 |
+
StudyChat, source routing and the onboarding gate still read
|
| 156 |
+
``study_profiles`` while the adaptive planner owns ``student_profiles``.
|
| 157 |
+
Writing both rows before the same commit prevents a completed profile from
|
| 158 |
+
existing without the plan and first task that completion promises.
|
| 159 |
+
"""
|
| 160 |
+
|
| 161 |
+
profile = db.scalar(
|
| 162 |
+
select(StudyProfile)
|
| 163 |
+
.where(StudyProfile.user_id == user_id)
|
| 164 |
+
.with_for_update()
|
| 165 |
+
)
|
| 166 |
+
if profile is None:
|
| 167 |
+
profile = StudyProfile(user_id=user_id)
|
| 168 |
+
|
| 169 |
+
preferences = dict(payload.preferences or {})
|
| 170 |
+
language = preferences.get("language")
|
| 171 |
+
learning_style = preferences.get("learning_style")
|
| 172 |
+
focus_areas = preferences.get("focus_areas")
|
| 173 |
+
daily_time = preferences.get("daily_time")
|
| 174 |
+
preferred_time = preferences.get("preferred_time")
|
| 175 |
+
if not isinstance(daily_time, str) or not daily_time.strip():
|
| 176 |
+
daily_time = (
|
| 177 |
+
f"{payload.daily_minutes // 60} hours"
|
| 178 |
+
if payload.daily_minutes >= 120 and payload.daily_minutes % 60 == 0
|
| 179 |
+
else f"{payload.daily_minutes} minutes"
|
| 180 |
+
)
|
| 181 |
+
if not isinstance(preferred_time, str) or not preferred_time.strip():
|
| 182 |
+
preferred_time = payload.preferred_time
|
| 183 |
+
|
| 184 |
+
extra = dict(profile.extra or {})
|
| 185 |
+
extra.update(
|
| 186 |
+
{
|
| 187 |
+
"schema_version": 2,
|
| 188 |
+
"subjects": list(payload.subjects),
|
| 189 |
+
"daily_time": daily_time,
|
| 190 |
+
"daily_minutes": payload.daily_minutes,
|
| 191 |
+
"preferred_time": preferred_time,
|
| 192 |
+
"focus_areas": focus_areas if isinstance(focus_areas, list) else [],
|
| 193 |
+
"language_preference": language if isinstance(language, str) else None,
|
| 194 |
+
"learning_style": learning_style if isinstance(learning_style, str) else None,
|
| 195 |
+
"available_study_days": available_days,
|
| 196 |
+
"first_plan_days": min(7, available_days),
|
| 197 |
+
"setup_completed_at": datetime.now(timezone.utc).isoformat(),
|
| 198 |
+
}
|
| 199 |
+
)
|
| 200 |
+
|
| 201 |
+
profile.board = payload.board
|
| 202 |
+
profile.grade = payload.class_level
|
| 203 |
+
profile.subject = payload.subjects[0]
|
| 204 |
+
profile.goal = payload.goal
|
| 205 |
+
profile.time_left = payload.exam_date.isoformat() if payload.exam_date else None
|
| 206 |
+
profile.language_preference = language if isinstance(language, str) else None
|
| 207 |
+
profile.source_mode = "onboarding"
|
| 208 |
+
profile.onboarding_completed = 1
|
| 209 |
+
profile.extra = extra
|
| 210 |
+
db.add(profile)
|
| 211 |
+
return profile
|
| 212 |
+
|
| 213 |
+
|
| 214 |
def create_onboarding_plan(
|
| 215 |
db: Session,
|
| 216 |
*,
|
|
|
|
| 227 |
available_days = (payload.exam_date - today).days
|
| 228 |
if available_days <= 0:
|
| 229 |
raise HTTPException(
|
| 230 |
+
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
| 231 |
detail={"code": "EXAM_DATE_NOT_FUTURE", "message": "Choose an exam date after today."},
|
| 232 |
)
|
| 233 |
|
|
|
|
| 387 |
profile.current_subject_id = first_task.subject_id
|
| 388 |
profile.current_chapter_id = first_task.chapter_id
|
| 389 |
profile.current_mission_id = first_task.mission_id
|
| 390 |
+
_mirror_onboarding_study_profile(
|
| 391 |
+
db,
|
| 392 |
+
user_id=user_id,
|
| 393 |
+
payload=payload,
|
| 394 |
+
available_days=available_days,
|
| 395 |
+
)
|
| 396 |
db.add(
|
| 397 |
UsageEvent(
|
| 398 |
user_id=user_id,
|
|
|
|
| 455 |
.where(GeneratedResource.user_id == user_id)
|
| 456 |
.order_by(GeneratedResource.created_at.desc())
|
| 457 |
).all()
|
| 458 |
+
class_sessions = db.scalars(
|
| 459 |
+
select(ClassSessionProgress)
|
| 460 |
+
.where(ClassSessionProgress.user_id == user_id)
|
| 461 |
+
.order_by(ClassSessionProgress.updated_at.desc())
|
| 462 |
+
.limit(50)
|
| 463 |
+
).all()
|
| 464 |
+
roadmaps = db.scalars(
|
| 465 |
+
select(LearnAnythingRoadmap)
|
| 466 |
+
.where(LearnAnythingRoadmap.user_id == user_id)
|
| 467 |
+
.order_by(LearnAnythingRoadmap.updated_at.desc())
|
| 468 |
+
.limit(50)
|
| 469 |
+
).all()
|
| 470 |
+
chat_sessions = db.scalars(
|
| 471 |
+
select(ChatSession)
|
| 472 |
+
.where(ChatSession.user_id == user_id)
|
| 473 |
+
.order_by(ChatSession.updated_at.desc())
|
| 474 |
+
.limit(50)
|
| 475 |
+
).all()
|
| 476 |
questions_asked = (
|
| 477 |
db.scalar(
|
| 478 |
select(func.count(ChatMessageRecord.id))
|
|
|
|
| 487 |
subject_map = {item.id: item.name for item in subjects}
|
| 488 |
chapter_map = {item.id: item.title for item in chapters}
|
| 489 |
chapter_by_id = {item.id: item for item in chapters}
|
| 490 |
+
resolved_now = datetime.now(timezone.utc)
|
| 491 |
+
academic_state, next_action = build_academic_projection(
|
| 492 |
+
profile=profile,
|
| 493 |
+
subjects=list(subjects),
|
| 494 |
+
chapters=list(chapters),
|
| 495 |
+
tasks=list(tasks),
|
| 496 |
+
lesson_progress=list(lesson_progress),
|
| 497 |
+
mastery=list(mastery),
|
| 498 |
+
repairs=list(repair_items),
|
| 499 |
+
attempts=list(attempts),
|
| 500 |
+
class_sessions=list(class_sessions),
|
| 501 |
+
roadmaps=list(roadmaps),
|
| 502 |
+
chat_sessions=list(chat_sessions),
|
| 503 |
+
now=resolved_now,
|
| 504 |
+
)
|
| 505 |
+
open_repair_keys = {item.concept_key for item in repair_items if item.status == "open"}
|
| 506 |
+
mastery_states = {
|
| 507 |
+
item.topic_key: current_mastery_state(
|
| 508 |
+
item,
|
| 509 |
+
now=resolved_now,
|
| 510 |
+
exam_date=profile.exam_date if profile else None,
|
| 511 |
+
has_open_repair=item.topic_key in open_repair_keys,
|
| 512 |
+
)
|
| 513 |
+
for item in mastery
|
| 514 |
+
}
|
| 515 |
return LearningStateSummary(
|
| 516 |
profile=LearningProfileOut.model_validate(profile, from_attributes=True) if profile else None,
|
| 517 |
subjects=[LearningSubjectOut.model_validate(item, from_attributes=True) for item in subjects],
|
|
|
|
| 534 |
for item in lesson_progress
|
| 535 |
if (chapter := chapter_by_id.get(item.chapter_id)) is not None
|
| 536 |
],
|
| 537 |
+
mastery=[
|
| 538 |
+
_mastery_out(item, resolved_state=mastery_states.get(item.topic_key))
|
| 539 |
+
for item in mastery
|
| 540 |
+
],
|
| 541 |
repair_items=[repair_item_out(item) for item in repair_items],
|
| 542 |
quiz_attempts=[
|
| 543 |
LearningQuizAttemptOut(
|
|
|
|
| 581 |
generated_resources=len(resources),
|
| 582 |
generated_notes=sum(item.resource_type == "notes" for item in resources),
|
| 583 |
questions_asked=int(questions_asked),
|
| 584 |
+
academic_state=academic_state,
|
| 585 |
+
next_action=next_action,
|
| 586 |
)
|
| 587 |
|
| 588 |
|
|
|
|
| 709 |
)
|
| 710 |
if chapter is None:
|
| 711 |
raise HTTPException(
|
| 712 |
+
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
| 713 |
detail={
|
| 714 |
"code": "CHAPTER_NOT_IN_STUDY_PLAN",
|
| 715 |
"message": "This chapter is not in the student's saved study plan.",
|
|
|
|
| 728 |
raise HTTPException(status_code=404, detail="Study task not found.")
|
| 729 |
if task.chapter_id and task.chapter_id != chapter.id:
|
| 730 |
raise HTTPException(
|
| 731 |
+
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
| 732 |
detail={
|
| 733 |
"code": "TASK_CHAPTER_MISMATCH",
|
| 734 |
"message": "This task belongs to a different chapter.",
|
|
|
|
| 845 |
)
|
| 846 |
|
| 847 |
|
| 848 |
+
def _replayed_assessment_response(
|
| 849 |
+
db: Session,
|
| 850 |
+
*,
|
| 851 |
+
user_id: str,
|
| 852 |
+
payload: AssessmentResultRequest,
|
| 853 |
+
attempt: QuizAttempt,
|
| 854 |
+
) -> AssessmentConsequenceResponse:
|
| 855 |
+
mastery = db.scalar(
|
| 856 |
+
select(TopicMastery).where(
|
| 857 |
+
TopicMastery.user_id == user_id,
|
| 858 |
+
TopicMastery.topic_key == payload.topic_key,
|
| 859 |
+
)
|
| 860 |
+
)
|
| 861 |
+
score = mastery.score if mastery is not None else 0.0
|
| 862 |
+
tasks = db.scalars(
|
| 863 |
+
select(DailyTask).where(DailyTask.user_id == user_id)
|
| 864 |
+
).all()
|
| 865 |
+
revision_task = next(
|
| 866 |
+
(
|
| 867 |
+
task
|
| 868 |
+
for task in tasks
|
| 869 |
+
if (task.task_metadata or {}).get("assessment_attempt_id") == attempt.id
|
| 870 |
+
),
|
| 871 |
+
None,
|
| 872 |
+
)
|
| 873 |
+
next_task = revision_task or next(
|
| 874 |
+
(
|
| 875 |
+
task
|
| 876 |
+
for task in sorted(
|
| 877 |
+
tasks,
|
| 878 |
+
key=lambda item: (-item.priority, item.scheduled_for),
|
| 879 |
+
)
|
| 880 |
+
if task.status == "pending"
|
| 881 |
+
),
|
| 882 |
+
None,
|
| 883 |
+
)
|
| 884 |
+
subject_map, chapter_map = _task_maps(db, user_id)
|
| 885 |
+
return AssessmentConsequenceResponse(
|
| 886 |
+
attempt_id=attempt.id,
|
| 887 |
+
mastery_before=round(score, 2),
|
| 888 |
+
mastery_after=round(score, 2),
|
| 889 |
+
revision_task=(
|
| 890 |
+
_task_out(revision_task, subject_map, chapter_map)
|
| 891 |
+
if revision_task
|
| 892 |
+
else None
|
| 893 |
+
),
|
| 894 |
+
next_recommended_task_id=next_task.id if next_task else None,
|
| 895 |
+
message="This assessment was already saved. No duplicate mastery or revision change was created.",
|
| 896 |
+
)
|
| 897 |
+
|
| 898 |
+
|
| 899 |
def record_assessment(
|
| 900 |
db: Session,
|
| 901 |
*,
|
|
|
|
| 904 |
) -> AssessmentConsequenceResponse:
|
| 905 |
from app.services.mastery_engine import EvidenceEvent, MasterySnapshot, apply_evidence
|
| 906 |
|
| 907 |
+
if payload.client_attempt_id:
|
| 908 |
+
existing_attempt = db.scalar(
|
| 909 |
+
select(QuizAttempt).where(
|
| 910 |
+
QuizAttempt.user_id == user_id,
|
| 911 |
+
QuizAttempt.client_attempt_id == payload.client_attempt_id,
|
| 912 |
+
)
|
| 913 |
+
)
|
| 914 |
+
if existing_attempt is not None:
|
| 915 |
+
return _replayed_assessment_response(
|
| 916 |
+
db,
|
| 917 |
+
user_id=user_id,
|
| 918 |
+
payload=payload,
|
| 919 |
+
attempt=existing_attempt,
|
| 920 |
+
)
|
| 921 |
+
|
| 922 |
observed = max(0.0, min(100.0, payload.score / payload.max_score * 100.0))
|
| 923 |
now = datetime.now(timezone.utc)
|
| 924 |
profile = db.scalar(select(StudentProfileState).where(StudentProfileState.user_id == user_id))
|
|
|
|
| 985 |
|
| 986 |
attempt = QuizAttempt(
|
| 987 |
user_id=user_id,
|
| 988 |
+
client_attempt_id=payload.client_attempt_id,
|
| 989 |
quiz_id=payload.quiz_id,
|
| 990 |
daily_task_id=payload.daily_task_id,
|
| 991 |
subject_id=payload.subject_id,
|
|
|
|
| 999 |
completed_at=now,
|
| 1000 |
)
|
| 1001 |
db.add(attempt)
|
| 1002 |
+
try:
|
| 1003 |
+
db.flush()
|
| 1004 |
+
except IntegrityError:
|
| 1005 |
+
db.rollback()
|
| 1006 |
+
if payload.client_attempt_id:
|
| 1007 |
+
existing_attempt = db.scalar(
|
| 1008 |
+
select(QuizAttempt).where(
|
| 1009 |
+
QuizAttempt.user_id == user_id,
|
| 1010 |
+
QuizAttempt.client_attempt_id == payload.client_attempt_id,
|
| 1011 |
+
)
|
| 1012 |
+
)
|
| 1013 |
+
if existing_attempt is not None:
|
| 1014 |
+
return _replayed_assessment_response(
|
| 1015 |
+
db,
|
| 1016 |
+
user_id=user_id,
|
| 1017 |
+
payload=payload,
|
| 1018 |
+
attempt=existing_attempt,
|
| 1019 |
+
)
|
| 1020 |
+
raise
|
| 1021 |
|
| 1022 |
if payload.daily_task_id:
|
| 1023 |
assessed_task = db.scalar(
|
|
|
|
| 1122 |
)
|
| 1123 |
if target is None:
|
| 1124 |
raise HTTPException(
|
| 1125 |
+
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
| 1126 |
detail={
|
| 1127 |
"code": "SUBJECT_NOT_SELECTED",
|
| 1128 |
"message": f"{payload.target_subject} is not in your selected subjects.",
|
| 1129 |
},
|
| 1130 |
)
|
| 1131 |
|
| 1132 |
+
chapters = db.scalars(
|
| 1133 |
+
select(Chapter).where(
|
| 1134 |
+
Chapter.user_id == user_id,
|
| 1135 |
+
Chapter.subject_id == target.id,
|
| 1136 |
+
Chapter.curated.is_(True),
|
| 1137 |
+
Chapter.status == "available",
|
| 1138 |
+
)
|
| 1139 |
+
).all()
|
| 1140 |
+
by_order = {str(item.order_index): item for item in chapters}
|
| 1141 |
+
by_title = {item.title.casefold(): item for item in chapters}
|
| 1142 |
+
resolved_chapters: list[tuple[str, Chapter]] = []
|
| 1143 |
+
unavailable_chapters: list[str] = []
|
| 1144 |
+
for label in payload.chapters:
|
| 1145 |
+
known = by_order.get(label.strip()) or by_title.get(label.casefold())
|
| 1146 |
+
if known is None:
|
| 1147 |
+
unavailable_chapters.append(label)
|
| 1148 |
+
else:
|
| 1149 |
+
resolved_chapters.append((label, known))
|
| 1150 |
+
|
| 1151 |
+
if not resolved_chapters:
|
| 1152 |
+
raise HTTPException(
|
| 1153 |
+
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
|
| 1154 |
+
detail={
|
| 1155 |
+
"code": "CHAPTER_NOT_AVAILABLE",
|
| 1156 |
+
"message": (
|
| 1157 |
+
f"DocDoe does not have a verified {target.name} class or source "
|
| 1158 |
+
f"for {', '.join(unavailable_chapters)} yet. Your existing plan is unchanged."
|
| 1159 |
+
),
|
| 1160 |
+
"unavailable_chapters": unavailable_chapters,
|
| 1161 |
+
},
|
| 1162 |
+
)
|
| 1163 |
+
|
| 1164 |
replaced = next(
|
| 1165 |
(
|
| 1166 |
item
|
|
|
|
| 1170 |
),
|
| 1171 |
None,
|
| 1172 |
)
|
| 1173 |
+
duration = min(
|
| 1174 |
+
60,
|
| 1175 |
+
max(20, active_plan.daily_minutes // max(1, len(resolved_chapters))),
|
| 1176 |
+
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1177 |
start = datetime.now(timezone.utc)
|
| 1178 |
existing_revisions = db.scalars(
|
| 1179 |
select(DailyTask).where(
|
|
|
|
| 1185 |
)
|
| 1186 |
).all()
|
| 1187 |
created: list[DailyTask] = []
|
| 1188 |
+
for index, (label, known) in enumerate(resolved_chapters):
|
| 1189 |
+
display = known.title
|
|
|
|
| 1190 |
# Sound Waves is the one fully curated beta chapter. A student asking
|
| 1191 |
# DocDoe for a chapter revision should therefore reopen its complete
|
| 1192 |
# chapter-test/revision mission in Tuition, not merely create a card
|
| 1193 |
# that the Tuition planner cannot resolve to a real lesson.
|
| 1194 |
+
curated_mission_id = "M9" if known.catalog_id == "phy-p1-c1" else None
|
| 1195 |
+
href = "/tuition"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1196 |
existing = next(
|
| 1197 |
(
|
| 1198 |
task
|
|
|
|
| 1203 |
None,
|
| 1204 |
)
|
| 1205 |
if existing is not None:
|
| 1206 |
+
existing.chapter_id = known.id
|
| 1207 |
existing.title = f"{payload.target_subject} revision: {display}"
|
| 1208 |
existing.scheduled_for = start + timedelta(minutes=index * duration)
|
| 1209 |
existing.duration_minutes = duration
|
|
|
|
| 1215 |
"assistant_requested": True,
|
| 1216 |
"reason": payload.reason,
|
| 1217 |
"student_chapter_label": label,
|
| 1218 |
+
"verified_chapter": True,
|
| 1219 |
+
"chapter_catalog_id": known.catalog_id,
|
| 1220 |
}
|
| 1221 |
created.append(existing)
|
| 1222 |
continue
|
|
|
|
| 1224 |
user_id=user_id,
|
| 1225 |
study_plan_id=active_plan.id,
|
| 1226 |
subject_id=target.id,
|
| 1227 |
+
chapter_id=known.id,
|
| 1228 |
task_type="revision",
|
| 1229 |
title=f"{payload.target_subject} revision: {display}",
|
| 1230 |
status="pending",
|
|
|
|
| 1237 |
"assistant_requested": True,
|
| 1238 |
"reason": payload.reason,
|
| 1239 |
"student_chapter_label": label,
|
| 1240 |
+
"verified_chapter": True,
|
| 1241 |
+
"chapter_catalog_id": known.catalog_id,
|
| 1242 |
},
|
| 1243 |
)
|
| 1244 |
db.add(task)
|
| 1245 |
created.append(task)
|
| 1246 |
db.flush()
|
| 1247 |
|
| 1248 |
+
rescheduled_tasks = 0
|
| 1249 |
+
if replaced is not None:
|
| 1250 |
+
replaced_tasks = db.scalars(
|
| 1251 |
+
select(DailyTask).where(
|
| 1252 |
+
DailyTask.user_id == user_id,
|
| 1253 |
+
DailyTask.study_plan_id == active_plan.id,
|
| 1254 |
+
DailyTask.subject_id == replaced.id,
|
| 1255 |
+
DailyTask.status == "pending",
|
| 1256 |
+
DailyTask.scheduled_for <= start + timedelta(days=2),
|
| 1257 |
+
).order_by(DailyTask.scheduled_for, DailyTask.created_at)
|
| 1258 |
+
).all()
|
| 1259 |
+
next_slot = created[-1].scheduled_for + timedelta(
|
| 1260 |
+
minutes=created[-1].duration_minutes
|
| 1261 |
+
)
|
| 1262 |
+
for task in replaced_tasks:
|
| 1263 |
+
metadata = task.task_metadata or {}
|
| 1264 |
+
task.task_metadata = {
|
| 1265 |
+
**metadata,
|
| 1266 |
+
"rescheduled_by_assistant": True,
|
| 1267 |
+
"assistant_original_scheduled_for": metadata.get(
|
| 1268 |
+
"assistant_original_scheduled_for",
|
| 1269 |
+
task.scheduled_for.isoformat(),
|
| 1270 |
+
),
|
| 1271 |
+
"reason": payload.reason,
|
| 1272 |
+
}
|
| 1273 |
+
task.scheduled_for = next_slot
|
| 1274 |
+
next_slot += timedelta(minutes=max(5, task.duration_minutes))
|
| 1275 |
+
rescheduled_tasks += 1
|
| 1276 |
+
|
| 1277 |
profile.current_subject_id = target.id
|
| 1278 |
profile.current_chapter_id = created[0].chapter_id
|
| 1279 |
profile.current_mission_id = created[0].mission_id
|
|
|
|
| 1292 |
)
|
| 1293 |
db.commit()
|
| 1294 |
subject_map, chapter_map = _task_maps(db, user_id)
|
| 1295 |
+
message = (
|
| 1296 |
+
f"Plan updated. {target.name} now comes first with {len(created)} verified "
|
| 1297 |
+
f"revision task{'s' if len(created) != 1 else ''}."
|
| 1298 |
+
)
|
| 1299 |
+
if rescheduled_tasks and replaced is not None:
|
| 1300 |
+
message += (
|
| 1301 |
+
f" {rescheduled_tasks} upcoming {replaced.name} "
|
| 1302 |
+
f"task{'s were' if rescheduled_tasks != 1 else ' was'} rescheduled, not removed."
|
| 1303 |
+
)
|
| 1304 |
+
if unavailable_chapters:
|
| 1305 |
+
message += (
|
| 1306 |
+
f" I did not add {', '.join(unavailable_chapters)} because DocDoe has "
|
| 1307 |
+
"no verified class or source for them yet."
|
| 1308 |
+
)
|
| 1309 |
return PlanAdjustmentResponse(
|
| 1310 |
tasks=[_task_out(task, subject_map, chapter_map) for task in created],
|
| 1311 |
+
unavailable_chapters=unavailable_chapters,
|
| 1312 |
+
rescheduled_tasks=rescheduled_tasks,
|
| 1313 |
+
message=message,
|
| 1314 |
)
|
app/services/physics_curriculum_repository.py
CHANGED
|
@@ -12,7 +12,9 @@ from app.core.config import PROJECT_ROOT
|
|
| 12 |
CURRICULUM_ROOT = PROJECT_ROOT / "data" / "curriculum" / "kerala-sslc" / "physics"
|
| 13 |
MANIFEST_PATH = CURRICULUM_ROOT / "chapter-manifest.json"
|
| 14 |
GENERATED_ROOT = CURRICULUM_ROOT / "generated"
|
| 15 |
-
|
|
|
|
|
|
|
| 16 |
CHAPTER_VIDEO_SLUGS = {
|
| 17 |
"phy-p1-c1": "sound-waves",
|
| 18 |
"phy-p1-c2": "lenses",
|
|
@@ -103,63 +105,66 @@ def get_lesson_artifact(lesson_id: str, artifact: str) -> Any:
|
|
| 103 |
return _read_json(path)
|
| 104 |
|
| 105 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
def get_chapter_video_status(chapter_id: str) -> dict[str, Any]:
|
| 107 |
chapter = get_chapter(chapter_id)
|
| 108 |
-
|
| 109 |
-
if slug is None:
|
| 110 |
raise PhysicsCurriculumNotFound(f"No production-video mapping exists for chapter {chapter_id}")
|
| 111 |
-
manifest_path =
|
| 112 |
if not manifest_path.is_file():
|
| 113 |
-
package_path = PRODUCTION_ROOT / slug / "full-chapter" / "render-manifest.json"
|
| 114 |
-
package = _read_json(package_path) if package_path.is_file() else None
|
| 115 |
return {
|
| 116 |
"chapterId": chapter_id,
|
| 117 |
"title": chapter.get("title"),
|
| 118 |
-
"status": "
|
| 119 |
"ready": False,
|
| 120 |
"qaPassed": False,
|
| 121 |
-
"audioPresent":
|
| 122 |
-
"resolution":
|
| 123 |
-
"durationSeconds":
|
| 124 |
"downloadUrl": None,
|
| 125 |
}
|
| 126 |
manifest = _read_json(manifest_path)
|
| 127 |
-
output_path =
|
| 128 |
-
|
| 129 |
-
|
| 130 |
-
ready = bool(manifest.get("qaPassed")) and manifest.get("status") == "ready" and output_path.is_file()
|
| 131 |
return {
|
| 132 |
"chapterId": chapter_id,
|
| 133 |
"title": chapter.get("title"),
|
| 134 |
"status": "ready" if ready else "qa_failed",
|
| 135 |
"ready": ready,
|
| 136 |
-
"qaPassed":
|
| 137 |
-
"audioPresent":
|
| 138 |
-
"resolution":
|
| 139 |
-
"durationSeconds": manifest.get("
|
| 140 |
"downloadUrl": (
|
| 141 |
-
|
| 142 |
-
or (
|
| 143 |
-
f"/video/physics-curriculum/chapters/{chapter_id}/video/file"
|
| 144 |
-
if ready and chapter_id == "phy-p1-c1"
|
| 145 |
-
else None
|
| 146 |
-
)
|
| 147 |
),
|
| 148 |
}
|
| 149 |
|
| 150 |
|
| 151 |
def get_chapter_video_file(chapter_id: str) -> Path:
|
| 152 |
-
"""Return the QA-passed
|
| 153 |
status_payload = get_chapter_video_status(chapter_id)
|
| 154 |
if not status_payload.get("ready"):
|
| 155 |
raise PhysicsCurriculumNotFound(f"Production video is unavailable for chapter {chapter_id}")
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
output_path = Path(str(manifest.get("outputFile") or ""))
|
| 161 |
-
if not output_path.is_absolute():
|
| 162 |
-
output_path = PROJECT_ROOT / output_path
|
| 163 |
resolved = output_path.resolve()
|
| 164 |
production_root = PRODUCTION_ROOT.resolve()
|
| 165 |
if production_root not in resolved.parents or not resolved.is_file():
|
|
|
|
| 12 |
CURRICULUM_ROOT = PROJECT_ROOT / "data" / "curriculum" / "kerala-sslc" / "physics"
|
| 13 |
MANIFEST_PATH = CURRICULUM_ROOT / "chapter-manifest.json"
|
| 14 |
GENERATED_ROOT = CURRICULUM_ROOT / "generated"
|
| 15 |
+
# The V2 Remotion renderer (scripts/video-engine/render-physics-v2-chapter.ts)
|
| 16 |
+
# writes one render-manifest.json per chapter ID (not slug) under this root.
|
| 17 |
+
PRODUCTION_ROOT = PROJECT_ROOT / "outputs" / "video" / "physics-course-2025-v2" / "renders"
|
| 18 |
CHAPTER_VIDEO_SLUGS = {
|
| 19 |
"phy-p1-c1": "sound-waves",
|
| 20 |
"phy-p1-c2": "lenses",
|
|
|
|
| 105 |
return _read_json(path)
|
| 106 |
|
| 107 |
|
| 108 |
+
def _v2_manifest_path(chapter_id: str) -> Path:
|
| 109 |
+
return PRODUCTION_ROOT / chapter_id / "final" / "render-manifest.json"
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
def _v2_mp4_output(manifest: dict[str, Any]) -> Path | None:
|
| 113 |
+
"""The V2 manifest lists deliverables (HLS master + compat MP4); pick the
|
| 114 |
+
flat MP4 for direct <video> playback, matching how HLS assets sit alongside it."""
|
| 115 |
+
outputs = (manifest.get("deliverables") or {}).get("outputs") or []
|
| 116 |
+
mp4s = [o for o in outputs if str(o).endswith(".mp4")]
|
| 117 |
+
if not mp4s:
|
| 118 |
+
return None
|
| 119 |
+
path = Path(str(mp4s[0]))
|
| 120 |
+
return path if path.is_absolute() else PROJECT_ROOT / path
|
| 121 |
+
|
| 122 |
+
|
| 123 |
def get_chapter_video_status(chapter_id: str) -> dict[str, Any]:
|
| 124 |
chapter = get_chapter(chapter_id)
|
| 125 |
+
if chapter_id not in CHAPTER_VIDEO_SLUGS:
|
|
|
|
| 126 |
raise PhysicsCurriculumNotFound(f"No production-video mapping exists for chapter {chapter_id}")
|
| 127 |
+
manifest_path = _v2_manifest_path(chapter_id)
|
| 128 |
if not manifest_path.is_file():
|
|
|
|
|
|
|
| 129 |
return {
|
| 130 |
"chapterId": chapter_id,
|
| 131 |
"title": chapter.get("title"),
|
| 132 |
+
"status": "not_generated",
|
| 133 |
"ready": False,
|
| 134 |
"qaPassed": False,
|
| 135 |
+
"audioPresent": False,
|
| 136 |
+
"resolution": None,
|
| 137 |
+
"durationSeconds": None,
|
| 138 |
"downloadUrl": None,
|
| 139 |
}
|
| 140 |
manifest = _read_json(manifest_path)
|
| 141 |
+
output_path = _v2_mp4_output(manifest)
|
| 142 |
+
qa_passed = bool(manifest.get("passed")) and not (manifest.get("blackFrames") or [])
|
| 143 |
+
ready = qa_passed and output_path is not None and output_path.is_file()
|
|
|
|
| 144 |
return {
|
| 145 |
"chapterId": chapter_id,
|
| 146 |
"title": chapter.get("title"),
|
| 147 |
"status": "ready" if ready else "qa_failed",
|
| 148 |
"ready": ready,
|
| 149 |
+
"qaPassed": qa_passed,
|
| 150 |
+
"audioPresent": ready, # V2 full-chapter MP4 always muxes English narration.
|
| 151 |
+
"resolution": "1920x1080" if ready else None,
|
| 152 |
+
"durationSeconds": manifest.get("visualDurationSeconds"),
|
| 153 |
"downloadUrl": (
|
| 154 |
+
f"/video/physics-curriculum/chapters/{chapter_id}/video/file" if ready else None
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 155 |
),
|
| 156 |
}
|
| 157 |
|
| 158 |
|
| 159 |
def get_chapter_video_file(chapter_id: str) -> Path:
|
| 160 |
+
"""Return the QA-passed chapter video file without exposing arbitrary paths."""
|
| 161 |
status_payload = get_chapter_video_status(chapter_id)
|
| 162 |
if not status_payload.get("ready"):
|
| 163 |
raise PhysicsCurriculumNotFound(f"Production video is unavailable for chapter {chapter_id}")
|
| 164 |
+
manifest = _read_json(_v2_manifest_path(chapter_id))
|
| 165 |
+
output_path = _v2_mp4_output(manifest)
|
| 166 |
+
if output_path is None:
|
| 167 |
+
raise PhysicsCurriculumNotFound(f"Production video file is unavailable for chapter {chapter_id}")
|
|
|
|
|
|
|
|
|
|
| 168 |
resolved = output_path.resolve()
|
| 169 |
production_root = PRODUCTION_ROOT.resolve()
|
| 170 |
if production_root not in resolved.parents or not resolved.is_file():
|
{backend/app → app}/services/social_science_curriculum_repository.py
RENAMED
|
File without changes
|
app/services/tts_provider.py
CHANGED
|
@@ -371,7 +371,7 @@ class AI4BharatIndicParlerTTSProvider(BaseTTSProvider):
|
|
| 371 |
prompt_attention_mask=prompt_inputs.attention_mask,
|
| 372 |
**generation_options,
|
| 373 |
)
|
| 374 |
-
candidate = generation.detach().to("cpu", dtype=torch.float32).numpy().
|
| 375 |
with tempfile.NamedTemporaryFile(
|
| 376 |
suffix=".wav",
|
| 377 |
delete=False,
|
|
|
|
| 371 |
prompt_attention_mask=prompt_inputs.attention_mask,
|
| 372 |
**generation_options,
|
| 373 |
)
|
| 374 |
+
candidate = generation.detach().to("cpu", dtype=torch.float32).numpy().reshape(-1)
|
| 375 |
with tempfile.NamedTemporaryFile(
|
| 376 |
suffix=".wav",
|
| 377 |
delete=False,
|
{backend/app → app}/services/workspace_context.py
RENAMED
|
File without changes
|
backend/.dockerignore
DELETED
|
@@ -1,16 +0,0 @@
|
|
| 1 |
-
.env
|
| 2 |
-
.env.local
|
| 3 |
-
.env.production
|
| 4 |
-
.env.huggingface
|
| 5 |
-
.venv
|
| 6 |
-
__pycache__
|
| 7 |
-
*.pyc
|
| 8 |
-
.pytest_cache
|
| 9 |
-
.ruff_cache
|
| 10 |
-
*.db
|
| 11 |
-
uploads/*
|
| 12 |
-
!uploads/.gitkeep
|
| 13 |
-
generated-video-jobs
|
| 14 |
-
generated-videos
|
| 15 |
-
uvicorn_log.txt
|
| 16 |
-
*.log
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/.env.example
DELETED
|
@@ -1,178 +0,0 @@
|
|
| 1 |
-
APP_NAME="AI Exam Success API"
|
| 2 |
-
ENVIRONMENT="development"
|
| 3 |
-
|
| 4 |
-
# PostgreSQL target for production/local DB work.
|
| 5 |
-
# If DATABASE_URL is not set, the app falls back to a local SQLite file so the
|
| 6 |
-
# mock backend can run immediately during early development.
|
| 7 |
-
DATABASE_URL="postgresql+psycopg://postgres:postgres@localhost:5432/exam_success"
|
| 8 |
-
|
| 9 |
-
# Production recovery controls. Development does not require these.
|
| 10 |
-
DATABASE_BACKUP_STRATEGY=""
|
| 11 |
-
DATABASE_BACKUP_RETENTION_DAYS="14"
|
| 12 |
-
DATABASE_RESTORE_TESTED_AT=""
|
| 13 |
-
DATABASE_BACKUP_DIR=""
|
| 14 |
-
|
| 15 |
-
CORS_ORIGINS="http://localhost:3000,http://127.0.0.1:3000,http://localhost:3003,http://127.0.0.1:3003"
|
| 16 |
-
UPLOAD_DIR="uploads"
|
| 17 |
-
|
| 18 |
-
# AI provider settings.
|
| 19 |
-
# Use AI_PROVIDER="mock" for local free development without an API key.
|
| 20 |
-
# Use AI_PROVIDER="sarvam" with SARVAM_API_KEY for real AI outputs (recommended).
|
| 21 |
-
# Use AI_PROVIDER="openrouter" with OPENROUTER_API_KEY for OpenRouter models.
|
| 22 |
-
AI_PROVIDER="sarvam"
|
| 23 |
-
SARVAM_API_KEY=""
|
| 24 |
-
SARVAM_BASE_URL="https://api.sarvam.ai/v1"
|
| 25 |
-
SARVAM_MODEL_MAIN="sarvam-30b"
|
| 26 |
-
SARVAM_MODEL_HEAVY="sarvam-105b"
|
| 27 |
-
SARVAM_MODEL_NOTES="sarvam-30b"
|
| 28 |
-
SARVAM_MODEL_QUIZ="sarvam-30b"
|
| 29 |
-
SARVAM_MODEL_FLASHCARDS="sarvam-30b"
|
| 30 |
-
SARVAM_MODEL_EXAM_MODE="sarvam-105b"
|
| 31 |
-
SARVAM_MODEL_PYQ_ANALYSIS="sarvam-105b"
|
| 32 |
-
SARVAM_MODEL_VIDEO_SCRIPT="sarvam-105b"
|
| 33 |
-
SARVAM_TIMEOUT_SECONDS="90"
|
| 34 |
-
SARVAM_MAX_RETRIES="2"
|
| 35 |
-
AI_FALLBACK_TO_MOCK="true"
|
| 36 |
-
MOCK_AI_MODEL_NAME="mock-exam-tutor-v1"
|
| 37 |
-
|
| 38 |
-
# Exa is used only when the student explicitly asks to search the web or asks
|
| 39 |
-
# for current/latest information. Keep this backend-only; never use NEXT_PUBLIC_.
|
| 40 |
-
EXA_API_KEY=""
|
| 41 |
-
EXA_BASE_URL="https://api.exa.ai"
|
| 42 |
-
EXA_SEARCH_TIMEOUT_SECONDS="15"
|
| 43 |
-
EXA_SEARCH_MAX_RESULTS="6"
|
| 44 |
-
|
| 45 |
-
# Legacy OpenRouter settings (set AI_PROVIDER="openrouter" to use)
|
| 46 |
-
OPENROUTER_API_KEY=""
|
| 47 |
-
OPENROUTER_BASE_URL="https://openrouter.ai/api/v1"
|
| 48 |
-
OPENROUTER_MODEL_MAIN="deepseek/deepseek-v4-flash"
|
| 49 |
-
OPENROUTER_MODEL_LLAMA="qwen/qwen3-next-80b-a3b-instruct"
|
| 50 |
-
AI_MAX_RETRIES="2"
|
| 51 |
-
AI_TIMEOUT_SECONDS="120"
|
| 52 |
-
|
| 53 |
-
# Learn Anything lesson authoring (OpenAI-compatible chat via Groq).
|
| 54 |
-
# Server-side only — never put this in NEXT_PUBLIC_*.
|
| 55 |
-
GROQ_API_KEY=""
|
| 56 |
-
|
| 57 |
-
# Scale knobs for ~1k concurrent students (raise in production Postgres deploys).
|
| 58 |
-
DATABASE_POOL_SIZE="10"
|
| 59 |
-
DATABASE_MAX_OVERFLOW="20"
|
| 60 |
-
RATE_LIMIT_ENABLED="true"
|
| 61 |
-
RATE_LIMIT_AI_REQUESTS_PER_DAY="80"
|
| 62 |
-
|
| 63 |
-
# TTS provider settings.
|
| 64 |
-
# AI4Bharat Indic Parler is the local Indian-English + Malayalam teacher voice.
|
| 65 |
-
# Accept its free Hugging Face access terms once, then set HUGGINGFACE_API_KEY.
|
| 66 |
-
# Use TTS_PROVIDER="mock" only for silent timing tests.
|
| 67 |
-
TTS_PROVIDER="ai4bharat"
|
| 68 |
-
TTS_DEFAULT_PROVIDER="ai4bharat"
|
| 69 |
-
VIDEO_TTS_PROVIDER="ai4bharat"
|
| 70 |
-
TTS_DEFAULT_VOICE="nila"
|
| 71 |
-
TTS_OUTPUT_DIR="public/generated/audio"
|
| 72 |
-
AI4BHARAT_TTS_MODEL="ai4bharat/indic-parler-tts"
|
| 73 |
-
AI4BHARAT_TTS_SPEAKER="Anjali"
|
| 74 |
-
AI4BHARAT_TTS_ENGLISH_SPEAKER="Mary"
|
| 75 |
-
AI4BHARAT_TTS_DEVICE="auto"
|
| 76 |
-
AI4BHARAT_TTS_PRECISION="float32"
|
| 77 |
-
AUDIO_SCENE_TIMEOUT_SECONDS="45"
|
| 78 |
-
AUDIO_TOTAL_TIMEOUT_SECONDS="300"
|
| 79 |
-
AUDIO_MAX_RETRIES="2"
|
| 80 |
-
AUDIO_MAX_CONCURRENCY="3"
|
| 81 |
-
KOKORO_VOICE="af_heart"
|
| 82 |
-
KOKORO_SPEED="0.95"
|
| 83 |
-
INDIC_TTS_MODEL=""
|
| 84 |
-
INDIC_TTS_LANGUAGE="ml"
|
| 85 |
-
EDGE_TTS_VOICE_EN="en-US-JennyNeural"
|
| 86 |
-
EDGE_TTS_VOICE_ML="ml-IN-SobhanaNeural"
|
| 87 |
-
EDGE_TTS_RATE="+15%"
|
| 88 |
-
ELEVENLABS_API_KEY=""
|
| 89 |
-
ELEVENLABS_VOICE_ID=""
|
| 90 |
-
AUDIO_NORMALIZE="true"
|
| 91 |
-
|
| 92 |
-
# Local Remotion final render settings.
|
| 93 |
-
GENERATED_VIDEO_JOBS_DIR="generated-video-jobs"
|
| 94 |
-
GENERATED_VIDEO_OUTPUT_DIR="generated-videos"
|
| 95 |
-
VIDEO_RENDER_TIMEOUT_SECONDS="420"
|
| 96 |
-
VIDEO_MIN_OUTPUT_BYTES="4096"
|
| 97 |
-
VIDEO_BETA_MAX_SCENES="5"
|
| 98 |
-
VIDEO_BETA_MAX_DURATION_SECONDS="90"
|
| 99 |
-
VIDEO_SCENE_TEXT_MAX_CHARS="700"
|
| 100 |
-
VIDEO_MAX_CONCURRENT_RENDER_JOBS_PER_USER="1"
|
| 101 |
-
VIDEO_ENFORCE_BETA_LIMITS_IN_DEVELOPMENT="false"
|
| 102 |
-
|
| 103 |
-
# Storage provider settings.
|
| 104 |
-
# Local is default and keeps current /generated/audio and /generated/videos URLs.
|
| 105 |
-
# Use STORAGE_PROVIDER="r2" or "s3" with an S3-compatible bucket for cloud media.
|
| 106 |
-
STORAGE_PROVIDER="local"
|
| 107 |
-
STORAGE_BUCKET=""
|
| 108 |
-
STORAGE_REGION=""
|
| 109 |
-
STORAGE_ENDPOINT_URL=""
|
| 110 |
-
STORAGE_ACCESS_KEY_ID=""
|
| 111 |
-
STORAGE_SECRET_ACCESS_KEY=""
|
| 112 |
-
STORAGE_PUBLIC_BASE_URL=""
|
| 113 |
-
STORAGE_UPLOAD_PREFIX="uploads"
|
| 114 |
-
STORAGE_AUDIO_PREFIX="generated-audio"
|
| 115 |
-
STORAGE_VIDEO_PREFIX="generated-videos"
|
| 116 |
-
|
| 117 |
-
# Auth settings.
|
| 118 |
-
# Local dev defaults keep all current flows working as the demo student.
|
| 119 |
-
AUTH_ENABLED="false"
|
| 120 |
-
AUTH_PROVIDER="dev"
|
| 121 |
-
FRONTEND_BASE_URL="http://127.0.0.1:3000"
|
| 122 |
-
JWT_SECRET_KEY="change-this-before-real-users"
|
| 123 |
-
JWT_ALGORITHM="HS256"
|
| 124 |
-
ACCESS_TOKEN_EXPIRE_MINUTES="10080"
|
| 125 |
-
SUPABASE_URL=""
|
| 126 |
-
SUPABASE_JWT_SECRET=""
|
| 127 |
-
SUPABASE_ANON_KEY=""
|
| 128 |
-
# Backend only. Required to fully remove Supabase Auth identities when
|
| 129 |
-
# AUTH_PROVIDER="supabase". Never copy this into a NEXT_PUBLIC_* variable.
|
| 130 |
-
SUPABASE_SERVICE_ROLE_KEY=""
|
| 131 |
-
|
| 132 |
-
# Transactional account email. Required for password recovery when
|
| 133 |
-
# AUTH_PROVIDER="jwt" in production.
|
| 134 |
-
EMAIL_PROVIDER="disabled" # disabled | resend
|
| 135 |
-
RESEND_API_KEY=""
|
| 136 |
-
EMAIL_FROM="DocDoe <support@docdoe.in>"
|
| 137 |
-
PASSWORD_RESET_TOKEN_MINUTES="30"
|
| 138 |
-
PASSWORD_RESET_REQUEST_COOLDOWN_SECONDS="60"
|
| 139 |
-
|
| 140 |
-
# Optional Google sign-in.
|
| 141 |
-
# Create OAuth credentials in Google Cloud and add this redirect URI:
|
| 142 |
-
# http://127.0.0.1:8000/auth/google/callback
|
| 143 |
-
GOOGLE_CLIENT_ID=""
|
| 144 |
-
GOOGLE_CLIENT_SECRET=""
|
| 145 |
-
GOOGLE_OAUTH_REDIRECT_URI="http://127.0.0.1:8000/auth/google/callback"
|
| 146 |
-
|
| 147 |
-
# Beta invite gate.
|
| 148 |
-
# Set BETA_ACCESS_ENABLED=true and a strong BETA_INVITE_CODE to require an invite
|
| 149 |
-
# code at signup. Leave BETA_ACCESS_ENABLED=false for open signup (dev/demo).
|
| 150 |
-
BETA_ACCESS_ENABLED="false"
|
| 151 |
-
BETA_INVITE_CODE=""
|
| 152 |
-
|
| 153 |
-
# Per-user AI rate limiting.
|
| 154 |
-
# Set RATE_LIMIT_ENABLED=true for beta to prevent a single user from exhausting
|
| 155 |
-
# Sarvam credits. RATE_LIMIT_AI_REQUESTS_PER_DAY applies per user (JWT sub) or
|
| 156 |
-
# per IP when no token is present. Resets every 24 hours from first request.
|
| 157 |
-
RATE_LIMIT_ENABLED="false"
|
| 158 |
-
RATE_LIMIT_AI_REQUESTS_PER_DAY="50"
|
| 159 |
-
|
| 160 |
-
# --- Stripe Billing (Popular 299 + Premium 599 subscriptions) ---
|
| 161 |
-
# Prefer a restricted TEST key (rk_test_...) with only the Checkout, Customers,
|
| 162 |
-
# Subscriptions, and Billing Portal permissions this backend needs.
|
| 163 |
-
# Create recurring test Prices for both active plans. Checkout stays disabled
|
| 164 |
-
# until the key, webhook secret, and requested Price are all present.
|
| 165 |
-
# Example:
|
| 166 |
-
# STRIPE_PRICE_IDS=popular_299=price_sub_abc,premium_599=price_sub_def
|
| 167 |
-
# For webhook testing: `stripe listen --forward-to http://127.0.0.1:8000/billing/webhook`
|
| 168 |
-
# (this prints a local whsec_... you paste here; in prod use the dashboard endpoint secret).
|
| 169 |
-
STRIPE_SECRET_KEY=""
|
| 170 |
-
STRIPE_WEBHOOK_SECRET=""
|
| 171 |
-
STRIPE_PRICE_IDS=""
|
| 172 |
-
|
| 173 |
-
# ── Observability + scaling (optional; A+ when set) ───────────────────────────
|
| 174 |
-
# Shared rate-limit + prompt cache across replicas (e.g. Upstash free tier):
|
| 175 |
-
REDIS_URL=
|
| 176 |
-
# Error tracking — paste a Sentry project DSN to capture backend + frontend errors:
|
| 177 |
-
SENTRY_DSN=
|
| 178 |
-
SENTRY_TRACES_SAMPLE_RATE=0.1
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/.env.huggingface.example
DELETED
|
@@ -1,39 +0,0 @@
|
|
| 1 |
-
ENVIRONMENT=production
|
| 2 |
-
DATABASE_URL=
|
| 3 |
-
CORS_ORIGINS=
|
| 4 |
-
|
| 5 |
-
AUTH_ENABLED=true
|
| 6 |
-
AUTH_PROVIDER=jwt
|
| 7 |
-
JWT_SECRET_KEY=
|
| 8 |
-
FRONTEND_BASE_URL=
|
| 9 |
-
GOOGLE_CLIENT_ID=
|
| 10 |
-
GOOGLE_CLIENT_SECRET=
|
| 11 |
-
GOOGLE_OAUTH_REDIRECT_URI=
|
| 12 |
-
|
| 13 |
-
BETA_ACCESS_ENABLED=true
|
| 14 |
-
BETA_INVITE_CODE=
|
| 15 |
-
|
| 16 |
-
RATE_LIMIT_ENABLED=true
|
| 17 |
-
RATE_LIMIT_AI_REQUESTS_PER_DAY=50
|
| 18 |
-
|
| 19 |
-
AI_PROVIDER=sarvam
|
| 20 |
-
SARVAM_API_KEY=
|
| 21 |
-
AI_FALLBACK_TO_MOCK=false
|
| 22 |
-
|
| 23 |
-
SARVAM_BASE_URL=https://api.sarvam.ai/v1
|
| 24 |
-
SARVAM_MODEL_MAIN=sarvam-30b
|
| 25 |
-
SARVAM_MODEL_HEAVY=sarvam-105b
|
| 26 |
-
SARVAM_MODEL_NOTES=sarvam-30b
|
| 27 |
-
SARVAM_MODEL_QUIZ=sarvam-30b
|
| 28 |
-
SARVAM_MODEL_FLASHCARDS=sarvam-30b
|
| 29 |
-
SARVAM_MODEL_EXAM_MODE=sarvam-105b
|
| 30 |
-
SARVAM_MODEL_PYQ_ANALYSIS=sarvam-105b
|
| 31 |
-
SARVAM_MODEL_VIDEO_SCRIPT=sarvam-105b
|
| 32 |
-
SARVAM_TIMEOUT_SECONDS=90
|
| 33 |
-
SARVAM_MAX_RETRIES=2
|
| 34 |
-
|
| 35 |
-
STORAGE_PROVIDER=local
|
| 36 |
-
UPLOAD_DIR=/app/uploads
|
| 37 |
-
TTS_OUTPUT_DIR=/app/generated/audio
|
| 38 |
-
GENERATED_VIDEO_JOBS_DIR=/app/generated-video-jobs
|
| 39 |
-
GENERATED_VIDEO_OUTPUT_DIR=/app/generated-videos
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/.env.production.example
DELETED
|
@@ -1,148 +0,0 @@
|
|
| 1 |
-
# DocDoe backend production template.
|
| 2 |
-
# Copy into your hosting provider's secret/env dashboard. Do not commit real values.
|
| 3 |
-
# Closed beta must use PostgreSQL for DATABASE_URL. Do not use SQLite for real students.
|
| 4 |
-
|
| 5 |
-
ENVIRONMENT=production
|
| 6 |
-
DATABASE_URL=
|
| 7 |
-
CORS_ORIGINS=
|
| 8 |
-
|
| 9 |
-
# ââ€â‚¬Ã¢â€â‚¬ Database backup and recovery ââ€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬Ã¢â€â‚¬
|
| 10 |
-
# Use "managed" when Supabase or another provider owns the schedule and
|
| 11 |
-
# retention. Use "pg_dump" for backend/scripts/database_backup.py.
|
| 12 |
-
DATABASE_BACKUP_STRATEGY=managed
|
| 13 |
-
DATABASE_BACKUP_RETENTION_DAYS=14
|
| 14 |
-
# Set only after successfully restoring the latest backup into an isolated DB.
|
| 15 |
-
DATABASE_RESTORE_TESTED_AT=
|
| 16 |
-
# Required only for pg_dump strategy. This should be durable off-host storage.
|
| 17 |
-
DATABASE_BACKUP_DIR=
|
| 18 |
-
|
| 19 |
-
# ── Auth ──────────────────────────────────────────────────────────────────
|
| 20 |
-
# AUTH_PROVIDER=supabase is the recommended production setup (managed auth + a
|
| 21 |
-
# real Postgres for DATABASE_URL). AUTH_PROVIDER=jwt is the self-hosted option.
|
| 22 |
-
# The tuition beta is login-less regardless (localStorage), so students still
|
| 23 |
-
# work without an account; auth only gates the non-beta app + unlocks the real
|
| 24 |
-
# LLM tuition brain for signed-in users.
|
| 25 |
-
AUTH_ENABLED=true
|
| 26 |
-
AUTH_PROVIDER=supabase
|
| 27 |
-
FRONTEND_BASE_URL=
|
| 28 |
-
|
| 29 |
-
# Supabase (when AUTH_PROVIDER=supabase). Dashboard → Project Settings.
|
| 30 |
-
# SUPABASE_URL → Settings → API → Project URL
|
| 31 |
-
# SUPABASE_ANON_KEY → Settings → API → Project API keys → anon public
|
| 32 |
-
# SUPABASE_JWT_SECRET → Settings → API → JWT Settings → JWT Secret
|
| 33 |
-
# DATABASE_URL (above) → Settings → Database → Connection string (URI),
|
| 34 |
-
# use the pooled connstring for serverless hosts,
|
| 35 |
-
# prefix with postgresql:// (not postgres://).
|
| 36 |
-
SUPABASE_URL=
|
| 37 |
-
SUPABASE_ANON_KEY=
|
| 38 |
-
SUPABASE_JWT_SECRET=
|
| 39 |
-
|
| 40 |
-
# JWT / Google OAuth (only when AUTH_PROVIDER=jwt) — leave blank for supabase.
|
| 41 |
-
JWT_SECRET_KEY=
|
| 42 |
-
GOOGLE_CLIENT_ID=
|
| 43 |
-
GOOGLE_CLIENT_SECRET=
|
| 44 |
-
GOOGLE_OAUTH_REDIRECT_URI=
|
| 45 |
-
|
| 46 |
-
BETA_ACCESS_ENABLED=true
|
| 47 |
-
BETA_INVITE_CODE=
|
| 48 |
-
|
| 49 |
-
RATE_LIMIT_ENABLED=true
|
| 50 |
-
RATE_LIMIT_AI_REQUESTS_PER_DAY=50
|
| 51 |
-
|
| 52 |
-
# ── Text AI provider (study-chat, notes, quizzes, video scripts) ─────────────
|
| 53 |
-
# Your main app provider. Keep whatever you already use (e.g. sarvam) — the
|
| 54 |
-
# tuition brain does NOT ride on this, it has its own provider below.
|
| 55 |
-
AI_PROVIDER=sarvam
|
| 56 |
-
AI_FALLBACK_TO_MOCK=false
|
| 57 |
-
|
| 58 |
-
# Learn Anything lesson authoring (Groq). Server-only.
|
| 59 |
-
GROQ_API_KEY=
|
| 60 |
-
|
| 61 |
-
# Postgres pool for ~1k concurrent students (tune against your plan limits).
|
| 62 |
-
DATABASE_POOL_SIZE=15
|
| 63 |
-
DATABASE_MAX_OVERFLOW=30
|
| 64 |
-
|
| 65 |
-
# ── Tuition brain provider (the real LLM that restates + plans today's class) ─
|
| 66 |
-
# Runs on its own cheap text adapter so it never forces the app off AI_PROVIDER.
|
| 67 |
-
# Must be a text adapter: cloudflare_workers_ai | openrouter | nvidia_nim.
|
| 68 |
-
# Defaults to cloudflare_workers_ai even if unset.
|
| 69 |
-
TUITION_BRAIN_PROVIDER=cloudflare_workers_ai
|
| 70 |
-
|
| 71 |
-
# Cloudflare Workers AI (when AI_PROVIDER=cloudflare_workers_ai).
|
| 72 |
-
# CLOUDFLARE_ACCOUNT_ID → dash.cloudflare.com → account id (right sidebar / URL)
|
| 73 |
-
# CLOUDFLARE_API_TOKEN → My Profile → API Tokens → Create Token →
|
| 74 |
-
# template "Workers AI" (permission: Account · Workers AI · Read/Run)
|
| 75 |
-
CLOUDFLARE_ACCOUNT_ID=
|
| 76 |
-
CLOUDFLARE_API_TOKEN=
|
| 77 |
-
# Optional model override (defaults to @cf/meta/llama-3.1-8b-instruct).
|
| 78 |
-
CLOUDFLARE_WORKERS_AI_TEXT_MODEL=@cf/meta/llama-3.1-8b-instruct
|
| 79 |
-
|
| 80 |
-
# Daily AI budget guard (USD). MUST be > 0 for the brain LLM to run — Cloudflare
|
| 81 |
-
# Workers AI is near-free but its estimated cost is > 0, so a 0 budget denies it
|
| 82 |
-
# and everything falls back to the deterministic brain. Start small.
|
| 83 |
-
AI_ROUTER_DAILY_BUDGET_USD=5
|
| 84 |
-
AI_ROUTER_ALLOW_FREE_PROVIDERS=true
|
| 85 |
-
|
| 86 |
-
# Alternative paid provider (only when AI_PROVIDER=sarvam) — leave blank otherwise.
|
| 87 |
-
SARVAM_API_KEY=
|
| 88 |
-
|
| 89 |
-
# Study-video voice defaults to local AI4Bharat Indic Parler for Indian English
|
| 90 |
-
# and Malayalam support without per-minute TTS costs.
|
| 91 |
-
TTS_PROVIDER=ai4bharat
|
| 92 |
-
TTS_DEFAULT_PROVIDER=ai4bharat
|
| 93 |
-
VIDEO_TTS_PROVIDER=ai4bharat
|
| 94 |
-
AI4BHARAT_TTS_MODEL=ai4bharat/indic-parler-tts
|
| 95 |
-
AI4BHARAT_TTS_SPEAKER=Anjali
|
| 96 |
-
AI4BHARAT_TTS_ENGLISH_SPEAKER=Mary
|
| 97 |
-
AI4BHARAT_TTS_DEVICE=auto
|
| 98 |
-
AI4BHARAT_TTS_PRECISION=float32
|
| 99 |
-
AUDIO_SCENE_TIMEOUT_SECONDS=45
|
| 100 |
-
AUDIO_TOTAL_TIMEOUT_SECONDS=300
|
| 101 |
-
AUDIO_MAX_RETRIES=2
|
| 102 |
-
AUDIO_MAX_CONCURRENCY=3
|
| 103 |
-
VIDEO_RENDER_TIMEOUT_SECONDS=420
|
| 104 |
-
VIDEO_MIN_OUTPUT_BYTES=4096
|
| 105 |
-
VIDEO_BETA_MAX_SCENES=5
|
| 106 |
-
VIDEO_BETA_MAX_DURATION_SECONDS=90
|
| 107 |
-
VIDEO_SCENE_TEXT_MAX_CHARS=700
|
| 108 |
-
VIDEO_MAX_CONCURRENT_RENDER_JOBS_PER_USER=1
|
| 109 |
-
|
| 110 |
-
# ── Cloud storage (REQUIRED in production for video URLs to be playable) ─────
|
| 111 |
-
# Current production target: Cloudinary (free tier, generous transformations).
|
| 112 |
-
# Alternatives: cloudflare R2, AWS S3 (see backend/STORAGE_SETUP.md).
|
| 113 |
-
STORAGE_PROVIDER=cloudinary
|
| 114 |
-
|
| 115 |
-
# Cloudinary credentials (when STORAGE_PROVIDER=cloudinary).
|
| 116 |
-
# Find under: cloudinary.com Dashboard → Settings → API Keys.
|
| 117 |
-
# All three required.
|
| 118 |
-
CLOUDINARY_CLOUD_NAME=
|
| 119 |
-
CLOUDINARY_API_KEY=
|
| 120 |
-
CLOUDINARY_API_SECRET=
|
| 121 |
-
|
| 122 |
-
# R2 / S3 (when STORAGE_PROVIDER=r2 or s3) — leave blank if using Cloudinary.
|
| 123 |
-
STORAGE_BUCKET=
|
| 124 |
-
STORAGE_ENDPOINT_URL=
|
| 125 |
-
STORAGE_ACCESS_KEY_ID=
|
| 126 |
-
STORAGE_SECRET_ACCESS_KEY=
|
| 127 |
-
STORAGE_PUBLIC_BASE_URL=
|
| 128 |
-
STORAGE_REGION=
|
| 129 |
-
|
| 130 |
-
# Deployment target tag (used by preflight to relax assumptions).
|
| 131 |
-
# Values: huggingface | railway | flyio | docker | local
|
| 132 |
-
DEPLOY_TARGET=huggingface
|
| 133 |
-
|
| 134 |
-
# ── Stripe Billing (MANDATORY for paid plans in production) ───────────────────
|
| 135 |
-
# Prefer a restricted LIVE key (rk_live_...) scoped to Checkout, Customers,
|
| 136 |
-
# Subscriptions, and Billing Portal. Store it only in the backend secret store.
|
| 137 |
-
# Create recurring LIVE Prices for the two plans currently shown to students.
|
| 138 |
-
# Checkout refuses to take payment unless the webhook secret and selected Price
|
| 139 |
-
# are both configured.
|
| 140 |
-
# STRIPE_PRICE_IDS=popular_299=price_...,premium_599=price_...
|
| 141 |
-
# WEBHOOK: In Stripe dashboard create endpoint https://yourdomain.com/billing/webhook
|
| 142 |
-
# select checkout.session.completed, customer.subscription.created/updated/deleted,
|
| 143 |
-
# invoice.paid, and invoice.payment_failed. Copy the signing secret here.
|
| 144 |
-
# IMPORTANT: After setting, restart/redeploy backend so pydantic-settings picks up.
|
| 145 |
-
# Never commit keys. Rotate secrets if leaked. See backend/app/routes/billing.py for impl.
|
| 146 |
-
STRIPE_SECRET_KEY=
|
| 147 |
-
STRIPE_WEBHOOK_SECRET=
|
| 148 |
-
STRIPE_PRICE_IDS=
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/.gitignore
DELETED
|
@@ -1,17 +0,0 @@
|
|
| 1 |
-
.env
|
| 2 |
-
.env.local
|
| 3 |
-
.env.production
|
| 4 |
-
.env.huggingface
|
| 5 |
-
.venv/
|
| 6 |
-
__pycache__/
|
| 7 |
-
*.py[cod]
|
| 8 |
-
.pytest_cache/
|
| 9 |
-
.ruff_cache/
|
| 10 |
-
*.db
|
| 11 |
-
uploads/*
|
| 12 |
-
!uploads/.gitkeep
|
| 13 |
-
generated-video-jobs/
|
| 14 |
-
generated-videos/
|
| 15 |
-
generated/
|
| 16 |
-
uvicorn_log.txt
|
| 17 |
-
*.log
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|