Spaces:
Paused
Paused
AnhviNguyen commited on
Commit ·
b3e4ba6
1
Parent(s): 1c2ed83
update reading-listening + phát âm vob
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- .claude/skills/gitnexus/gitnexus-cli/SKILL.md +83 -0
- .claude/skills/gitnexus/gitnexus-debugging/SKILL.md +89 -0
- .claude/skills/gitnexus/gitnexus-exploring/SKILL.md +78 -0
- .claude/skills/gitnexus/gitnexus-guide/SKILL.md +64 -0
- .claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md +97 -0
- .claude/skills/gitnexus/gitnexus-refactoring/SKILL.md +121 -0
- .env.production.example +2 -1
- AGENTS.md +43 -0
- CLAUDE.md +43 -0
- backend/.env.example +21 -2
- backend/README.md +48 -2
- backend/alembic/versions/002_password_reset.py +20 -11
- backend/alembic/versions/003_history_archive.py +26 -18
- backend/alembic/versions/004_adaptive_notifications.py +63 -49
- backend/alembic/versions/005_admin_user_columns.py +59 -0
- backend/alembic/versions/006_google_oauth_email_verify.py +68 -0
- backend/alembic/versions/007_translation_practice.py +78 -0
- backend/alembic/versions/20260524_initial_schema.py +398 -9
- backend/app/cli/__init__.py +1 -0
- backend/app/cli/promote_admin.py +55 -0
- backend/app/core/auth_cookies.py +5 -0
- backend/app/core/config.py +60 -10
- backend/app/core/dependencies.py +0 -10
- backend/app/core/metrics.py +17 -2
- backend/app/core/openrouter_client.py +253 -0
- backend/app/core/password_policy.py +55 -0
- backend/app/core/rate_limit.py +32 -4
- backend/app/core/upload.py +22 -1
- backend/app/data/translation_seed.py +319 -0
- backend/app/db/models.py +103 -0
- backend/app/main.py +43 -3
- backend/app/repositories/email_verification_repository.py +53 -0
- backend/app/repositories/profile_repository.py +7 -2
- backend/app/repositories/translation_repository.py +189 -0
- backend/app/repositories/user_repository.py +36 -6
- backend/app/routers/auth.py +54 -15
- backend/app/routers/history.py +8 -3
- backend/app/routers/pronunciation.py +88 -0
- backend/app/routers/shadowing.py +22 -4
- backend/app/routers/speaking.py +19 -26
- backend/app/routers/translation.py +71 -0
- backend/app/routers/users.py +12 -24
- backend/app/routers/writing.py +14 -22
- backend/app/schemas.py +25 -6
- backend/app/services/admin_content_service.py +2 -3
- backend/app/services/auth_service.py +214 -25
- backend/app/services/email_service.py +39 -0
- backend/app/services/phoneme_recognizer.py +164 -0
- backend/app/services/phoneme_scorer.py +373 -0
- backend/app/services/pronunciation_audio.py +76 -0
.claude/skills/gitnexus/gitnexus-cli/SKILL.md
ADDED
|
@@ -0,0 +1,83 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: gitnexus-cli
|
| 3 |
+
description: "Use when the user needs to run GitNexus CLI commands like analyze/index a repo, check status, clean the index, generate a wiki, or list indexed repos. Examples: \"Index this repo\", \"Reanalyze the codebase\", \"Generate a wiki\""
|
| 4 |
+
---
|
| 5 |
+
|
| 6 |
+
# GitNexus CLI Commands
|
| 7 |
+
|
| 8 |
+
All commands work via `npx` — no global install required.
|
| 9 |
+
|
| 10 |
+
## Commands
|
| 11 |
+
|
| 12 |
+
### analyze — Build or refresh the index
|
| 13 |
+
|
| 14 |
+
```bash
|
| 15 |
+
npx gitnexus analyze
|
| 16 |
+
```
|
| 17 |
+
|
| 18 |
+
Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files.
|
| 19 |
+
|
| 20 |
+
| Flag | Effect |
|
| 21 |
+
| -------------- | ---------------------------------------------------------------- |
|
| 22 |
+
| `--force` | Force full re-index even if up to date |
|
| 23 |
+
| `--embeddings` | Enable embedding generation for semantic search (off by default) |
|
| 24 |
+
| `--drop-embeddings` | Drop existing embeddings on rebuild. By default, an `analyze` without `--embeddings` preserves them. |
|
| 25 |
+
|
| 26 |
+
**When to run:** First time in a project, after major code changes, or when `gitnexus://repo/{name}/context` reports the index is stale. In Claude Code, a PostToolUse hook detects staleness after `git commit` and `git merge` and notifies the agent to run `analyze` — the hook does not run analyze itself, to avoid blocking the agent for up to 120s and risking KuzuDB corruption on timeout.
|
| 27 |
+
|
| 28 |
+
### status — Check index freshness
|
| 29 |
+
|
| 30 |
+
```bash
|
| 31 |
+
npx gitnexus status
|
| 32 |
+
```
|
| 33 |
+
|
| 34 |
+
Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed.
|
| 35 |
+
|
| 36 |
+
### clean — Delete the index
|
| 37 |
+
|
| 38 |
+
```bash
|
| 39 |
+
npx gitnexus clean
|
| 40 |
+
```
|
| 41 |
+
|
| 42 |
+
Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project.
|
| 43 |
+
|
| 44 |
+
| Flag | Effect |
|
| 45 |
+
| --------- | ------------------------------------------------- |
|
| 46 |
+
| `--force` | Skip confirmation prompt |
|
| 47 |
+
| `--all` | Clean all indexed repos, not just the current one |
|
| 48 |
+
|
| 49 |
+
### wiki — Generate documentation from the graph
|
| 50 |
+
|
| 51 |
+
```bash
|
| 52 |
+
npx gitnexus wiki
|
| 53 |
+
```
|
| 54 |
+
|
| 55 |
+
Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use).
|
| 56 |
+
|
| 57 |
+
| Flag | Effect |
|
| 58 |
+
| ------------------- | ----------------------------------------- |
|
| 59 |
+
| `--force` | Force full regeneration |
|
| 60 |
+
| `--model <model>` | LLM model (default: minimax/minimax-m2.5) |
|
| 61 |
+
| `--base-url <url>` | LLM API base URL |
|
| 62 |
+
| `--api-key <key>` | LLM API key |
|
| 63 |
+
| `--concurrency <n>` | Parallel LLM calls (default: 3) |
|
| 64 |
+
| `--gist` | Publish wiki as a public GitHub Gist |
|
| 65 |
+
|
| 66 |
+
### list — Show all indexed repos
|
| 67 |
+
|
| 68 |
+
```bash
|
| 69 |
+
npx gitnexus list
|
| 70 |
+
```
|
| 71 |
+
|
| 72 |
+
Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information.
|
| 73 |
+
|
| 74 |
+
## After Indexing
|
| 75 |
+
|
| 76 |
+
1. **Read `gitnexus://repo/{name}/context`** to verify the index loaded
|
| 77 |
+
2. Use the other GitNexus skills (`exploring`, `debugging`, `impact-analysis`, `refactoring`) for your task
|
| 78 |
+
|
| 79 |
+
## Troubleshooting
|
| 80 |
+
|
| 81 |
+
- **"Not inside a git repository"**: Run from a directory inside a git repo
|
| 82 |
+
- **Index is stale after re-analyzing**: Restart Claude Code to reload the MCP server
|
| 83 |
+
- **Embeddings slow**: Omit `--embeddings` (it's off by default) or set `OPENAI_API_KEY` for faster API-based embedding
|
.claude/skills/gitnexus/gitnexus-debugging/SKILL.md
ADDED
|
@@ -0,0 +1,89 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: gitnexus-debugging
|
| 3 |
+
description: "Use when the user is debugging a bug, tracing an error, or asking why something fails. Examples: \"Why is X failing?\", \"Where does this error come from?\", \"Trace this bug\""
|
| 4 |
+
---
|
| 5 |
+
|
| 6 |
+
# Debugging with GitNexus
|
| 7 |
+
|
| 8 |
+
## When to Use
|
| 9 |
+
|
| 10 |
+
- "Why is this function failing?"
|
| 11 |
+
- "Trace where this error comes from"
|
| 12 |
+
- "Who calls this method?"
|
| 13 |
+
- "This endpoint returns 500"
|
| 14 |
+
- Investigating bugs, errors, or unexpected behavior
|
| 15 |
+
|
| 16 |
+
## Workflow
|
| 17 |
+
|
| 18 |
+
```
|
| 19 |
+
1. gitnexus_query({query: "<error or symptom>"}) → Find related execution flows
|
| 20 |
+
2. gitnexus_context({name: "<suspect>"}) → See callers/callees/processes
|
| 21 |
+
3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow
|
| 22 |
+
4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
| 26 |
+
|
| 27 |
+
## Checklist
|
| 28 |
+
|
| 29 |
+
```
|
| 30 |
+
- [ ] Understand the symptom (error message, unexpected behavior)
|
| 31 |
+
- [ ] gitnexus_query for error text or related code
|
| 32 |
+
- [ ] Identify the suspect function from returned processes
|
| 33 |
+
- [ ] gitnexus_context to see callers and callees
|
| 34 |
+
- [ ] Trace execution flow via process resource if applicable
|
| 35 |
+
- [ ] gitnexus_cypher for custom call chain traces if needed
|
| 36 |
+
- [ ] Read source files to confirm root cause
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
## Debugging Patterns
|
| 40 |
+
|
| 41 |
+
| Symptom | GitNexus Approach |
|
| 42 |
+
| -------------------- | ---------------------------------------------------------- |
|
| 43 |
+
| Error message | `gitnexus_query` for error text → `context` on throw sites |
|
| 44 |
+
| Wrong return value | `context` on the function → trace callees for data flow |
|
| 45 |
+
| Intermittent failure | `context` → look for external calls, async deps |
|
| 46 |
+
| Performance issue | `context` → find symbols with many callers (hot paths) |
|
| 47 |
+
| Recent regression | `detect_changes` to see what your changes affect |
|
| 48 |
+
|
| 49 |
+
## Tools
|
| 50 |
+
|
| 51 |
+
**gitnexus_query** — find code related to error:
|
| 52 |
+
|
| 53 |
+
```
|
| 54 |
+
gitnexus_query({query: "payment validation error"})
|
| 55 |
+
→ Processes: CheckoutFlow, ErrorHandling
|
| 56 |
+
→ Symbols: validatePayment, handlePaymentError, PaymentException
|
| 57 |
+
```
|
| 58 |
+
|
| 59 |
+
**gitnexus_context** — full context for a suspect:
|
| 60 |
+
|
| 61 |
+
```
|
| 62 |
+
gitnexus_context({name: "validatePayment"})
|
| 63 |
+
→ Incoming calls: processCheckout, webhookHandler
|
| 64 |
+
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
| 65 |
+
→ Processes: CheckoutFlow (step 3/7)
|
| 66 |
+
```
|
| 67 |
+
|
| 68 |
+
**gitnexus_cypher** — custom call chain traces:
|
| 69 |
+
|
| 70 |
+
```cypher
|
| 71 |
+
MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"})
|
| 72 |
+
RETURN [n IN nodes(path) | n.name] AS chain
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
## Example: "Payment endpoint returns 500 intermittently"
|
| 76 |
+
|
| 77 |
+
```
|
| 78 |
+
1. gitnexus_query({query: "payment error handling"})
|
| 79 |
+
→ Processes: CheckoutFlow, ErrorHandling
|
| 80 |
+
→ Symbols: validatePayment, handlePaymentError
|
| 81 |
+
|
| 82 |
+
2. gitnexus_context({name: "validatePayment"})
|
| 83 |
+
→ Outgoing calls: verifyCard, fetchRates (external API!)
|
| 84 |
+
|
| 85 |
+
3. READ gitnexus://repo/my-app/process/CheckoutFlow
|
| 86 |
+
→ Step 3: validatePayment → calls fetchRates (external)
|
| 87 |
+
|
| 88 |
+
4. Root cause: fetchRates calls external API without proper timeout
|
| 89 |
+
```
|
.claude/skills/gitnexus/gitnexus-exploring/SKILL.md
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: gitnexus-exploring
|
| 3 |
+
description: "Use when the user asks how code works, wants to understand architecture, trace execution flows, or explore unfamiliar parts of the codebase. Examples: \"How does X work?\", \"What calls this function?\", \"Show me the auth flow\""
|
| 4 |
+
---
|
| 5 |
+
|
| 6 |
+
# Exploring Codebases with GitNexus
|
| 7 |
+
|
| 8 |
+
## When to Use
|
| 9 |
+
|
| 10 |
+
- "How does authentication work?"
|
| 11 |
+
- "What's the project structure?"
|
| 12 |
+
- "Show me the main components"
|
| 13 |
+
- "Where is the database logic?"
|
| 14 |
+
- Understanding code you haven't seen before
|
| 15 |
+
|
| 16 |
+
## Workflow
|
| 17 |
+
|
| 18 |
+
```
|
| 19 |
+
1. READ gitnexus://repos → Discover indexed repos
|
| 20 |
+
2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness
|
| 21 |
+
3. gitnexus_query({query: "<what you want to understand>"}) → Find related execution flows
|
| 22 |
+
4. gitnexus_context({name: "<symbol>"}) → Deep dive on specific symbol
|
| 23 |
+
5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal.
|
| 27 |
+
|
| 28 |
+
## Checklist
|
| 29 |
+
|
| 30 |
+
```
|
| 31 |
+
- [ ] READ gitnexus://repo/{name}/context
|
| 32 |
+
- [ ] gitnexus_query for the concept you want to understand
|
| 33 |
+
- [ ] Review returned processes (execution flows)
|
| 34 |
+
- [ ] gitnexus_context on key symbols for callers/callees
|
| 35 |
+
- [ ] READ process resource for full execution traces
|
| 36 |
+
- [ ] Read source files for implementation details
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
## Resources
|
| 40 |
+
|
| 41 |
+
| Resource | What you get |
|
| 42 |
+
| --------------------------------------- | ------------------------------------------------------- |
|
| 43 |
+
| `gitnexus://repo/{name}/context` | Stats, staleness warning (~150 tokens) |
|
| 44 |
+
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores (~300 tokens) |
|
| 45 |
+
| `gitnexus://repo/{name}/cluster/{name}` | Area members with file paths (~500 tokens) |
|
| 46 |
+
| `gitnexus://repo/{name}/process/{name}` | Step-by-step execution trace (~200 tokens) |
|
| 47 |
+
|
| 48 |
+
## Tools
|
| 49 |
+
|
| 50 |
+
**gitnexus_query** — find execution flows related to a concept:
|
| 51 |
+
|
| 52 |
+
```
|
| 53 |
+
gitnexus_query({query: "payment processing"})
|
| 54 |
+
→ Processes: CheckoutFlow, RefundFlow, WebhookHandler
|
| 55 |
+
→ Symbols grouped by flow with file locations
|
| 56 |
+
```
|
| 57 |
+
|
| 58 |
+
**gitnexus_context** — 360-degree view of a symbol:
|
| 59 |
+
|
| 60 |
+
```
|
| 61 |
+
gitnexus_context({name: "validateUser"})
|
| 62 |
+
→ Incoming calls: loginHandler, apiMiddleware
|
| 63 |
+
→ Outgoing calls: checkToken, getUserById
|
| 64 |
+
→ Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3)
|
| 65 |
+
```
|
| 66 |
+
|
| 67 |
+
## Example: "How does payment processing work?"
|
| 68 |
+
|
| 69 |
+
```
|
| 70 |
+
1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes
|
| 71 |
+
2. gitnexus_query({query: "payment processing"})
|
| 72 |
+
→ CheckoutFlow: processPayment → validateCard → chargeStripe
|
| 73 |
+
→ RefundFlow: initiateRefund → calculateRefund → processRefund
|
| 74 |
+
3. gitnexus_context({name: "processPayment"})
|
| 75 |
+
→ Incoming: checkoutHandler, webhookHandler
|
| 76 |
+
→ Outgoing: validateCard, chargeStripe, saveTransaction
|
| 77 |
+
4. Read src/payments/processor.ts for implementation details
|
| 78 |
+
```
|
.claude/skills/gitnexus/gitnexus-guide/SKILL.md
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: gitnexus-guide
|
| 3 |
+
description: "Use when the user asks about GitNexus itself — available tools, how to query the knowledge graph, MCP resources, graph schema, or workflow reference. Examples: \"What GitNexus tools are available?\", \"How do I use GitNexus?\""
|
| 4 |
+
---
|
| 5 |
+
|
| 6 |
+
# GitNexus Guide
|
| 7 |
+
|
| 8 |
+
Quick reference for all GitNexus MCP tools, resources, and the knowledge graph schema.
|
| 9 |
+
|
| 10 |
+
## Always Start Here
|
| 11 |
+
|
| 12 |
+
For any task involving code understanding, debugging, impact analysis, or refactoring:
|
| 13 |
+
|
| 14 |
+
1. **Read `gitnexus://repo/{name}/context`** — codebase overview + check index freshness
|
| 15 |
+
2. **Match your task to a skill below** and **read that skill file**
|
| 16 |
+
3. **Follow the skill's workflow and checklist**
|
| 17 |
+
|
| 18 |
+
> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first.
|
| 19 |
+
|
| 20 |
+
## Skills
|
| 21 |
+
|
| 22 |
+
| Task | Skill to read |
|
| 23 |
+
| -------------------------------------------- | ------------------- |
|
| 24 |
+
| Understand architecture / "How does X work?" | `gitnexus-exploring` |
|
| 25 |
+
| Blast radius / "What breaks if I change X?" | `gitnexus-impact-analysis` |
|
| 26 |
+
| Trace bugs / "Why is X failing?" | `gitnexus-debugging` |
|
| 27 |
+
| Rename / extract / split / refactor | `gitnexus-refactoring` |
|
| 28 |
+
| Tools, resources, schema reference | `gitnexus-guide` (this file) |
|
| 29 |
+
| Index, status, clean, wiki CLI commands | `gitnexus-cli` |
|
| 30 |
+
|
| 31 |
+
## Tools Reference
|
| 32 |
+
|
| 33 |
+
| Tool | What it gives you |
|
| 34 |
+
| ---------------- | ------------------------------------------------------------------------ |
|
| 35 |
+
| `query` | Process-grouped code intelligence — execution flows related to a concept |
|
| 36 |
+
| `context` | 360-degree symbol view — categorized refs, processes it participates in |
|
| 37 |
+
| `impact` | Symbol blast radius — what breaks at depth 1/2/3 with confidence |
|
| 38 |
+
| `detect_changes` | Git-diff impact — what do your current changes affect |
|
| 39 |
+
| `rename` | Multi-file coordinated rename with confidence-tagged edits |
|
| 40 |
+
| `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) |
|
| 41 |
+
| `list_repos` | Discover indexed repos |
|
| 42 |
+
|
| 43 |
+
## Resources Reference
|
| 44 |
+
|
| 45 |
+
Lightweight reads (~100-500 tokens) for navigation:
|
| 46 |
+
|
| 47 |
+
| Resource | Content |
|
| 48 |
+
| ---------------------------------------------- | ----------------------------------------- |
|
| 49 |
+
| `gitnexus://repo/{name}/context` | Stats, staleness check |
|
| 50 |
+
| `gitnexus://repo/{name}/clusters` | All functional areas with cohesion scores |
|
| 51 |
+
| `gitnexus://repo/{name}/cluster/{clusterName}` | Area members |
|
| 52 |
+
| `gitnexus://repo/{name}/processes` | All execution flows |
|
| 53 |
+
| `gitnexus://repo/{name}/process/{processName}` | Step-by-step trace |
|
| 54 |
+
| `gitnexus://repo/{name}/schema` | Graph schema for Cypher |
|
| 55 |
+
|
| 56 |
+
## Graph Schema
|
| 57 |
+
|
| 58 |
+
**Nodes:** File, Function, Class, Interface, Method, Community, Process
|
| 59 |
+
**Edges (via CodeRelation.type):** CALLS, IMPORTS, EXTENDS, IMPLEMENTS, DEFINES, MEMBER_OF, STEP_IN_PROCESS
|
| 60 |
+
|
| 61 |
+
```cypher
|
| 62 |
+
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "myFunc"})
|
| 63 |
+
RETURN caller.name, caller.filePath
|
| 64 |
+
```
|
.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: gitnexus-impact-analysis
|
| 3 |
+
description: "Use when the user wants to know what will break if they change something, or needs safety analysis before editing code. Examples: \"Is it safe to change X?\", \"What depends on this?\", \"What will break?\""
|
| 4 |
+
---
|
| 5 |
+
|
| 6 |
+
# Impact Analysis with GitNexus
|
| 7 |
+
|
| 8 |
+
## When to Use
|
| 9 |
+
|
| 10 |
+
- "Is it safe to change this function?"
|
| 11 |
+
- "What will break if I modify X?"
|
| 12 |
+
- "Show me the blast radius"
|
| 13 |
+
- "Who uses this code?"
|
| 14 |
+
- Before making non-trivial code changes
|
| 15 |
+
- Before committing — to understand what your changes affect
|
| 16 |
+
|
| 17 |
+
## Workflow
|
| 18 |
+
|
| 19 |
+
```
|
| 20 |
+
1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this
|
| 21 |
+
2. READ gitnexus://repo/{name}/processes → Check affected execution flows
|
| 22 |
+
3. gitnexus_detect_changes() → Map current git changes to affected flows
|
| 23 |
+
4. Assess risk and report to user
|
| 24 |
+
```
|
| 25 |
+
|
| 26 |
+
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
| 27 |
+
|
| 28 |
+
## Checklist
|
| 29 |
+
|
| 30 |
+
```
|
| 31 |
+
- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents
|
| 32 |
+
- [ ] Review d=1 items first (these WILL BREAK)
|
| 33 |
+
- [ ] Check high-confidence (>0.8) dependencies
|
| 34 |
+
- [ ] READ processes to check affected execution flows
|
| 35 |
+
- [ ] gitnexus_detect_changes() for pre-commit check
|
| 36 |
+
- [ ] Assess risk level and report to user
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
## Understanding Output
|
| 40 |
+
|
| 41 |
+
| Depth | Risk Level | Meaning |
|
| 42 |
+
| ----- | ---------------- | ------------------------ |
|
| 43 |
+
| d=1 | **WILL BREAK** | Direct callers/importers |
|
| 44 |
+
| d=2 | LIKELY AFFECTED | Indirect dependencies |
|
| 45 |
+
| d=3 | MAY NEED TESTING | Transitive effects |
|
| 46 |
+
|
| 47 |
+
## Risk Assessment
|
| 48 |
+
|
| 49 |
+
| Affected | Risk |
|
| 50 |
+
| ------------------------------ | -------- |
|
| 51 |
+
| <5 symbols, few processes | LOW |
|
| 52 |
+
| 5-15 symbols, 2-5 processes | MEDIUM |
|
| 53 |
+
| >15 symbols or many processes | HIGH |
|
| 54 |
+
| Critical path (auth, payments) | CRITICAL |
|
| 55 |
+
|
| 56 |
+
## Tools
|
| 57 |
+
|
| 58 |
+
**gitnexus_impact** — the primary tool for symbol blast radius:
|
| 59 |
+
|
| 60 |
+
```
|
| 61 |
+
gitnexus_impact({
|
| 62 |
+
target: "validateUser",
|
| 63 |
+
direction: "upstream",
|
| 64 |
+
minConfidence: 0.8,
|
| 65 |
+
maxDepth: 3
|
| 66 |
+
})
|
| 67 |
+
|
| 68 |
+
→ d=1 (WILL BREAK):
|
| 69 |
+
- loginHandler (src/auth/login.ts:42) [CALLS, 100%]
|
| 70 |
+
- apiMiddleware (src/api/middleware.ts:15) [CALLS, 100%]
|
| 71 |
+
|
| 72 |
+
→ d=2 (LIKELY AFFECTED):
|
| 73 |
+
- authRouter (src/routes/auth.ts:22) [CALLS, 95%]
|
| 74 |
+
```
|
| 75 |
+
|
| 76 |
+
**gitnexus_detect_changes** — git-diff based impact analysis:
|
| 77 |
+
|
| 78 |
+
```
|
| 79 |
+
gitnexus_detect_changes({scope: "staged"})
|
| 80 |
+
|
| 81 |
+
→ Changed: 5 symbols in 3 files
|
| 82 |
+
→ Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline
|
| 83 |
+
→ Risk: MEDIUM
|
| 84 |
+
```
|
| 85 |
+
|
| 86 |
+
## Example: "What breaks if I change validateUser?"
|
| 87 |
+
|
| 88 |
+
```
|
| 89 |
+
1. gitnexus_impact({target: "validateUser", direction: "upstream"})
|
| 90 |
+
→ d=1: loginHandler, apiMiddleware (WILL BREAK)
|
| 91 |
+
→ d=2: authRouter, sessionManager (LIKELY AFFECTED)
|
| 92 |
+
|
| 93 |
+
2. READ gitnexus://repo/my-app/processes
|
| 94 |
+
→ LoginFlow and TokenRefresh touch validateUser
|
| 95 |
+
|
| 96 |
+
3. Risk: 2 direct callers, 2 processes = MEDIUM
|
| 97 |
+
```
|
.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
---
|
| 2 |
+
name: gitnexus-refactoring
|
| 3 |
+
description: "Use when the user wants to rename, extract, split, move, or restructure code safely. Examples: \"Rename this function\", \"Extract this into a module\", \"Refactor this class\", \"Move this to a separate file\""
|
| 4 |
+
---
|
| 5 |
+
|
| 6 |
+
# Refactoring with GitNexus
|
| 7 |
+
|
| 8 |
+
## When to Use
|
| 9 |
+
|
| 10 |
+
- "Rename this function safely"
|
| 11 |
+
- "Extract this into a module"
|
| 12 |
+
- "Split this service"
|
| 13 |
+
- "Move this to a new file"
|
| 14 |
+
- Any task involving renaming, extracting, splitting, or restructuring code
|
| 15 |
+
|
| 16 |
+
## Workflow
|
| 17 |
+
|
| 18 |
+
```
|
| 19 |
+
1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents
|
| 20 |
+
2. gitnexus_query({query: "X"}) → Find execution flows involving X
|
| 21 |
+
3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs
|
| 22 |
+
4. Plan update order: interfaces → implementations → callers → tests
|
| 23 |
+
```
|
| 24 |
+
|
| 25 |
+
> If "Index is stale" → run `npx gitnexus analyze` in terminal.
|
| 26 |
+
|
| 27 |
+
## Checklists
|
| 28 |
+
|
| 29 |
+
### Rename Symbol
|
| 30 |
+
|
| 31 |
+
```
|
| 32 |
+
- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits
|
| 33 |
+
- [ ] Review graph edits (high confidence) and ast_search edits (review carefully)
|
| 34 |
+
- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits
|
| 35 |
+
- [ ] gitnexus_detect_changes() — verify only expected files changed
|
| 36 |
+
- [ ] Run tests for affected processes
|
| 37 |
+
```
|
| 38 |
+
|
| 39 |
+
### Extract Module
|
| 40 |
+
|
| 41 |
+
```
|
| 42 |
+
- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs
|
| 43 |
+
- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers
|
| 44 |
+
- [ ] Define new module interface
|
| 45 |
+
- [ ] Extract code, update imports
|
| 46 |
+
- [ ] gitnexus_detect_changes() — verify affected scope
|
| 47 |
+
- [ ] Run tests for affected processes
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
### Split Function/Service
|
| 51 |
+
|
| 52 |
+
```
|
| 53 |
+
- [ ] gitnexus_context({name: target}) — understand all callees
|
| 54 |
+
- [ ] Group callees by responsibility
|
| 55 |
+
- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update
|
| 56 |
+
- [ ] Create new functions/services
|
| 57 |
+
- [ ] Update callers
|
| 58 |
+
- [ ] gitnexus_detect_changes() — verify affected scope
|
| 59 |
+
- [ ] Run tests for affected processes
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
## Tools
|
| 63 |
+
|
| 64 |
+
**gitnexus_rename** — automated multi-file rename:
|
| 65 |
+
|
| 66 |
+
```
|
| 67 |
+
gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
| 68 |
+
→ 12 edits across 8 files
|
| 69 |
+
→ 10 graph edits (high confidence), 2 ast_search edits (review)
|
| 70 |
+
→ Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}]
|
| 71 |
+
```
|
| 72 |
+
|
| 73 |
+
**gitnexus_impact** — map all dependents first:
|
| 74 |
+
|
| 75 |
+
```
|
| 76 |
+
gitnexus_impact({target: "validateUser", direction: "upstream"})
|
| 77 |
+
→ d=1: loginHandler, apiMiddleware, testUtils
|
| 78 |
+
→ Affected Processes: LoginFlow, TokenRefresh
|
| 79 |
+
```
|
| 80 |
+
|
| 81 |
+
**gitnexus_detect_changes** — verify your changes after refactoring:
|
| 82 |
+
|
| 83 |
+
```
|
| 84 |
+
gitnexus_detect_changes({scope: "all"})
|
| 85 |
+
→ Changed: 8 files, 12 symbols
|
| 86 |
+
→ Affected processes: LoginFlow, TokenRefresh
|
| 87 |
+
→ Risk: MEDIUM
|
| 88 |
+
```
|
| 89 |
+
|
| 90 |
+
**gitnexus_cypher** — custom reference queries:
|
| 91 |
+
|
| 92 |
+
```cypher
|
| 93 |
+
MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"})
|
| 94 |
+
RETURN caller.name, caller.filePath ORDER BY caller.filePath
|
| 95 |
+
```
|
| 96 |
+
|
| 97 |
+
## Risk Rules
|
| 98 |
+
|
| 99 |
+
| Risk Factor | Mitigation |
|
| 100 |
+
| ------------------- | ----------------------------------------- |
|
| 101 |
+
| Many callers (>5) | Use gitnexus_rename for automated updates |
|
| 102 |
+
| Cross-area refs | Use detect_changes after to verify scope |
|
| 103 |
+
| String/dynamic refs | gitnexus_query to find them |
|
| 104 |
+
| External/public API | Version and deprecate properly |
|
| 105 |
+
|
| 106 |
+
## Example: Rename `validateUser` to `authenticateUser`
|
| 107 |
+
|
| 108 |
+
```
|
| 109 |
+
1. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true})
|
| 110 |
+
→ 12 edits: 10 graph (safe), 2 ast_search (review)
|
| 111 |
+
→ Files: validator.ts, login.ts, middleware.ts, config.json...
|
| 112 |
+
|
| 113 |
+
2. Review ast_search edits (config.json: dynamic reference!)
|
| 114 |
+
|
| 115 |
+
3. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false})
|
| 116 |
+
→ Applied 12 edits across 8 files
|
| 117 |
+
|
| 118 |
+
4. gitnexus_detect_changes({scope: "all"})
|
| 119 |
+
→ Affected: LoginFlow, TokenRefresh
|
| 120 |
+
→ Risk: MEDIUM — run tests for these flows
|
| 121 |
+
```
|
.env.production.example
CHANGED
|
@@ -27,8 +27,9 @@ MINIO_ROOT_PASSWORD=change-minio-password
|
|
| 27 |
|
| 28 |
AUTH_HTTPONLY_REFRESH=true
|
| 29 |
|
| 30 |
-
# Metrics (Prometheus scrapes GET /metrics on api:8000)
|
| 31 |
METRICS_ENABLED=true
|
|
|
|
| 32 |
|
| 33 |
# AI
|
| 34 |
OPENROUTER_API_KEY=sk-or-...
|
|
|
|
| 27 |
|
| 28 |
AUTH_HTTPONLY_REFRESH=true
|
| 29 |
|
| 30 |
+
# Metrics (Prometheus scrapes GET /metrics on api:8000 — set a strong bearer token)
|
| 31 |
METRICS_ENABLED=true
|
| 32 |
+
METRICS_TOKEN=change-this-metrics-bearer-token
|
| 33 |
|
| 34 |
# AI
|
| 35 |
OPENROUTER_API_KEY=sk-or-...
|
AGENTS.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!-- gitnexus:start -->
|
| 2 |
+
# GitNexus — Code Intelligence
|
| 3 |
+
|
| 4 |
+
This project is indexed by GitNexus as **ielts_web** (7370 symbols, 16808 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
| 5 |
+
|
| 6 |
+
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
| 7 |
+
|
| 8 |
+
## Always Do
|
| 9 |
+
|
| 10 |
+
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
| 11 |
+
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
| 12 |
+
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
| 13 |
+
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
| 14 |
+
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
| 15 |
+
|
| 16 |
+
## Never Do
|
| 17 |
+
|
| 18 |
+
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
| 19 |
+
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
| 20 |
+
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
| 21 |
+
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
| 22 |
+
|
| 23 |
+
## Resources
|
| 24 |
+
|
| 25 |
+
| Resource | Use for |
|
| 26 |
+
|----------|---------|
|
| 27 |
+
| `gitnexus://repo/ielts_web/context` | Codebase overview, check index freshness |
|
| 28 |
+
| `gitnexus://repo/ielts_web/clusters` | All functional areas |
|
| 29 |
+
| `gitnexus://repo/ielts_web/processes` | All execution flows |
|
| 30 |
+
| `gitnexus://repo/ielts_web/process/{name}` | Step-by-step execution trace |
|
| 31 |
+
|
| 32 |
+
## CLI
|
| 33 |
+
|
| 34 |
+
| Task | Read this skill file |
|
| 35 |
+
|------|---------------------|
|
| 36 |
+
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
| 37 |
+
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
| 38 |
+
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
| 39 |
+
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
| 40 |
+
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
| 41 |
+
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
| 42 |
+
|
| 43 |
+
<!-- gitnexus:end -->
|
CLAUDE.md
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!-- gitnexus:start -->
|
| 2 |
+
# GitNexus — Code Intelligence
|
| 3 |
+
|
| 4 |
+
This project is indexed by GitNexus as **ielts_web** (7370 symbols, 16808 relationships, 300 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
|
| 5 |
+
|
| 6 |
+
> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first.
|
| 7 |
+
|
| 8 |
+
## Always Do
|
| 9 |
+
|
| 10 |
+
- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
|
| 11 |
+
- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows.
|
| 12 |
+
- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
|
| 13 |
+
- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
|
| 14 |
+
- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`.
|
| 15 |
+
|
| 16 |
+
## Never Do
|
| 17 |
+
|
| 18 |
+
- NEVER edit a function, class, or method without first running `gitnexus_impact` on it.
|
| 19 |
+
- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
|
| 20 |
+
- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph.
|
| 21 |
+
- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope.
|
| 22 |
+
|
| 23 |
+
## Resources
|
| 24 |
+
|
| 25 |
+
| Resource | Use for |
|
| 26 |
+
|----------|---------|
|
| 27 |
+
| `gitnexus://repo/ielts_web/context` | Codebase overview, check index freshness |
|
| 28 |
+
| `gitnexus://repo/ielts_web/clusters` | All functional areas |
|
| 29 |
+
| `gitnexus://repo/ielts_web/processes` | All execution flows |
|
| 30 |
+
| `gitnexus://repo/ielts_web/process/{name}` | Step-by-step execution trace |
|
| 31 |
+
|
| 32 |
+
## CLI
|
| 33 |
+
|
| 34 |
+
| Task | Read this skill file |
|
| 35 |
+
|------|---------------------|
|
| 36 |
+
| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
|
| 37 |
+
| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
|
| 38 |
+
| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
|
| 39 |
+
| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
|
| 40 |
+
| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
|
| 41 |
+
| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
|
| 42 |
+
|
| 43 |
+
<!-- gitnexus:end -->
|
backend/.env.example
CHANGED
|
@@ -9,7 +9,8 @@ SECRET_KEY=change-this-to-64-char-random-string
|
|
| 9 |
ALGORITHM=HS256
|
| 10 |
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
| 11 |
REFRESH_TOKEN_EXPIRE_DAYS=30
|
| 12 |
-
|
|
|
|
| 13 |
|
| 14 |
# ── App ─────────────────────────────────────────────────────────────────────
|
| 15 |
APP_NAME=LinguaIELTS API
|
|
@@ -34,13 +35,21 @@ STORAGE_BACKEND=local
|
|
| 34 |
# S3_PUBLIC_BASE_URL=/media
|
| 35 |
|
| 36 |
METRICS_ENABLED=true
|
|
|
|
|
|
|
| 37 |
|
| 38 |
# Auth cookies (production: refresh httpOnly; dev set false for simpler testing)
|
| 39 |
AUTH_HTTPONLY_REFRESH=false
|
| 40 |
|
| 41 |
# ── AI ──────────────────────────────────────────────────────────────────────
|
| 42 |
OPENROUTER_API_KEY=
|
|
|
|
|
|
|
| 43 |
OPENROUTER_FAST_MODEL=google/gemini-2.0-flash-001
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
|
| 45 |
# ── Observability ───────────────────────────────────────────────────────────
|
| 46 |
SENTRY_DSN=
|
|
@@ -48,7 +57,7 @@ ENVIRONMENT=development
|
|
| 48 |
# Skip SQLAlchemy create_all on startup when false (production: use Alembic only)
|
| 49 |
AUTO_CREATE_TABLES=true
|
| 50 |
|
| 51 |
-
# Email (password reset)
|
| 52 |
SMTP_HOST=
|
| 53 |
SMTP_PORT=587
|
| 54 |
SMTP_USER=
|
|
@@ -56,3 +65,13 @@ SMTP_PASSWORD=
|
|
| 56 |
SMTP_FROM=
|
| 57 |
SMTP_USE_TLS=true
|
| 58 |
PASSWORD_RESET_EXPIRE_HOURS=24
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
ALGORITHM=HS256
|
| 10 |
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
| 11 |
REFRESH_TOKEN_EXPIRE_DAYS=30
|
| 12 |
+
# Legacy only. Admin rights should be assigned by updating users.role in the DB/seed process.
|
| 13 |
+
# Use: python -m app.cli.promote_admin --email you@example.com
|
| 14 |
|
| 15 |
# ── App ─────────────────────────────────────────────────────────────────────
|
| 16 |
APP_NAME=LinguaIELTS API
|
|
|
|
| 35 |
# S3_PUBLIC_BASE_URL=/media
|
| 36 |
|
| 37 |
METRICS_ENABLED=true
|
| 38 |
+
# Required when METRICS_ENABLED=true and ENVIRONMENT=production
|
| 39 |
+
METRICS_TOKEN=
|
| 40 |
|
| 41 |
# Auth cookies (production: refresh httpOnly; dev set false for simpler testing)
|
| 42 |
AUTH_HTTPONLY_REFRESH=false
|
| 43 |
|
| 44 |
# ── AI ──────────────────────────────────────────────────────────────────────
|
| 45 |
OPENROUTER_API_KEY=
|
| 46 |
+
# Extra keys (comma-separated) for rotation when quota/rate-limit hit — scales to 1000+ users
|
| 47 |
+
# OPENROUTER_API_KEYS=sk-or-v1-key2,sk-or-v1-key3
|
| 48 |
OPENROUTER_FAST_MODEL=google/gemini-2.0-flash-001
|
| 49 |
+
# Prefer :free models first (default true in production) — no token cost, auto-failover cascade
|
| 50 |
+
# OPENROUTER_PREFER_FREE=true
|
| 51 |
+
# Comma-separated free models (optional — defaults built-in)
|
| 52 |
+
# OPENROUTER_FREE_MODELS=google/gemini-2.0-flash-exp:free,meta-llama/llama-3.3-70b-instruct:free
|
| 53 |
|
| 54 |
# ── Observability ───────────────────────────────────────────────────────────
|
| 55 |
SENTRY_DSN=
|
|
|
|
| 57 |
# Skip SQLAlchemy create_all on startup when false (production: use Alembic only)
|
| 58 |
AUTO_CREATE_TABLES=true
|
| 59 |
|
| 60 |
+
# Email (password reset + email verification)
|
| 61 |
SMTP_HOST=
|
| 62 |
SMTP_PORT=587
|
| 63 |
SMTP_USER=
|
|
|
|
| 65 |
SMTP_FROM=
|
| 66 |
SMTP_USE_TLS=true
|
| 67 |
PASSWORD_RESET_EXPIRE_HOURS=24
|
| 68 |
+
EMAIL_VERIFY_EXPIRE_MINUTES=15
|
| 69 |
+
# Set false to skip email verification requirement (dev only)
|
| 70 |
+
REQUIRE_EMAIL_VERIFICATION=true
|
| 71 |
+
|
| 72 |
+
# ── Google OAuth ─────────────────────────────────────────────────────────────
|
| 73 |
+
# Get from Google Cloud Console → APIs & Services → Credentials → OAuth 2.0 Client ID
|
| 74 |
+
# Register redirect URI: http://localhost:5173/auth/google/callback
|
| 75 |
+
GOOGLE_CLIENT_ID=
|
| 76 |
+
GOOGLE_CLIENT_SECRET=
|
| 77 |
+
GOOGLE_REDIRECT_URI=http://localhost:5173/auth/google/callback
|
backend/README.md
CHANGED
|
@@ -17,7 +17,14 @@ python -m pip install -r requirements.txt
|
|
| 17 |
DATABASE_URL=sqlite+aiosqlite:///./linguaielts.db
|
| 18 |
```
|
| 19 |
|
| 20 |
-
3) Run
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
|
| 22 |
```bash
|
| 23 |
cd backend
|
|
@@ -34,13 +41,50 @@ If you ran uvicorn from `DATN/` (parent folder) you get `No module named 'app'`
|
|
| 34 |
|
| 35 |
Open docs at `http://localhost:8000/docs`.
|
| 36 |
|
| 37 |
-
Optional in `backend/.env` for Speaking
|
| 38 |
|
| 39 |
```env
|
| 40 |
OPENROUTER_API_KEY=sk-or-v1-...
|
|
|
|
|
|
|
| 41 |
OPENROUTER_FAST_MODEL=google/gemini-2.0-flash-001
|
|
|
|
|
|
|
| 42 |
```
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
## Mock test JSON APIs (used by frontend)
|
| 45 |
|
| 46 |
- `GET /mock-tests`
|
|
@@ -50,3 +94,5 @@ OPENROUTER_FAST_MODEL=google/gemini-2.0-flash-001
|
|
| 50 |
|
| 51 |
These endpoints read directly from `backend/data` and return the JSON with original field names.
|
| 52 |
|
|
|
|
|
|
|
|
|
| 17 |
DATABASE_URL=sqlite+aiosqlite:///./linguaielts.db
|
| 18 |
```
|
| 19 |
|
| 20 |
+
3) Run migrations (recommended even for SQLite):
|
| 21 |
+
|
| 22 |
+
```bash
|
| 23 |
+
cd backend
|
| 24 |
+
alembic upgrade head
|
| 25 |
+
```
|
| 26 |
+
|
| 27 |
+
4) Run the API (**current directory must be `backend/`** so `import app` works):
|
| 28 |
|
| 29 |
```bash
|
| 30 |
cd backend
|
|
|
|
| 41 |
|
| 42 |
Open docs at `http://localhost:8000/docs`.
|
| 43 |
|
| 44 |
+
Optional in `backend/.env` for AI features (Speaking, Writing, Translation, Vocab, Study plan):
|
| 45 |
|
| 46 |
```env
|
| 47 |
OPENROUTER_API_KEY=sk-or-v1-...
|
| 48 |
+
# Extra keys — rotated automatically when quota/rate-limit hit (recommended for 1000+ users)
|
| 49 |
+
# OPENROUTER_API_KEYS=sk-or-v1-key2,sk-or-v1-key3
|
| 50 |
OPENROUTER_FAST_MODEL=google/gemini-2.0-flash-001
|
| 51 |
+
# Production default: try :free models first (no token cost, auto-failover across 6+ models)
|
| 52 |
+
# OPENROUTER_PREFER_FREE=true
|
| 53 |
```
|
| 54 |
|
| 55 |
+
The shared client (`app/core/openrouter_client.py`) cascades: **free models → paid primary → fallback**, rotates API keys on 402/429, and enables `provider.allow_fallbacks` on OpenRouter.
|
| 56 |
+
|
| 57 |
+
## Database migrations
|
| 58 |
+
|
| 59 |
+
Initial migration `20260524_initial_schema` uses **frozen DDL** (not `Base.metadata.create_all`) so fresh databases can run `alembic upgrade head` without duplicate table/column errors from later revisions.
|
| 60 |
+
|
| 61 |
+
```bash
|
| 62 |
+
cd backend
|
| 63 |
+
alembic upgrade head
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
## Admin user (one-time)
|
| 67 |
+
|
| 68 |
+
Admin is **not** auto-assigned by email at login/register. After a user registers, promote deliberately:
|
| 69 |
+
|
| 70 |
+
```bash
|
| 71 |
+
cd backend
|
| 72 |
+
python -m app.cli.promote_admin --email admin@example.com
|
| 73 |
+
```
|
| 74 |
+
|
| 75 |
+
Or SQL: `UPDATE users SET role='admin' WHERE email='admin@example.com';`
|
| 76 |
+
|
| 77 |
+
## Security notes (production)
|
| 78 |
+
|
| 79 |
+
| Topic | Configuration |
|
| 80 |
+
|-------|----------------|
|
| 81 |
+
| Secrets | `ENVIRONMENT=production` fails startup if `SECRET_KEY`, DB password, S3 keys, or `METRICS_TOKEN` look weak/default. Generate: `openssl rand -hex 32` |
|
| 82 |
+
| OpenAPI | `/docs`, `/redoc`, `/openapi.json` disabled when `ENVIRONMENT=production` |
|
| 83 |
+
| Metrics | `GET /metrics` requires `Authorization: Bearer <METRICS_TOKEN>` in production |
|
| 84 |
+
| Refresh token | Set `AUTH_HTTPONLY_REFRESH=true`; access token kept in memory on frontend (`tokenStore.js`) |
|
| 85 |
+
| History scores | `POST /history/save` is disabled (410) — use skill submit endpoints for server-side scoring |
|
| 86 |
+
| Rate limit | SlowAPI uses `X-Forwarded-For` only when the direct client is a trusted proxy; nginx also rate-limits auth/ML routes |
|
| 87 |
+
|
| 88 |
## Mock test JSON APIs (used by frontend)
|
| 89 |
|
| 90 |
- `GET /mock-tests`
|
|
|
|
| 94 |
|
| 95 |
These endpoints read directly from `backend/data` and return the JSON with original field names.
|
| 96 |
|
| 97 |
+
#tạo bản đồ code cho AI
|
| 98 |
+
gitnexus analyze
|
backend/alembic/versions/002_password_reset.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
from alembic import op
|
| 4 |
import sqlalchemy as sa
|
|
|
|
| 5 |
|
| 6 |
revision = "002_password_reset"
|
| 7 |
down_revision = "001_add_indexes"
|
|
@@ -10,17 +11,25 @@ depends_on = None
|
|
| 10 |
|
| 11 |
|
| 12 |
def upgrade() -> None:
|
| 13 |
-
op.
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
|
| 25 |
|
| 26 |
def downgrade() -> None:
|
|
|
|
| 2 |
|
| 3 |
from alembic import op
|
| 4 |
import sqlalchemy as sa
|
| 5 |
+
from sqlalchemy import inspect
|
| 6 |
|
| 7 |
revision = "002_password_reset"
|
| 8 |
down_revision = "001_add_indexes"
|
|
|
|
| 11 |
|
| 12 |
|
| 13 |
def upgrade() -> None:
|
| 14 |
+
bind = op.get_bind()
|
| 15 |
+
inspector = inspect(bind)
|
| 16 |
+
if not inspector.has_table("password_reset_tokens"):
|
| 17 |
+
op.create_table(
|
| 18 |
+
"password_reset_tokens",
|
| 19 |
+
sa.Column("id", sa.Integer(), primary_key=True),
|
| 20 |
+
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
| 21 |
+
sa.Column("token_hash", sa.String(255), nullable=False),
|
| 22 |
+
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
| 23 |
+
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
|
| 24 |
+
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
| 25 |
+
)
|
| 26 |
+
inspector = inspect(bind)
|
| 27 |
+
|
| 28 |
+
indexes = {idx["name"] for idx in inspector.get_indexes("password_reset_tokens")}
|
| 29 |
+
if "ix_password_reset_tokens_user_id" not in indexes:
|
| 30 |
+
op.create_index("ix_password_reset_tokens_user_id", "password_reset_tokens", ["user_id"])
|
| 31 |
+
if "ix_password_reset_tokens_token_hash" not in indexes:
|
| 32 |
+
op.create_index("ix_password_reset_tokens_token_hash", "password_reset_tokens", ["token_hash"], unique=True)
|
| 33 |
|
| 34 |
|
| 35 |
def downgrade() -> None:
|
backend/alembic/versions/003_history_archive.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
from alembic import op
|
| 4 |
import sqlalchemy as sa
|
|
|
|
| 5 |
|
| 6 |
revision = "003_history_archive"
|
| 7 |
down_revision = "002_password_reset"
|
|
@@ -10,24 +11,31 @@ depends_on = None
|
|
| 10 |
|
| 11 |
|
| 12 |
def upgrade() -> None:
|
| 13 |
-
op.
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
|
| 32 |
|
| 33 |
def downgrade() -> None:
|
|
|
|
| 2 |
|
| 3 |
from alembic import op
|
| 4 |
import sqlalchemy as sa
|
| 5 |
+
from sqlalchemy import inspect
|
| 6 |
|
| 7 |
revision = "003_history_archive"
|
| 8 |
down_revision = "002_password_reset"
|
|
|
|
| 11 |
|
| 12 |
|
| 13 |
def upgrade() -> None:
|
| 14 |
+
bind = op.get_bind()
|
| 15 |
+
inspector = inspect(bind)
|
| 16 |
+
if not inspector.has_table("history_archive"):
|
| 17 |
+
op.create_table(
|
| 18 |
+
"history_archive",
|
| 19 |
+
sa.Column("id", sa.Integer(), primary_key=True),
|
| 20 |
+
sa.Column("user_id", sa.Integer(), nullable=False),
|
| 21 |
+
sa.Column("quiz_id", sa.String(100), nullable=True),
|
| 22 |
+
sa.Column("practice_session_id", sa.Integer(), nullable=True),
|
| 23 |
+
sa.Column("subject", sa.String(100), nullable=True),
|
| 24 |
+
sa.Column("score", sa.Integer(), nullable=True),
|
| 25 |
+
sa.Column("total_questions", sa.Integer(), nullable=True),
|
| 26 |
+
sa.Column("percentage", sa.Float(), nullable=True),
|
| 27 |
+
sa.Column("band_score", sa.Float(), nullable=True),
|
| 28 |
+
sa.Column("mode", sa.String(20), nullable=True),
|
| 29 |
+
sa.Column("duration_seconds", sa.Integer(), nullable=True),
|
| 30 |
+
sa.Column("answers", sa.JSON(), nullable=True),
|
| 31 |
+
sa.Column("completed_at", sa.DateTime(timezone=True), nullable=False),
|
| 32 |
+
sa.Column("archived_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
| 33 |
+
)
|
| 34 |
+
inspector = inspect(bind)
|
| 35 |
+
|
| 36 |
+
indexes = {idx["name"] for idx in inspector.get_indexes("history_archive")}
|
| 37 |
+
if "idx_history_archive_user_date" not in indexes:
|
| 38 |
+
op.create_index("idx_history_archive_user_date", "history_archive", ["user_id", "completed_at"])
|
| 39 |
|
| 40 |
|
| 41 |
def downgrade() -> None:
|
backend/alembic/versions/004_adaptive_notifications.py
CHANGED
|
@@ -2,6 +2,7 @@
|
|
| 2 |
|
| 3 |
from alembic import op
|
| 4 |
import sqlalchemy as sa
|
|
|
|
| 5 |
|
| 6 |
revision = "004_adaptive_notifications"
|
| 7 |
down_revision = "003_history_archive"
|
|
@@ -10,58 +11,71 @@ depends_on = None
|
|
| 10 |
|
| 11 |
|
| 12 |
def upgrade() -> None:
|
| 13 |
-
op.
|
| 14 |
-
|
| 15 |
-
sa.Column("suggested_difficulty", sa.String(20), nullable=True),
|
| 16 |
-
)
|
| 17 |
-
op.add_column(
|
| 18 |
-
"study_plan_tasks",
|
| 19 |
-
sa.Column("priority_score", sa.Float(), server_default="0", nullable=False),
|
| 20 |
-
)
|
| 21 |
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
sa.Column("srs_last_review_at", sa.DateTime(timezone=True), nullable=True),
|
| 32 |
-
sa.Column("suggested_difficulty", sa.String(20), server_default="medium", nullable=False),
|
| 33 |
-
sa.Column("avg_performance", sa.Float(), server_default="0", nullable=False),
|
| 34 |
-
sa.Column("attempt_count", sa.Integer(), server_default="0", nullable=False),
|
| 35 |
-
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
| 36 |
-
sa.UniqueConstraint("user_id", "skill", name="uq_user_skill_adaptive"),
|
| 37 |
-
)
|
| 38 |
-
op.create_index("idx_skill_adaptive_user", "skill_adaptive_states", ["user_id"])
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
-
|
| 54 |
-
|
| 55 |
-
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
|
| 66 |
|
| 67 |
def downgrade() -> None:
|
|
|
|
| 2 |
|
| 3 |
from alembic import op
|
| 4 |
import sqlalchemy as sa
|
| 5 |
+
from sqlalchemy import inspect, text
|
| 6 |
|
| 7 |
revision = "004_adaptive_notifications"
|
| 8 |
down_revision = "003_history_archive"
|
|
|
|
| 11 |
|
| 12 |
|
| 13 |
def upgrade() -> None:
|
| 14 |
+
bind = op.get_bind()
|
| 15 |
+
inspector = inspect(bind)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
|
| 17 |
+
# Use raw SQL with IF NOT EXISTS to avoid inspect() issues with async drivers
|
| 18 |
+
bind.execute(sa.text(
|
| 19 |
+
"ALTER TABLE study_plan_tasks "
|
| 20 |
+
"ADD COLUMN IF NOT EXISTS suggested_difficulty VARCHAR(20);"
|
| 21 |
+
))
|
| 22 |
+
bind.execute(sa.text(
|
| 23 |
+
"ALTER TABLE study_plan_tasks "
|
| 24 |
+
"ADD COLUMN IF NOT EXISTS priority_score FLOAT NOT NULL DEFAULT 0;"
|
| 25 |
+
))
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
+
if not inspector.has_table("skill_adaptive_states"):
|
| 28 |
+
op.create_table(
|
| 29 |
+
"skill_adaptive_states",
|
| 30 |
+
sa.Column("id", sa.Integer(), primary_key=True),
|
| 31 |
+
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
| 32 |
+
sa.Column("skill", sa.String(30), nullable=False),
|
| 33 |
+
sa.Column("srs_ease", sa.Float(), server_default="2.5", nullable=False),
|
| 34 |
+
sa.Column("srs_interval_days", sa.Integer(), server_default="1", nullable=False),
|
| 35 |
+
sa.Column("srs_repetitions", sa.Integer(), server_default="0", nullable=False),
|
| 36 |
+
sa.Column("srs_next_review_at", sa.DateTime(timezone=True), nullable=True),
|
| 37 |
+
sa.Column("srs_last_review_at", sa.DateTime(timezone=True), nullable=True),
|
| 38 |
+
sa.Column("suggested_difficulty", sa.String(20), server_default="medium", nullable=False),
|
| 39 |
+
sa.Column("avg_performance", sa.Float(), server_default="0", nullable=False),
|
| 40 |
+
sa.Column("attempt_count", sa.Integer(), server_default="0", nullable=False),
|
| 41 |
+
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
| 42 |
+
sa.UniqueConstraint("user_id", "skill", name="uq_user_skill_adaptive"),
|
| 43 |
+
)
|
| 44 |
+
inspector = inspect(bind)
|
| 45 |
+
indexes = {idx["name"] for idx in inspector.get_indexes("skill_adaptive_states")}
|
| 46 |
+
if "idx_skill_adaptive_user" not in indexes:
|
| 47 |
+
op.create_index("idx_skill_adaptive_user", "skill_adaptive_states", ["user_id"])
|
| 48 |
|
| 49 |
+
if not inspector.has_table("notification_settings"):
|
| 50 |
+
op.create_table(
|
| 51 |
+
"notification_settings",
|
| 52 |
+
sa.Column("id", sa.Integer(), primary_key=True),
|
| 53 |
+
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True),
|
| 54 |
+
sa.Column("reminder_enabled", sa.Boolean(), server_default=sa.true(), nullable=False),
|
| 55 |
+
sa.Column("reminder_time", sa.Time(), nullable=True),
|
| 56 |
+
sa.Column("channel", sa.String(20), server_default="in_app", nullable=False),
|
| 57 |
+
sa.Column("email_daily_digest", sa.Boolean(), server_default=sa.false(), nullable=False),
|
| 58 |
+
sa.Column("push_enabled", sa.Boolean(), server_default=sa.false(), nullable=False),
|
| 59 |
+
sa.Column("timezone", sa.String(64), server_default="Asia/Ho_Chi_Minh", nullable=False),
|
| 60 |
+
sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
| 61 |
+
)
|
| 62 |
+
|
| 63 |
+
if not inspector.has_table("notifications"):
|
| 64 |
+
op.create_table(
|
| 65 |
+
"notifications",
|
| 66 |
+
sa.Column("id", sa.Integer(), primary_key=True),
|
| 67 |
+
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
| 68 |
+
sa.Column("type", sa.String(40), nullable=False),
|
| 69 |
+
sa.Column("title", sa.String(200), nullable=False),
|
| 70 |
+
sa.Column("body", sa.Text(), nullable=False),
|
| 71 |
+
sa.Column("link_path", sa.String(200), nullable=True),
|
| 72 |
+
sa.Column("is_read", sa.Boolean(), server_default=sa.false(), nullable=False),
|
| 73 |
+
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
|
| 74 |
+
)
|
| 75 |
+
inspector = inspect(bind)
|
| 76 |
+
indexes = {idx["name"] for idx in inspector.get_indexes("notifications")}
|
| 77 |
+
if "idx_notifications_user_read" not in indexes:
|
| 78 |
+
op.create_index("idx_notifications_user_read", "notifications", ["user_id", "is_read", "created_at"])
|
| 79 |
|
| 80 |
|
| 81 |
def downgrade() -> None:
|
backend/alembic/versions/005_admin_user_columns.py
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Admin: users.role/lock fields and user_profiles leaderboard moderation."""
|
| 2 |
+
|
| 3 |
+
from alembic import op
|
| 4 |
+
import sqlalchemy as sa
|
| 5 |
+
from sqlalchemy import inspect
|
| 6 |
+
|
| 7 |
+
revision = "005_admin_user_columns"
|
| 8 |
+
down_revision = "004_adaptive_notifications"
|
| 9 |
+
branch_labels = None
|
| 10 |
+
depends_on = None
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def upgrade() -> None:
|
| 14 |
+
inspector = inspect(op.get_bind())
|
| 15 |
+
|
| 16 |
+
user_columns = {col["name"] for col in inspector.get_columns("users")}
|
| 17 |
+
if "role" not in user_columns:
|
| 18 |
+
op.add_column(
|
| 19 |
+
"users",
|
| 20 |
+
sa.Column("role", sa.String(20), nullable=False, server_default="user"),
|
| 21 |
+
)
|
| 22 |
+
if "is_active" not in user_columns:
|
| 23 |
+
op.add_column(
|
| 24 |
+
"users",
|
| 25 |
+
sa.Column("is_active", sa.Boolean(), nullable=False, server_default=sa.true()),
|
| 26 |
+
)
|
| 27 |
+
if "locked_at" not in user_columns:
|
| 28 |
+
op.add_column("users", sa.Column("locked_at", sa.DateTime(timezone=True)))
|
| 29 |
+
if "lock_reason" not in user_columns:
|
| 30 |
+
op.add_column("users", sa.Column("lock_reason", sa.Text()))
|
| 31 |
+
|
| 32 |
+
profile_columns = {col["name"] for col in inspector.get_columns("user_profiles")}
|
| 33 |
+
if "is_leaderboard_hidden" not in profile_columns:
|
| 34 |
+
op.add_column(
|
| 35 |
+
"user_profiles",
|
| 36 |
+
sa.Column(
|
| 37 |
+
"is_leaderboard_hidden",
|
| 38 |
+
sa.Boolean(),
|
| 39 |
+
nullable=False,
|
| 40 |
+
server_default=sa.false(),
|
| 41 |
+
),
|
| 42 |
+
)
|
| 43 |
+
if "leaderboard_flag_reason" not in profile_columns:
|
| 44 |
+
op.add_column("user_profiles", sa.Column("leaderboard_flag_reason", sa.Text()))
|
| 45 |
+
if "leaderboard_hidden_at" not in profile_columns:
|
| 46 |
+
op.add_column(
|
| 47 |
+
"user_profiles",
|
| 48 |
+
sa.Column("leaderboard_hidden_at", sa.DateTime(timezone=True)),
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def downgrade() -> None:
|
| 53 |
+
op.drop_column("user_profiles", "leaderboard_hidden_at")
|
| 54 |
+
op.drop_column("user_profiles", "leaderboard_flag_reason")
|
| 55 |
+
op.drop_column("user_profiles", "is_leaderboard_hidden")
|
| 56 |
+
op.drop_column("users", "lock_reason")
|
| 57 |
+
op.drop_column("users", "locked_at")
|
| 58 |
+
op.drop_column("users", "is_active")
|
| 59 |
+
op.drop_column("users", "role")
|
backend/alembic/versions/006_google_oauth_email_verify.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Google OAuth columns and email_verifications table.
|
| 2 |
+
|
| 3 |
+
Revision ID: 006_google_oauth_email_verify
|
| 4 |
+
Revises: 005_admin_user_columns
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from alembic import op
|
| 8 |
+
import sqlalchemy as sa
|
| 9 |
+
from sqlalchemy import inspect
|
| 10 |
+
|
| 11 |
+
revision = "006_google_oauth_email_verify"
|
| 12 |
+
down_revision = "005_admin_user_columns"
|
| 13 |
+
branch_labels = None
|
| 14 |
+
depends_on = None
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def upgrade() -> None:
|
| 18 |
+
inspector = inspect(op.get_bind())
|
| 19 |
+
user_columns = {col["name"] for col in inspector.get_columns("users")}
|
| 20 |
+
|
| 21 |
+
if "google_id" not in user_columns:
|
| 22 |
+
op.add_column("users", sa.Column("google_id", sa.String(255), nullable=True))
|
| 23 |
+
op.create_index("ix_users_google_id", "users", ["google_id"], unique=True)
|
| 24 |
+
|
| 25 |
+
if "is_verified" not in user_columns:
|
| 26 |
+
# Existing users are trusted — mark them verified by default.
|
| 27 |
+
op.add_column(
|
| 28 |
+
"users",
|
| 29 |
+
sa.Column("is_verified", sa.Boolean(), nullable=False, server_default=sa.true()),
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
if "auth_provider" not in user_columns:
|
| 33 |
+
op.add_column(
|
| 34 |
+
"users",
|
| 35 |
+
sa.Column("auth_provider", sa.String(20), nullable=False, server_default="email"),
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
tables = inspector.get_table_names()
|
| 39 |
+
if "email_verifications" not in tables:
|
| 40 |
+
op.create_table(
|
| 41 |
+
"email_verifications",
|
| 42 |
+
sa.Column("id", sa.Integer(), primary_key=True),
|
| 43 |
+
sa.Column(
|
| 44 |
+
"user_id",
|
| 45 |
+
sa.Integer(),
|
| 46 |
+
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
| 47 |
+
nullable=False,
|
| 48 |
+
),
|
| 49 |
+
sa.Column("code_hash", sa.String(64), nullable=False),
|
| 50 |
+
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
| 51 |
+
sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
|
| 52 |
+
sa.Column(
|
| 53 |
+
"created_at",
|
| 54 |
+
sa.DateTime(timezone=True),
|
| 55 |
+
server_default=sa.func.now(),
|
| 56 |
+
),
|
| 57 |
+
)
|
| 58 |
+
op.create_index(
|
| 59 |
+
"ix_email_verifications_user_id", "email_verifications", ["user_id"]
|
| 60 |
+
)
|
| 61 |
+
|
| 62 |
+
|
| 63 |
+
def downgrade() -> None:
|
| 64 |
+
op.drop_table("email_verifications")
|
| 65 |
+
op.drop_index("ix_users_google_id", table_name="users")
|
| 66 |
+
op.drop_column("users", "auth_provider")
|
| 67 |
+
op.drop_column("users", "is_verified")
|
| 68 |
+
op.drop_column("users", "google_id")
|
backend/alembic/versions/007_translation_practice.py
ADDED
|
@@ -0,0 +1,78 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""007 – Add translation practice tables
|
| 2 |
+
|
| 3 |
+
Revision ID: 007
|
| 4 |
+
Revises: 006
|
| 5 |
+
Create Date: 2026-05-30
|
| 6 |
+
"""
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from alembic import op
|
| 10 |
+
import sqlalchemy as sa
|
| 11 |
+
from sqlalchemy import inspect
|
| 12 |
+
|
| 13 |
+
revision = "007_translation_practice"
|
| 14 |
+
down_revision = "006_google_oauth_email_verify"
|
| 15 |
+
branch_labels = None
|
| 16 |
+
depends_on = None
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def upgrade() -> None:
|
| 20 |
+
inspector = inspect(op.get_bind())
|
| 21 |
+
tables = set(inspector.get_table_names())
|
| 22 |
+
|
| 23 |
+
if "translation_steps" not in tables:
|
| 24 |
+
op.create_table(
|
| 25 |
+
"translation_steps",
|
| 26 |
+
sa.Column("id", sa.Integer(), primary_key=True),
|
| 27 |
+
sa.Column("order", sa.Integer(), nullable=False, server_default="0"),
|
| 28 |
+
sa.Column("title", sa.String(200), nullable=False),
|
| 29 |
+
sa.Column("description", sa.Text(), nullable=False, server_default=""),
|
| 30 |
+
sa.Column("badge_label", sa.String(30), nullable=True),
|
| 31 |
+
sa.Column("badge_color", sa.String(20), nullable=False, server_default="gray"),
|
| 32 |
+
sa.Column("icon_emoji", sa.String(10), nullable=False, server_default="📝"),
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
if "translation_topics" not in tables:
|
| 36 |
+
op.create_table(
|
| 37 |
+
"translation_topics",
|
| 38 |
+
sa.Column("id", sa.Integer(), primary_key=True),
|
| 39 |
+
sa.Column("step_id", sa.Integer(), sa.ForeignKey("translation_steps.id", ondelete="CASCADE"), nullable=False),
|
| 40 |
+
sa.Column("order", sa.Integer(), nullable=False, server_default="0"),
|
| 41 |
+
sa.Column("title", sa.String(200), nullable=False),
|
| 42 |
+
sa.Column("description", sa.Text(), nullable=False, server_default=""),
|
| 43 |
+
)
|
| 44 |
+
op.create_index("ix_translation_topics_step_id", "translation_topics", ["step_id"])
|
| 45 |
+
|
| 46 |
+
if "translation_sentences" not in tables:
|
| 47 |
+
op.create_table(
|
| 48 |
+
"translation_sentences",
|
| 49 |
+
sa.Column("id", sa.Integer(), primary_key=True),
|
| 50 |
+
sa.Column("topic_id", sa.Integer(), sa.ForeignKey("translation_topics.id", ondelete="CASCADE"), nullable=False),
|
| 51 |
+
sa.Column("order", sa.Integer(), nullable=False, server_default="0"),
|
| 52 |
+
sa.Column("vietnamese", sa.Text(), nullable=False),
|
| 53 |
+
sa.Column("english", sa.Text(), nullable=False),
|
| 54 |
+
sa.Column("explanation", sa.Text(), nullable=True),
|
| 55 |
+
)
|
| 56 |
+
op.create_index("ix_translation_sentences_topic_id", "translation_sentences", ["topic_id"])
|
| 57 |
+
|
| 58 |
+
if "translation_attempts" not in tables:
|
| 59 |
+
op.create_table(
|
| 60 |
+
"translation_attempts",
|
| 61 |
+
sa.Column("id", sa.Integer(), primary_key=True),
|
| 62 |
+
sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
|
| 63 |
+
sa.Column("sentence_id", sa.Integer(), sa.ForeignKey("translation_sentences.id", ondelete="CASCADE"), nullable=False),
|
| 64 |
+
sa.Column("user_translation", sa.Text(), nullable=False),
|
| 65 |
+
sa.Column("score", sa.Float(), nullable=True),
|
| 66 |
+
sa.Column("feedback", sa.Text(), nullable=True),
|
| 67 |
+
sa.Column("model_answer", sa.Text(), nullable=True),
|
| 68 |
+
sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
|
| 69 |
+
)
|
| 70 |
+
op.create_index("ix_translation_attempts_user_id", "translation_attempts", ["user_id"])
|
| 71 |
+
op.create_index("ix_translation_attempts_sentence_id", "translation_attempts", ["sentence_id"])
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
def downgrade() -> None:
|
| 75 |
+
op.drop_table("translation_attempts")
|
| 76 |
+
op.drop_table("translation_sentences")
|
| 77 |
+
op.drop_table("translation_topics")
|
| 78 |
+
op.drop_table("translation_steps")
|
backend/alembic/versions/20260524_initial_schema.py
CHANGED
|
@@ -1,19 +1,18 @@
|
|
| 1 |
-
"""Initial schema —
|
| 2 |
|
| 3 |
Revision ID: 20260524_initial
|
| 4 |
Revises:
|
| 5 |
Create Date: 2026-05-24
|
| 6 |
|
|
|
|
|
|
|
| 7 |
"""
|
| 8 |
|
| 9 |
from typing import Sequence, Union
|
| 10 |
|
|
|
|
| 11 |
from alembic import op
|
| 12 |
|
| 13 |
-
# Import models so Base.metadata is populated
|
| 14 |
-
from app.db import models # noqa: F401
|
| 15 |
-
from app.db.database import Base
|
| 16 |
-
|
| 17 |
revision: str = "20260524_initial"
|
| 18 |
down_revision: Union[str, None] = None
|
| 19 |
branch_labels: Union[str, Sequence[str], None] = None
|
|
@@ -21,10 +20,400 @@ depends_on: Union[str, Sequence[str], None] = None
|
|
| 21 |
|
| 22 |
|
| 23 |
def upgrade() -> None:
|
| 24 |
-
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
|
| 27 |
|
| 28 |
def downgrade() -> None:
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Initial schema — frozen baseline DDL (no live model import).
|
| 2 |
|
| 3 |
Revision ID: 20260524_initial
|
| 4 |
Revises:
|
| 5 |
Create Date: 2026-05-24
|
| 6 |
|
| 7 |
+
Tables/columns added in later revisions (002–005) are intentionally omitted here
|
| 8 |
+
so `alembic upgrade head` on a fresh database does not duplicate objects.
|
| 9 |
"""
|
| 10 |
|
| 11 |
from typing import Sequence, Union
|
| 12 |
|
| 13 |
+
import sqlalchemy as sa
|
| 14 |
from alembic import op
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
revision: str = "20260524_initial"
|
| 17 |
down_revision: Union[str, None] = None
|
| 18 |
branch_labels: Union[str, Sequence[str], None] = None
|
|
|
|
| 20 |
|
| 21 |
|
| 22 |
def upgrade() -> None:
|
| 23 |
+
op.create_table(
|
| 24 |
+
"users",
|
| 25 |
+
sa.Column("id", sa.Integer(), primary_key=True),
|
| 26 |
+
sa.Column("email", sa.String(255), nullable=False),
|
| 27 |
+
sa.Column("password_hash", sa.String(255), nullable=False),
|
| 28 |
+
sa.Column(
|
| 29 |
+
"created_at",
|
| 30 |
+
sa.DateTime(timezone=True),
|
| 31 |
+
server_default=sa.func.now(),
|
| 32 |
+
nullable=False,
|
| 33 |
+
),
|
| 34 |
+
)
|
| 35 |
+
op.create_index("ix_users_id", "users", ["id"])
|
| 36 |
+
op.create_index("ix_users_email", "users", ["email"], unique=True)
|
| 37 |
+
|
| 38 |
+
op.create_table(
|
| 39 |
+
"user_profiles",
|
| 40 |
+
sa.Column("id", sa.Integer(), primary_key=True),
|
| 41 |
+
sa.Column(
|
| 42 |
+
"user_id",
|
| 43 |
+
sa.Integer(),
|
| 44 |
+
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
| 45 |
+
nullable=False,
|
| 46 |
+
unique=True,
|
| 47 |
+
),
|
| 48 |
+
sa.Column("full_name", sa.String(255)),
|
| 49 |
+
sa.Column("avatar_url", sa.Text()),
|
| 50 |
+
sa.Column("phone", sa.String(20)),
|
| 51 |
+
sa.Column("bio", sa.Text()),
|
| 52 |
+
sa.Column("target_band", sa.Float(), server_default="7"),
|
| 53 |
+
sa.Column("exam_date", sa.Date()),
|
| 54 |
+
sa.Column("streak", sa.Integer(), server_default="0", nullable=False),
|
| 55 |
+
sa.Column("longest_streak", sa.Integer(), server_default="0", nullable=False),
|
| 56 |
+
sa.Column("streak_freeze_count", sa.Integer(), server_default="0", nullable=False),
|
| 57 |
+
sa.Column("last_activity_date", sa.Date()),
|
| 58 |
+
sa.Column("xp", sa.Integer(), server_default="0", nullable=False),
|
| 59 |
+
sa.Column("daily_writing_used", sa.Integer(), server_default="0", nullable=False),
|
| 60 |
+
sa.Column("daily_speaking_used", sa.Integer(), server_default="0", nullable=False),
|
| 61 |
+
sa.Column("tutor_questions_used_month", sa.Integer(), server_default="0", nullable=False),
|
| 62 |
+
sa.Column(
|
| 63 |
+
"updated_at",
|
| 64 |
+
sa.DateTime(timezone=True),
|
| 65 |
+
server_default=sa.func.now(),
|
| 66 |
+
nullable=False,
|
| 67 |
+
),
|
| 68 |
+
)
|
| 69 |
+
op.create_index("ix_user_profiles_id", "user_profiles", ["id"])
|
| 70 |
+
|
| 71 |
+
op.create_table(
|
| 72 |
+
"practice_sessions",
|
| 73 |
+
sa.Column("id", sa.Integer(), primary_key=True),
|
| 74 |
+
sa.Column(
|
| 75 |
+
"user_id",
|
| 76 |
+
sa.Integer(),
|
| 77 |
+
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
| 78 |
+
nullable=False,
|
| 79 |
+
),
|
| 80 |
+
sa.Column("session_type", sa.String(20), nullable=False),
|
| 81 |
+
sa.Column("quiz_id", sa.String(100)),
|
| 82 |
+
sa.Column("status", sa.String(20), server_default="started", nullable=False),
|
| 83 |
+
sa.Column("score", sa.Float()),
|
| 84 |
+
sa.Column(
|
| 85 |
+
"started_at",
|
| 86 |
+
sa.DateTime(timezone=True),
|
| 87 |
+
server_default=sa.func.now(),
|
| 88 |
+
nullable=False,
|
| 89 |
+
),
|
| 90 |
+
sa.Column("submitted_at", sa.DateTime(timezone=True)),
|
| 91 |
+
)
|
| 92 |
+
op.create_index("ix_practice_sessions_id", "practice_sessions", ["id"])
|
| 93 |
+
op.create_index("ix_practice_sessions_user_id", "practice_sessions", ["user_id"])
|
| 94 |
+
|
| 95 |
+
op.create_table(
|
| 96 |
+
"history",
|
| 97 |
+
sa.Column("id", sa.Integer(), primary_key=True),
|
| 98 |
+
sa.Column(
|
| 99 |
+
"user_id",
|
| 100 |
+
sa.Integer(),
|
| 101 |
+
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
| 102 |
+
nullable=False,
|
| 103 |
+
),
|
| 104 |
+
sa.Column("quiz_id", sa.String(100)),
|
| 105 |
+
sa.Column(
|
| 106 |
+
"practice_session_id",
|
| 107 |
+
sa.Integer(),
|
| 108 |
+
sa.ForeignKey("practice_sessions.id", ondelete="SET NULL"),
|
| 109 |
+
),
|
| 110 |
+
sa.Column("subject", sa.String(100)),
|
| 111 |
+
sa.Column("score", sa.Integer()),
|
| 112 |
+
sa.Column("total_questions", sa.Integer()),
|
| 113 |
+
sa.Column("percentage", sa.Float()),
|
| 114 |
+
sa.Column("band_score", sa.Float()),
|
| 115 |
+
sa.Column("mode", sa.String(20)),
|
| 116 |
+
sa.Column("duration_seconds", sa.Integer()),
|
| 117 |
+
sa.Column("answers", sa.JSON()),
|
| 118 |
+
sa.Column(
|
| 119 |
+
"completed_at",
|
| 120 |
+
sa.DateTime(timezone=True),
|
| 121 |
+
server_default=sa.func.now(),
|
| 122 |
+
nullable=False,
|
| 123 |
+
),
|
| 124 |
+
)
|
| 125 |
+
op.create_index("ix_history_id", "history", ["id"])
|
| 126 |
+
op.create_index("ix_history_practice_session_id", "history", ["practice_session_id"])
|
| 127 |
+
|
| 128 |
+
op.create_table(
|
| 129 |
+
"progress",
|
| 130 |
+
sa.Column("id", sa.Integer(), primary_key=True),
|
| 131 |
+
sa.Column(
|
| 132 |
+
"user_id",
|
| 133 |
+
sa.Integer(),
|
| 134 |
+
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
| 135 |
+
nullable=False,
|
| 136 |
+
),
|
| 137 |
+
sa.Column("subject", sa.String(100), nullable=False),
|
| 138 |
+
sa.Column("total_questions", sa.Integer(), server_default="0", nullable=False),
|
| 139 |
+
sa.Column("completed_questions", sa.Integer(), server_default="0", nullable=False),
|
| 140 |
+
sa.Column("percentage", sa.Float(), server_default="0", nullable=False),
|
| 141 |
+
sa.Column("band_score", sa.Float()),
|
| 142 |
+
sa.Column(
|
| 143 |
+
"updated_at",
|
| 144 |
+
sa.DateTime(timezone=True),
|
| 145 |
+
server_default=sa.func.now(),
|
| 146 |
+
nullable=False,
|
| 147 |
+
),
|
| 148 |
+
sa.UniqueConstraint("user_id", "subject", name="uq_user_subject"),
|
| 149 |
+
)
|
| 150 |
+
op.create_index("ix_progress_id", "progress", ["id"])
|
| 151 |
+
|
| 152 |
+
op.create_table(
|
| 153 |
+
"refresh_tokens",
|
| 154 |
+
sa.Column("id", sa.Integer(), primary_key=True),
|
| 155 |
+
sa.Column(
|
| 156 |
+
"user_id",
|
| 157 |
+
sa.Integer(),
|
| 158 |
+
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
| 159 |
+
nullable=False,
|
| 160 |
+
),
|
| 161 |
+
sa.Column("token_hash", sa.String(255), nullable=False),
|
| 162 |
+
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
| 163 |
+
sa.Column("revoked", sa.Boolean(), server_default=sa.false(), nullable=False),
|
| 164 |
+
sa.Column(
|
| 165 |
+
"created_at",
|
| 166 |
+
sa.DateTime(timezone=True),
|
| 167 |
+
server_default=sa.func.now(),
|
| 168 |
+
nullable=False,
|
| 169 |
+
),
|
| 170 |
+
sa.Column("revoked_at", sa.DateTime(timezone=True)),
|
| 171 |
+
)
|
| 172 |
+
op.create_index("ix_refresh_tokens_id", "refresh_tokens", ["id"])
|
| 173 |
+
op.create_index("ix_refresh_tokens_user_id", "refresh_tokens", ["user_id"])
|
| 174 |
+
op.create_index("ix_refresh_tokens_token_hash", "refresh_tokens", ["token_hash"], unique=True)
|
| 175 |
+
|
| 176 |
+
op.create_table(
|
| 177 |
+
"vocab_topics",
|
| 178 |
+
sa.Column("id", sa.Integer(), primary_key=True),
|
| 179 |
+
sa.Column(
|
| 180 |
+
"user_id",
|
| 181 |
+
sa.Integer(),
|
| 182 |
+
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
| 183 |
+
nullable=False,
|
| 184 |
+
),
|
| 185 |
+
sa.Column("name", sa.String(200), nullable=False),
|
| 186 |
+
sa.Column("sort_order", sa.Integer(), server_default="0", nullable=False),
|
| 187 |
+
sa.Column(
|
| 188 |
+
"created_at",
|
| 189 |
+
sa.DateTime(timezone=True),
|
| 190 |
+
server_default=sa.func.now(),
|
| 191 |
+
nullable=False,
|
| 192 |
+
),
|
| 193 |
+
)
|
| 194 |
+
op.create_index("ix_vocab_topics_id", "vocab_topics", ["id"])
|
| 195 |
+
op.create_index("ix_vocab_topics_user_id", "vocab_topics", ["user_id"])
|
| 196 |
+
|
| 197 |
+
op.create_table(
|
| 198 |
+
"vocab_words",
|
| 199 |
+
sa.Column("id", sa.Integer(), primary_key=True),
|
| 200 |
+
sa.Column(
|
| 201 |
+
"topic_id",
|
| 202 |
+
sa.Integer(),
|
| 203 |
+
sa.ForeignKey("vocab_topics.id", ondelete="CASCADE"),
|
| 204 |
+
nullable=False,
|
| 205 |
+
),
|
| 206 |
+
sa.Column("word", sa.String(200), nullable=False),
|
| 207 |
+
sa.Column("phonetic", sa.String(200)),
|
| 208 |
+
sa.Column("word_type", sa.String(100)),
|
| 209 |
+
sa.Column("meaning_en", sa.Text()),
|
| 210 |
+
sa.Column("meaning_vi", sa.Text()),
|
| 211 |
+
sa.Column("example", sa.Text()),
|
| 212 |
+
sa.Column("example_vi", sa.Text()),
|
| 213 |
+
sa.Column("note", sa.Text()),
|
| 214 |
+
sa.Column("mastery", sa.String(20), server_default="new", nullable=False),
|
| 215 |
+
sa.Column("srs_ease", sa.Float(), server_default="2.5", nullable=False),
|
| 216 |
+
sa.Column("srs_interval_days", sa.Integer(), server_default="0", nullable=False),
|
| 217 |
+
sa.Column("srs_repetitions", sa.Integer(), server_default="0", nullable=False),
|
| 218 |
+
sa.Column("srs_next_review_at", sa.DateTime(timezone=True)),
|
| 219 |
+
sa.Column("srs_last_review_at", sa.DateTime(timezone=True)),
|
| 220 |
+
sa.Column("source_quiz_id", sa.String(100)),
|
| 221 |
+
sa.Column("source_type", sa.String(20)),
|
| 222 |
+
sa.Column(
|
| 223 |
+
"created_at",
|
| 224 |
+
sa.DateTime(timezone=True),
|
| 225 |
+
server_default=sa.func.now(),
|
| 226 |
+
nullable=False,
|
| 227 |
+
),
|
| 228 |
+
sa.Column(
|
| 229 |
+
"updated_at",
|
| 230 |
+
sa.DateTime(timezone=True),
|
| 231 |
+
server_default=sa.func.now(),
|
| 232 |
+
nullable=False,
|
| 233 |
+
),
|
| 234 |
+
)
|
| 235 |
+
op.create_index("ix_vocab_words_id", "vocab_words", ["id"])
|
| 236 |
+
op.create_index("ix_vocab_words_topic_id", "vocab_words", ["topic_id"])
|
| 237 |
+
|
| 238 |
+
op.create_table(
|
| 239 |
+
"system_vocab_topics",
|
| 240 |
+
sa.Column("id", sa.Integer(), primary_key=True),
|
| 241 |
+
sa.Column("name", sa.String(200), nullable=False),
|
| 242 |
+
sa.Column("description", sa.Text()),
|
| 243 |
+
sa.Column("level", sa.String(50)),
|
| 244 |
+
sa.Column("sort_order", sa.Integer(), server_default="0", nullable=False),
|
| 245 |
+
sa.Column("is_active", sa.Boolean(), server_default=sa.true(), nullable=False),
|
| 246 |
+
sa.Column(
|
| 247 |
+
"created_at",
|
| 248 |
+
sa.DateTime(timezone=True),
|
| 249 |
+
server_default=sa.func.now(),
|
| 250 |
+
nullable=False,
|
| 251 |
+
),
|
| 252 |
+
sa.Column(
|
| 253 |
+
"updated_at",
|
| 254 |
+
sa.DateTime(timezone=True),
|
| 255 |
+
server_default=sa.func.now(),
|
| 256 |
+
nullable=False,
|
| 257 |
+
),
|
| 258 |
+
)
|
| 259 |
+
op.create_index("ix_system_vocab_topics_id", "system_vocab_topics", ["id"])
|
| 260 |
+
op.create_index("ix_system_vocab_topics_name", "system_vocab_topics", ["name"])
|
| 261 |
+
|
| 262 |
+
op.create_table(
|
| 263 |
+
"system_vocab_words",
|
| 264 |
+
sa.Column("id", sa.Integer(), primary_key=True),
|
| 265 |
+
sa.Column(
|
| 266 |
+
"topic_id",
|
| 267 |
+
sa.Integer(),
|
| 268 |
+
sa.ForeignKey("system_vocab_topics.id", ondelete="CASCADE"),
|
| 269 |
+
nullable=False,
|
| 270 |
+
),
|
| 271 |
+
sa.Column("word", sa.String(200), nullable=False),
|
| 272 |
+
sa.Column("phonetic", sa.String(200)),
|
| 273 |
+
sa.Column("word_type", sa.String(100)),
|
| 274 |
+
sa.Column("meaning_en", sa.Text()),
|
| 275 |
+
sa.Column("meaning_vi", sa.Text()),
|
| 276 |
+
sa.Column("example", sa.Text()),
|
| 277 |
+
sa.Column("example_vi", sa.Text()),
|
| 278 |
+
sa.Column("tags", sa.JSON()),
|
| 279 |
+
sa.Column("sort_order", sa.Integer(), server_default="0", nullable=False),
|
| 280 |
+
sa.Column(
|
| 281 |
+
"created_at",
|
| 282 |
+
sa.DateTime(timezone=True),
|
| 283 |
+
server_default=sa.func.now(),
|
| 284 |
+
nullable=False,
|
| 285 |
+
),
|
| 286 |
+
sa.Column(
|
| 287 |
+
"updated_at",
|
| 288 |
+
sa.DateTime(timezone=True),
|
| 289 |
+
server_default=sa.func.now(),
|
| 290 |
+
nullable=False,
|
| 291 |
+
),
|
| 292 |
+
)
|
| 293 |
+
op.create_index("ix_system_vocab_words_id", "system_vocab_words", ["id"])
|
| 294 |
+
op.create_index("ix_system_vocab_words_topic_id", "system_vocab_words", ["topic_id"])
|
| 295 |
+
|
| 296 |
+
op.create_table(
|
| 297 |
+
"reading_annotations",
|
| 298 |
+
sa.Column("id", sa.Integer(), primary_key=True),
|
| 299 |
+
sa.Column(
|
| 300 |
+
"user_id",
|
| 301 |
+
sa.Integer(),
|
| 302 |
+
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
| 303 |
+
nullable=False,
|
| 304 |
+
),
|
| 305 |
+
sa.Column("session_id", sa.String(100), nullable=False),
|
| 306 |
+
sa.Column("quiz_id", sa.String(100)),
|
| 307 |
+
sa.Column("highlights", sa.JSON()),
|
| 308 |
+
sa.Column("note", sa.Text()),
|
| 309 |
+
sa.Column(
|
| 310 |
+
"created_at",
|
| 311 |
+
sa.DateTime(timezone=True),
|
| 312 |
+
server_default=sa.func.now(),
|
| 313 |
+
nullable=False,
|
| 314 |
+
),
|
| 315 |
+
sa.Column(
|
| 316 |
+
"updated_at",
|
| 317 |
+
sa.DateTime(timezone=True),
|
| 318 |
+
server_default=sa.func.now(),
|
| 319 |
+
nullable=False,
|
| 320 |
+
),
|
| 321 |
+
)
|
| 322 |
+
op.create_index("ix_reading_annotations_id", "reading_annotations", ["id"])
|
| 323 |
+
op.create_index("ix_reading_annotations_user_id", "reading_annotations", ["user_id"])
|
| 324 |
+
op.create_index("ix_reading_annotations_session_id", "reading_annotations", ["session_id"])
|
| 325 |
+
op.create_index("ix_reading_annotations_quiz_id", "reading_annotations", ["quiz_id"])
|
| 326 |
+
|
| 327 |
+
op.create_table(
|
| 328 |
+
"shadowing_videos",
|
| 329 |
+
sa.Column("id", sa.Integer(), primary_key=True),
|
| 330 |
+
sa.Column("video_id", sa.String(20), nullable=False),
|
| 331 |
+
sa.Column("title", sa.String(500), server_default="", nullable=False),
|
| 332 |
+
sa.Column("level", sa.String(50), server_default="Intermediate", nullable=False),
|
| 333 |
+
sa.Column("language", sa.String(10), server_default="en", nullable=False),
|
| 334 |
+
sa.Column("source_url", sa.Text(), server_default="", nullable=False),
|
| 335 |
+
sa.Column("transcript_source", sa.String(20), server_default="youtube", nullable=False),
|
| 336 |
+
sa.Column("segments", sa.JSON(), server_default="[]", nullable=False),
|
| 337 |
+
sa.Column(
|
| 338 |
+
"created_by",
|
| 339 |
+
sa.Integer(),
|
| 340 |
+
sa.ForeignKey("users.id", ondelete="SET NULL"),
|
| 341 |
+
),
|
| 342 |
+
sa.Column(
|
| 343 |
+
"created_at",
|
| 344 |
+
sa.DateTime(timezone=True),
|
| 345 |
+
server_default=sa.func.now(),
|
| 346 |
+
nullable=False,
|
| 347 |
+
),
|
| 348 |
+
)
|
| 349 |
+
op.create_index("ix_shadowing_videos_id", "shadowing_videos", ["id"])
|
| 350 |
+
op.create_index("ix_shadowing_videos_video_id", "shadowing_videos", ["video_id"], unique=True)
|
| 351 |
+
|
| 352 |
+
op.create_table(
|
| 353 |
+
"shadowing_user_history",
|
| 354 |
+
sa.Column("id", sa.Integer(), primary_key=True),
|
| 355 |
+
sa.Column(
|
| 356 |
+
"user_id",
|
| 357 |
+
sa.Integer(),
|
| 358 |
+
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
| 359 |
+
nullable=False,
|
| 360 |
+
),
|
| 361 |
+
sa.Column("video_id", sa.String(20), nullable=False),
|
| 362 |
+
sa.Column("display_title", sa.String(500)),
|
| 363 |
+
sa.Column("display_level", sa.String(50)),
|
| 364 |
+
sa.Column(
|
| 365 |
+
"last_viewed_at",
|
| 366 |
+
sa.DateTime(timezone=True),
|
| 367 |
+
server_default=sa.func.now(),
|
| 368 |
+
nullable=False,
|
| 369 |
+
),
|
| 370 |
+
sa.UniqueConstraint("user_id", "video_id", name="uq_shadowing_user_video"),
|
| 371 |
+
)
|
| 372 |
+
op.create_index("ix_shadowing_user_history_id", "shadowing_user_history", ["id"])
|
| 373 |
+
op.create_index("ix_shadowing_user_history_user_id", "shadowing_user_history", ["user_id"])
|
| 374 |
+
op.create_index("ix_shadowing_user_history_video_id", "shadowing_user_history", ["video_id"])
|
| 375 |
+
|
| 376 |
+
op.create_table(
|
| 377 |
+
"study_plan_tasks",
|
| 378 |
+
sa.Column("id", sa.Integer(), primary_key=True),
|
| 379 |
+
sa.Column(
|
| 380 |
+
"user_id",
|
| 381 |
+
sa.Integer(),
|
| 382 |
+
sa.ForeignKey("users.id", ondelete="CASCADE"),
|
| 383 |
+
nullable=False,
|
| 384 |
+
),
|
| 385 |
+
sa.Column("day_number", sa.Integer(), nullable=False),
|
| 386 |
+
sa.Column("plan_date", sa.Date()),
|
| 387 |
+
sa.Column("focus_skill", sa.String(50), nullable=False),
|
| 388 |
+
sa.Column("task_description", sa.Text(), nullable=False),
|
| 389 |
+
sa.Column("duration_minutes", sa.Integer(), server_default="45", nullable=False),
|
| 390 |
+
sa.Column("quiz_id", sa.String(100)),
|
| 391 |
+
sa.Column("route_path", sa.String(200)),
|
| 392 |
+
sa.Column("is_completed", sa.Boolean(), server_default=sa.false(), nullable=False),
|
| 393 |
+
sa.Column("completed_at", sa.DateTime(timezone=True)),
|
| 394 |
+
sa.Column(
|
| 395 |
+
"created_at",
|
| 396 |
+
sa.DateTime(timezone=True),
|
| 397 |
+
server_default=sa.func.now(),
|
| 398 |
+
nullable=False,
|
| 399 |
+
),
|
| 400 |
+
)
|
| 401 |
+
op.create_index("ix_study_plan_tasks_id", "study_plan_tasks", ["id"])
|
| 402 |
+
op.create_index("ix_study_plan_tasks_user_id", "study_plan_tasks", ["user_id"])
|
| 403 |
|
| 404 |
|
| 405 |
def downgrade() -> None:
|
| 406 |
+
op.drop_table("study_plan_tasks")
|
| 407 |
+
op.drop_table("shadowing_user_history")
|
| 408 |
+
op.drop_table("shadowing_videos")
|
| 409 |
+
op.drop_table("reading_annotations")
|
| 410 |
+
op.drop_table("system_vocab_words")
|
| 411 |
+
op.drop_table("system_vocab_topics")
|
| 412 |
+
op.drop_table("vocab_words")
|
| 413 |
+
op.drop_table("vocab_topics")
|
| 414 |
+
op.drop_table("refresh_tokens")
|
| 415 |
+
op.drop_table("progress")
|
| 416 |
+
op.drop_table("history")
|
| 417 |
+
op.drop_table("practice_sessions")
|
| 418 |
+
op.drop_table("user_profiles")
|
| 419 |
+
op.drop_table("users")
|
backend/app/cli/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Management CLI entrypoints."""
|
backend/app/cli/promote_admin.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
One-shot CLI to grant admin role to an existing user by email.
|
| 3 |
+
|
| 4 |
+
Usage (from backend/):
|
| 5 |
+
python -m app.cli.promote_admin --email admin@example.com
|
| 6 |
+
|
| 7 |
+
Does not auto-promote at login/register — assign admin deliberately via this tool
|
| 8 |
+
or direct SQL: UPDATE users SET role='admin' WHERE email='...';
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
from __future__ import annotations
|
| 12 |
+
|
| 13 |
+
import argparse
|
| 14 |
+
import asyncio
|
| 15 |
+
import logging
|
| 16 |
+
import sys
|
| 17 |
+
|
| 18 |
+
from sqlalchemy import select
|
| 19 |
+
|
| 20 |
+
from app.db.database import AsyncSessionLocal
|
| 21 |
+
from app.db.models import User
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
|
| 26 |
+
async def _promote(email: str) -> int:
|
| 27 |
+
normalized = email.strip().lower()
|
| 28 |
+
if not normalized:
|
| 29 |
+
print("Email is required.", file=sys.stderr)
|
| 30 |
+
return 1
|
| 31 |
+
|
| 32 |
+
async with AsyncSessionLocal() as db:
|
| 33 |
+
result = await db.execute(select(User).where(User.email == normalized))
|
| 34 |
+
user = result.scalar_one_or_none()
|
| 35 |
+
if not user:
|
| 36 |
+
print(f"No user found with email: {normalized}", file=sys.stderr)
|
| 37 |
+
return 1
|
| 38 |
+
if user.role == "admin":
|
| 39 |
+
print(f"User {normalized} is already admin (id={user.id}).")
|
| 40 |
+
return 0
|
| 41 |
+
user.role = "admin"
|
| 42 |
+
await db.commit()
|
| 43 |
+
print(f"Promoted user id={user.id} ({normalized}) to admin.")
|
| 44 |
+
return 0
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def main() -> None:
|
| 48 |
+
parser = argparse.ArgumentParser(description="Promote a user to admin by email.")
|
| 49 |
+
parser.add_argument("--email", required=True, help="Registered user email")
|
| 50 |
+
args = parser.parse_args()
|
| 51 |
+
raise SystemExit(asyncio.run(_promote(args.email)))
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
if __name__ == "__main__":
|
| 55 |
+
main()
|
backend/app/core/auth_cookies.py
CHANGED
|
@@ -94,8 +94,13 @@ def validate_csrf(request: Request) -> None:
|
|
| 94 |
exempt_prefixes = (
|
| 95 |
"/auth/login",
|
| 96 |
"/auth/register",
|
|
|
|
|
|
|
| 97 |
"/auth/forgot-password",
|
| 98 |
"/auth/reset-password",
|
|
|
|
|
|
|
|
|
|
| 99 |
"/health",
|
| 100 |
"/metrics",
|
| 101 |
"/docs",
|
|
|
|
| 94 |
exempt_prefixes = (
|
| 95 |
"/auth/login",
|
| 96 |
"/auth/register",
|
| 97 |
+
"/auth/refresh", # refresh uses httpOnly cookie — no CSRF token needed
|
| 98 |
+
"/auth/logout", # logout is safe (revoke-only, no state change risk)
|
| 99 |
"/auth/forgot-password",
|
| 100 |
"/auth/reset-password",
|
| 101 |
+
"/auth/google", # Google OAuth — no session yet when exchanging code
|
| 102 |
+
"/auth/verify-email", # OTP verify — public, no session yet
|
| 103 |
+
"/auth/resend-verification", # OTP resend — public, no session yet
|
| 104 |
"/health",
|
| 105 |
"/metrics",
|
| 106 |
"/docs",
|
backend/app/core/config.py
CHANGED
|
@@ -8,9 +8,19 @@ Uses pydantic-settings for type-safe configuration.
|
|
| 8 |
from functools import lru_cache
|
| 9 |
from typing import Any
|
| 10 |
|
| 11 |
-
from pydantic import field_validator
|
| 12 |
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 13 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 14 |
|
| 15 |
class Settings(BaseSettings):
|
| 16 |
# ── Application ──────────────────────────────────────────
|
|
@@ -36,6 +46,7 @@ class Settings(BaseSettings):
|
|
| 36 |
ALGORITHM: str = "HS256"
|
| 37 |
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
| 38 |
REFRESH_TOKEN_EXPIRE_DAYS: int = 30
|
|
|
|
| 39 |
ADMIN_EMAILS: str = ""
|
| 40 |
AVATAR_UPLOAD_DIR: str = "uploads/avatars"
|
| 41 |
|
|
@@ -51,6 +62,8 @@ class Settings(BaseSettings):
|
|
| 51 |
|
| 52 |
# ── Metrics ────────────────────────────────────────────────
|
| 53 |
METRICS_ENABLED: bool = True
|
|
|
|
|
|
|
| 54 |
|
| 55 |
# ── ML preload ─────────────────────────────────────────────
|
| 56 |
ML_PRELOAD_ON_STARTUP: bool | None = None
|
|
@@ -76,10 +89,26 @@ class Settings(BaseSettings):
|
|
| 76 |
SMTP_USE_TLS: bool = True
|
| 77 |
PASSWORD_RESET_EXPIRE_HOURS: int = 24
|
| 78 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 79 |
# ── ML / Speaking pipeline ────────────────────────────────
|
| 80 |
OPENROUTER_API_KEY: str = ""
|
|
|
|
|
|
|
| 81 |
# Must be a model slug that exists on OpenRouter (mistral-7b-instruct returns 404).
|
| 82 |
OPENROUTER_FAST_MODEL: str = "google/gemini-2.0-flash-001"
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
PRON_MODEL_PATH: str = "model/pron_scorer_best.pt"
|
| 84 |
WHISPER_MODEL_SIZE: str = "base"
|
| 85 |
|
|
@@ -121,6 +150,36 @@ class Settings(BaseSettings):
|
|
| 121 |
return self.AUTH_HTTPONLY_REFRESH
|
| 122 |
return self.ENVIRONMENT == "production"
|
| 123 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
@lru_cache()
|
| 126 |
def get_settings() -> Settings:
|
|
@@ -130,12 +189,3 @@ def get_settings() -> Settings:
|
|
| 130 |
|
| 131 |
# Module-level singleton for convenience imports
|
| 132 |
settings = get_settings()
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
def admin_email_set() -> set[str]:
|
| 136 |
-
"""Return lowercase admin bootstrap emails from ADMIN_EMAILS."""
|
| 137 |
-
return {
|
| 138 |
-
email.strip().lower()
|
| 139 |
-
for email in settings.ADMIN_EMAILS.split(",")
|
| 140 |
-
if email.strip()
|
| 141 |
-
}
|
|
|
|
| 8 |
from functools import lru_cache
|
| 9 |
from typing import Any
|
| 10 |
|
| 11 |
+
from pydantic import field_validator, model_validator
|
| 12 |
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 13 |
|
| 14 |
+
_WEAK_SECRET_MARKERS = (
|
| 15 |
+
"change-this",
|
| 16 |
+
"changeme",
|
| 17 |
+
"minioadmin",
|
| 18 |
+
"password",
|
| 19 |
+
"secret",
|
| 20 |
+
"example",
|
| 21 |
+
"yourdomain",
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
|
| 25 |
class Settings(BaseSettings):
|
| 26 |
# ── Application ──────────────────────────────────────────
|
|
|
|
| 46 |
ALGORITHM: str = "HS256"
|
| 47 |
ACCESS_TOKEN_EXPIRE_MINUTES: int = 30
|
| 48 |
REFRESH_TOKEN_EXPIRE_DAYS: int = 30
|
| 49 |
+
# Deprecated — no runtime effect. Use app.cli.promote_admin instead.
|
| 50 |
ADMIN_EMAILS: str = ""
|
| 51 |
AVATAR_UPLOAD_DIR: str = "uploads/avatars"
|
| 52 |
|
|
|
|
| 62 |
|
| 63 |
# ── Metrics ────────────────────────────────────────────────
|
| 64 |
METRICS_ENABLED: bool = True
|
| 65 |
+
# Bearer token required for GET /metrics when ENVIRONMENT=production
|
| 66 |
+
METRICS_TOKEN: str = ""
|
| 67 |
|
| 68 |
# ── ML preload ─────────────────────────────────────────────
|
| 69 |
ML_PRELOAD_ON_STARTUP: bool | None = None
|
|
|
|
| 89 |
SMTP_USE_TLS: bool = True
|
| 90 |
PASSWORD_RESET_EXPIRE_HOURS: int = 24
|
| 91 |
|
| 92 |
+
# ── Google OAuth ───────────────────────────────────────────
|
| 93 |
+
GOOGLE_CLIENT_ID: str = ""
|
| 94 |
+
GOOGLE_CLIENT_SECRET: str = ""
|
| 95 |
+
# Registered redirect URI in Google Cloud Console
|
| 96 |
+
GOOGLE_REDIRECT_URI: str = "http://localhost:5173/auth/google/callback"
|
| 97 |
+
|
| 98 |
+
# ── Email verification ─────────────────────────────────────
|
| 99 |
+
EMAIL_VERIFY_EXPIRE_MINUTES: int = 15
|
| 100 |
+
REQUIRE_EMAIL_VERIFICATION: bool = True
|
| 101 |
+
|
| 102 |
# ── ML / Speaking pipeline ────────────────────────────────
|
| 103 |
OPENROUTER_API_KEY: str = ""
|
| 104 |
+
# Additional keys (comma-separated) — rotated when one hits quota/rate-limit.
|
| 105 |
+
OPENROUTER_API_KEYS: str = ""
|
| 106 |
# Must be a model slug that exists on OpenRouter (mistral-7b-instruct returns 404).
|
| 107 |
OPENROUTER_FAST_MODEL: str = "google/gemini-2.0-flash-001"
|
| 108 |
+
# Comma-separated free models tried when primary hits quota (see openrouter_client.py).
|
| 109 |
+
OPENROUTER_FREE_MODELS: str = ""
|
| 110 |
+
# When True (default in production), try :free models before paid primary.
|
| 111 |
+
OPENROUTER_PREFER_FREE: bool | None = None
|
| 112 |
PRON_MODEL_PATH: str = "model/pron_scorer_best.pt"
|
| 113 |
WHISPER_MODEL_SIZE: str = "base"
|
| 114 |
|
|
|
|
| 150 |
return self.AUTH_HTTPONLY_REFRESH
|
| 151 |
return self.ENVIRONMENT == "production"
|
| 152 |
|
| 153 |
+
@staticmethod
|
| 154 |
+
def _looks_weak_secret(value: str, *, min_len: int = 32) -> bool:
|
| 155 |
+
normalized = (value or "").strip().lower()
|
| 156 |
+
if len(normalized) < min_len:
|
| 157 |
+
return True
|
| 158 |
+
return any(marker in normalized for marker in _WEAK_SECRET_MARKERS)
|
| 159 |
+
|
| 160 |
+
@model_validator(mode="after")
|
| 161 |
+
def validate_production_secrets(self) -> "Settings":
|
| 162 |
+
if self.ENVIRONMENT != "production":
|
| 163 |
+
return self
|
| 164 |
+
if self._looks_weak_secret(self.SECRET_KEY):
|
| 165 |
+
raise ValueError(
|
| 166 |
+
"SECRET_KEY is missing or uses a weak/default value. "
|
| 167 |
+
"Generate one with: openssl rand -hex 32"
|
| 168 |
+
)
|
| 169 |
+
if self.METRICS_ENABLED and not (self.METRICS_TOKEN or "").strip():
|
| 170 |
+
raise ValueError(
|
| 171 |
+
"METRICS_TOKEN is required when METRICS_ENABLED=true in production."
|
| 172 |
+
)
|
| 173 |
+
if self.STORAGE_BACKEND.lower() == "s3":
|
| 174 |
+
if self._looks_weak_secret(self.S3_SECRET_KEY, min_len=16):
|
| 175 |
+
raise ValueError("S3_SECRET_KEY must be a strong secret in production.")
|
| 176 |
+
if self._looks_weak_secret(self.S3_ACCESS_KEY, min_len=8):
|
| 177 |
+
raise ValueError("S3_ACCESS_KEY must not use default credentials in production.")
|
| 178 |
+
db_url = self.DATABASE_URL.lower()
|
| 179 |
+
if ":password@" in db_url or ":changeme@" in db_url:
|
| 180 |
+
raise ValueError("DATABASE_URL must not use default/weak DB passwords in production.")
|
| 181 |
+
return self
|
| 182 |
+
|
| 183 |
|
| 184 |
@lru_cache()
|
| 185 |
def get_settings() -> Settings:
|
|
|
|
| 189 |
|
| 190 |
# Module-level singleton for convenience imports
|
| 191 |
settings = get_settings()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
backend/app/core/dependencies.py
CHANGED
|
@@ -7,7 +7,6 @@ FastAPI dependency functions injected into protected routes.
|
|
| 7 |
from fastapi import Depends, HTTPException, status
|
| 8 |
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
| 9 |
|
| 10 |
-
from app.core.config import admin_email_set
|
| 11 |
from app.core.security import decode_access_token
|
| 12 |
from app.db.database import AsyncSession, get_db
|
| 13 |
from app.db.models import User
|
|
@@ -46,11 +45,6 @@ async def get_current_user(
|
|
| 46 |
if user is None:
|
| 47 |
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
| 48 |
|
| 49 |
-
if user.email.lower() in admin_email_set() and user.role != "admin":
|
| 50 |
-
user.role = "admin"
|
| 51 |
-
db.add(user)
|
| 52 |
-
await db.flush()
|
| 53 |
-
|
| 54 |
if not user.is_active:
|
| 55 |
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account is locked")
|
| 56 |
|
|
@@ -81,10 +75,6 @@ async def get_current_user_optional(
|
|
| 81 |
return None
|
| 82 |
result = await db.execute(select(User).where(User.id == int(user_id)))
|
| 83 |
user = result.scalar_one_or_none()
|
| 84 |
-
if user and user.email.lower() in admin_email_set() and user.role != "admin":
|
| 85 |
-
user.role = "admin"
|
| 86 |
-
db.add(user)
|
| 87 |
-
await db.flush()
|
| 88 |
if user and not user.is_active:
|
| 89 |
return None
|
| 90 |
return user
|
|
|
|
| 7 |
from fastapi import Depends, HTTPException, status
|
| 8 |
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
| 9 |
|
|
|
|
| 10 |
from app.core.security import decode_access_token
|
| 11 |
from app.db.database import AsyncSession, get_db
|
| 12 |
from app.db.models import User
|
|
|
|
| 45 |
if user is None:
|
| 46 |
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
| 47 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 48 |
if not user.is_active:
|
| 49 |
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account is locked")
|
| 50 |
|
|
|
|
| 75 |
return None
|
| 76 |
result = await db.execute(select(User).where(User.id == int(user_id)))
|
| 77 |
user = result.scalar_one_or_none()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
if user and not user.is_active:
|
| 79 |
return None
|
| 80 |
return user
|
backend/app/core/metrics.py
CHANGED
|
@@ -4,11 +4,13 @@ from __future__ import annotations
|
|
| 4 |
|
| 5 |
import time
|
| 6 |
|
|
|
|
| 7 |
from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_latest
|
| 8 |
from starlette.middleware.base import BaseHTTPMiddleware
|
| 9 |
-
from starlette.requests import Request
|
| 10 |
from starlette.responses import Response
|
| 11 |
|
|
|
|
|
|
|
| 12 |
HTTP_REQUESTS = Counter(
|
| 13 |
"http_requests_total",
|
| 14 |
"Total HTTP requests",
|
|
@@ -42,5 +44,18 @@ class PrometheusMiddleware(BaseHTTPMiddleware):
|
|
| 42 |
return response
|
| 43 |
|
| 44 |
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
|
|
|
|
| 4 |
|
| 5 |
import time
|
| 6 |
|
| 7 |
+
from fastapi import HTTPException, Request
|
| 8 |
from prometheus_client import CONTENT_TYPE_LATEST, Counter, Histogram, generate_latest
|
| 9 |
from starlette.middleware.base import BaseHTTPMiddleware
|
|
|
|
| 10 |
from starlette.responses import Response
|
| 11 |
|
| 12 |
+
from app.core.config import settings
|
| 13 |
+
|
| 14 |
HTTP_REQUESTS = Counter(
|
| 15 |
"http_requests_total",
|
| 16 |
"Total HTTP requests",
|
|
|
|
| 44 |
return response
|
| 45 |
|
| 46 |
|
| 47 |
+
def _authorize_metrics(request: Request) -> None:
|
| 48 |
+
if settings.ENVIRONMENT != "production":
|
| 49 |
+
return
|
| 50 |
+
expected = (settings.METRICS_TOKEN or "").strip()
|
| 51 |
+
if not expected:
|
| 52 |
+
raise HTTPException(status_code=503, detail="Metrics endpoint is not configured")
|
| 53 |
+
auth = request.headers.get("Authorization", "")
|
| 54 |
+
token = auth[7:].strip() if auth.lower().startswith("bearer ") else ""
|
| 55 |
+
if token != expected:
|
| 56 |
+
raise HTTPException(status_code=401, detail="Invalid metrics token")
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
async def metrics_endpoint(request: Request) -> Response:
|
| 60 |
+
_authorize_metrics(request)
|
| 61 |
return Response(content=generate_latest(), media_type=CONTENT_TYPE_LATEST)
|
backend/app/core/openrouter_client.py
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Shared OpenRouter HTTP client — multi-key rotation + free-model cascade.
|
| 3 |
+
|
| 4 |
+
Designed so many users share AI capacity without exhausting a single key/model:
|
| 5 |
+
1. OPENROUTER_PREFER_FREE=true → try :free models first (no token cost)
|
| 6 |
+
2. Multiple keys (OPENROUTER_API_KEY + OPENROUTER_API_KEYS) rotated on 402/429
|
| 7 |
+
3. Full model cascade per key before moving to the next key
|
| 8 |
+
4. provider.allow_fallbacks lets OpenRouter route to alternate providers
|
| 9 |
+
"""
|
| 10 |
+
from __future__ import annotations
|
| 11 |
+
|
| 12 |
+
import json
|
| 13 |
+
import logging
|
| 14 |
+
import os
|
| 15 |
+
import re
|
| 16 |
+
import threading
|
| 17 |
+
from typing import Any
|
| 18 |
+
|
| 19 |
+
import httpx
|
| 20 |
+
|
| 21 |
+
from app.core.config import settings
|
| 22 |
+
|
| 23 |
+
logger = logging.getLogger(__name__)
|
| 24 |
+
|
| 25 |
+
OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
|
| 26 |
+
|
| 27 |
+
DEFAULT_FREE_MODELS: tuple[str, ...] = (
|
| 28 |
+
"google/gemini-2.0-flash-exp:free",
|
| 29 |
+
"meta-llama/llama-3.3-70b-instruct:free",
|
| 30 |
+
"qwen/qwen-2.5-72b-instruct:free",
|
| 31 |
+
"deepseek/deepseek-r1-distill-llama-70b:free",
|
| 32 |
+
"mistralai/mistral-7b-instruct:free",
|
| 33 |
+
"google/gemma-3-12b-it:free",
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
_RETRYABLE_STATUS = frozenset({402, 404, 429, 503})
|
| 37 |
+
_FALLBACK_MODEL = "anthropic/claude-3-haiku"
|
| 38 |
+
|
| 39 |
+
_key_rr = 0
|
| 40 |
+
_key_lock = threading.Lock()
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
def openrouter_keys() -> list[str]:
|
| 44 |
+
"""Primary key + comma-separated extras (deduped)."""
|
| 45 |
+
out: list[str] = []
|
| 46 |
+
primary = (settings.OPENROUTER_API_KEY or os.getenv("OPENROUTER_API_KEY", "") or "").strip()
|
| 47 |
+
if primary:
|
| 48 |
+
out.append(primary)
|
| 49 |
+
extra = (getattr(settings, "OPENROUTER_API_KEYS", None) or os.getenv("OPENROUTER_API_KEYS", "") or "").strip()
|
| 50 |
+
for k in extra.split(","):
|
| 51 |
+
k = k.strip()
|
| 52 |
+
if k and k not in out:
|
| 53 |
+
out.append(k)
|
| 54 |
+
return out
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def openrouter_key() -> str:
|
| 58 |
+
keys = openrouter_keys()
|
| 59 |
+
return keys[0] if keys else ""
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def has_openrouter_keys() -> bool:
|
| 63 |
+
return bool(openrouter_keys())
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def free_models() -> list[str]:
|
| 67 |
+
raw = (getattr(settings, "OPENROUTER_FREE_MODELS", None) or "").strip()
|
| 68 |
+
if raw:
|
| 69 |
+
return [m.strip() for m in raw.split(",") if m.strip()]
|
| 70 |
+
return list(DEFAULT_FREE_MODELS)
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def prefer_free_models() -> bool:
|
| 74 |
+
val = getattr(settings, "OPENROUTER_PREFER_FREE", None)
|
| 75 |
+
if val is not None:
|
| 76 |
+
return bool(val)
|
| 77 |
+
return settings.ENVIRONMENT == "production"
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def build_model_cascade(preferred: str | None = None, *, prefer_free: bool | None = None) -> list[str]:
|
| 81 |
+
"""Build deduped model list. Free-first when prefer_free=True (default in production)."""
|
| 82 |
+
use_free_first = prefer_free_models() if prefer_free is None else prefer_free
|
| 83 |
+
primary = (
|
| 84 |
+
(preferred or "").strip()
|
| 85 |
+
or (settings.OPENROUTER_FAST_MODEL or "").strip()
|
| 86 |
+
or "google/gemini-2.0-flash-001"
|
| 87 |
+
)
|
| 88 |
+
free = free_models()
|
| 89 |
+
if use_free_first:
|
| 90 |
+
candidates = (*free, primary, _FALLBACK_MODEL)
|
| 91 |
+
else:
|
| 92 |
+
candidates = (primary, *free, _FALLBACK_MODEL)
|
| 93 |
+
out: list[str] = []
|
| 94 |
+
for m in candidates:
|
| 95 |
+
if m and m not in out:
|
| 96 |
+
out.append(m)
|
| 97 |
+
return out
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def _rotate_keys(keys: list[str]) -> list[str]:
|
| 101 |
+
global _key_rr
|
| 102 |
+
if len(keys) <= 1:
|
| 103 |
+
return keys
|
| 104 |
+
with _key_lock:
|
| 105 |
+
start = _key_rr % len(keys)
|
| 106 |
+
_key_rr += 1
|
| 107 |
+
return keys[start:] + keys[:start]
|
| 108 |
+
|
| 109 |
+
|
| 110 |
+
def default_headers(*, title: str = "LinguaIELTS", api_key: str | None = None) -> dict[str, str]:
|
| 111 |
+
key = (api_key or openrouter_key()).strip()
|
| 112 |
+
return {
|
| 113 |
+
"Authorization": f"Bearer {key}",
|
| 114 |
+
"Content-Type": "application/json",
|
| 115 |
+
"HTTP-Referer": settings.FRONTEND_ORIGIN or "http://localhost:5173",
|
| 116 |
+
"X-Title": title,
|
| 117 |
+
}
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
def _should_try_next_model(exc: httpx.HTTPStatusError) -> bool:
|
| 121 |
+
if exc.response.status_code in _RETRYABLE_STATUS:
|
| 122 |
+
return True
|
| 123 |
+
try:
|
| 124 |
+
body = exc.response.json()
|
| 125 |
+
msg = str(body.get("error", {}).get("message", "")).lower()
|
| 126 |
+
if any(k in msg for k in ("quota", "credit", "rate limit", "insufficient", "no endpoints", "overloaded")):
|
| 127 |
+
return True
|
| 128 |
+
except Exception:
|
| 129 |
+
pass
|
| 130 |
+
return False
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def should_retry_model(exc: httpx.HTTPStatusError) -> bool:
|
| 134 |
+
return _should_try_next_model(exc)
|
| 135 |
+
|
| 136 |
+
|
| 137 |
+
def should_try_next_key(exc: httpx.HTTPStatusError) -> bool:
|
| 138 |
+
"""Rotate API key on account-level quota / rate-limit errors."""
|
| 139 |
+
if exc.response.status_code in {402, 429}:
|
| 140 |
+
return True
|
| 141 |
+
return _should_try_next_model(exc)
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
def parse_json_content(raw: str) -> dict[str, Any]:
|
| 145 |
+
"""Extract and parse a JSON object from model output."""
|
| 146 |
+
text = (raw or "").strip()
|
| 147 |
+
if not text:
|
| 148 |
+
raise json.JSONDecodeError("Empty AI response", text, 0)
|
| 149 |
+
|
| 150 |
+
if text.startswith("```"):
|
| 151 |
+
lines = text.split("\n")
|
| 152 |
+
text = "\n".join(lines[1:-1] if lines[-1].strip() == "```" else lines[1:]).strip()
|
| 153 |
+
|
| 154 |
+
try:
|
| 155 |
+
data = json.loads(text)
|
| 156 |
+
if isinstance(data, dict):
|
| 157 |
+
return data
|
| 158 |
+
except json.JSONDecodeError:
|
| 159 |
+
pass
|
| 160 |
+
|
| 161 |
+
match = re.search(r"\{[\s\S]*\}", text)
|
| 162 |
+
if match:
|
| 163 |
+
data = json.loads(match.group(0))
|
| 164 |
+
if isinstance(data, dict):
|
| 165 |
+
return data
|
| 166 |
+
|
| 167 |
+
raise json.JSONDecodeError("No JSON object in AI response", text, 0)
|
| 168 |
+
|
| 169 |
+
|
| 170 |
+
async def chat_completion(
|
| 171 |
+
messages: list[dict[str, str]],
|
| 172 |
+
*,
|
| 173 |
+
model: str | None = None,
|
| 174 |
+
max_tokens: int = 900,
|
| 175 |
+
temperature: float = 0.3,
|
| 176 |
+
timeout: float = 60.0,
|
| 177 |
+
title: str = "LinguaIELTS",
|
| 178 |
+
extra_payload: dict[str, Any] | None = None,
|
| 179 |
+
) -> tuple[str, str]:
|
| 180 |
+
"""
|
| 181 |
+
POST to OpenRouter with key rotation + model cascade.
|
| 182 |
+
|
| 183 |
+
Returns (content, model_used).
|
| 184 |
+
"""
|
| 185 |
+
keys = openrouter_keys()
|
| 186 |
+
if not keys:
|
| 187 |
+
raise RuntimeError("OPENROUTER_API_KEY is not configured")
|
| 188 |
+
|
| 189 |
+
cascade = build_model_cascade(model)
|
| 190 |
+
last_exc: Exception | None = None
|
| 191 |
+
|
| 192 |
+
for api_key in _rotate_keys(keys):
|
| 193 |
+
headers = default_headers(title=title, api_key=api_key)
|
| 194 |
+
key_failed = False
|
| 195 |
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
| 196 |
+
for model_id in cascade:
|
| 197 |
+
payload: dict[str, Any] = {
|
| 198 |
+
"model": model_id,
|
| 199 |
+
"messages": messages,
|
| 200 |
+
"temperature": temperature,
|
| 201 |
+
"max_tokens": max_tokens,
|
| 202 |
+
"provider": {"allow_fallbacks": True},
|
| 203 |
+
}
|
| 204 |
+
if extra_payload:
|
| 205 |
+
payload.update(extra_payload)
|
| 206 |
+
try:
|
| 207 |
+
resp = await client.post(OPENROUTER_URL, json=payload, headers=headers)
|
| 208 |
+
resp.raise_for_status()
|
| 209 |
+
content = resp.json()["choices"][0]["message"]["content"]
|
| 210 |
+
logger.debug("OpenRouter OK model=%s key=...%s", model_id, api_key[-6:])
|
| 211 |
+
return content, model_id
|
| 212 |
+
except httpx.HTTPStatusError as exc:
|
| 213 |
+
last_exc = exc
|
| 214 |
+
if should_try_next_key(exc) and len(keys) > 1:
|
| 215 |
+
logger.warning(
|
| 216 |
+
"OpenRouter key ...%s exhausted (%s), rotating key",
|
| 217 |
+
api_key[-6:],
|
| 218 |
+
exc.response.status_code,
|
| 219 |
+
)
|
| 220 |
+
key_failed = True
|
| 221 |
+
break
|
| 222 |
+
if _should_try_next_model(exc) and model_id != cascade[-1]:
|
| 223 |
+
logger.warning(
|
| 224 |
+
"OpenRouter model %s failed (%s), trying next model",
|
| 225 |
+
model_id,
|
| 226 |
+
exc.response.status_code,
|
| 227 |
+
)
|
| 228 |
+
continue
|
| 229 |
+
raise
|
| 230 |
+
if not key_failed:
|
| 231 |
+
break
|
| 232 |
+
|
| 233 |
+
raise last_exc or RuntimeError("OpenRouter request failed")
|
| 234 |
+
|
| 235 |
+
|
| 236 |
+
async def chat_completion_json(
|
| 237 |
+
messages: list[dict[str, str]],
|
| 238 |
+
*,
|
| 239 |
+
model: str | None = None,
|
| 240 |
+
max_tokens: int = 900,
|
| 241 |
+
temperature: float = 0.15,
|
| 242 |
+
timeout: float = 60.0,
|
| 243 |
+
title: str = "LinguaIELTS",
|
| 244 |
+
) -> tuple[dict[str, Any], str]:
|
| 245 |
+
raw, used = await chat_completion(
|
| 246 |
+
messages,
|
| 247 |
+
model=model,
|
| 248 |
+
max_tokens=max_tokens,
|
| 249 |
+
temperature=temperature,
|
| 250 |
+
timeout=timeout,
|
| 251 |
+
title=title,
|
| 252 |
+
)
|
| 253 |
+
return parse_json_content(raw), used
|
backend/app/core/password_policy.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Password strength checks shared by register and reset flows."""
|
| 2 |
+
|
| 3 |
+
from fastapi import HTTPException, status
|
| 4 |
+
|
| 5 |
+
# Small blocklist — extend as needed; not a substitute for breached-password API.
|
| 6 |
+
_COMMON_PASSWORDS = frozenset(
|
| 7 |
+
{
|
| 8 |
+
"password",
|
| 9 |
+
"password1",
|
| 10 |
+
"password123",
|
| 11 |
+
"123456",
|
| 12 |
+
"12345678",
|
| 13 |
+
"123456789",
|
| 14 |
+
"1234567890",
|
| 15 |
+
"qwerty",
|
| 16 |
+
"qwerty123",
|
| 17 |
+
"admin123",
|
| 18 |
+
"letmein",
|
| 19 |
+
"welcome",
|
| 20 |
+
"iloveyou",
|
| 21 |
+
"monkey",
|
| 22 |
+
"dragon",
|
| 23 |
+
"football",
|
| 24 |
+
"baseball",
|
| 25 |
+
"abc123",
|
| 26 |
+
"111111",
|
| 27 |
+
"000000",
|
| 28 |
+
"changeme",
|
| 29 |
+
"secret",
|
| 30 |
+
"master",
|
| 31 |
+
"login",
|
| 32 |
+
"passw0rd",
|
| 33 |
+
"P@ssw0rd",
|
| 34 |
+
"P@ssword1",
|
| 35 |
+
}
|
| 36 |
+
)
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
def assert_password_strength(password: str) -> None:
|
| 40 |
+
"""Raise HTTP 400 when password is too weak or on the common-password list."""
|
| 41 |
+
if len(password) < 10:
|
| 42 |
+
raise HTTPException(
|
| 43 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 44 |
+
detail="Mật khẩu phải có ít nhất 10 ký tự.",
|
| 45 |
+
)
|
| 46 |
+
if password.lower() in _COMMON_PASSWORDS:
|
| 47 |
+
raise HTTPException(
|
| 48 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 49 |
+
detail="Mật khẩu quá phổ biến. Vui lòng chọn mật khẩu khác.",
|
| 50 |
+
)
|
| 51 |
+
if password.isdigit():
|
| 52 |
+
raise HTTPException(
|
| 53 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 54 |
+
detail="Mật khẩu không được chỉ gồm chữ số.",
|
| 55 |
+
)
|
backend/app/core/rate_limit.py
CHANGED
|
@@ -1,10 +1,11 @@
|
|
| 1 |
-
"""Rate limiting (slowapi)
|
|
|
|
|
|
|
| 2 |
|
| 3 |
from fastapi import Request
|
| 4 |
from fastapi.responses import JSONResponse
|
| 5 |
from slowapi import Limiter
|
| 6 |
from slowapi.errors import RateLimitExceeded
|
| 7 |
-
from slowapi.util import get_remote_address
|
| 8 |
|
| 9 |
from app.core.config import settings
|
| 10 |
|
|
@@ -14,8 +15,35 @@ _storage_uri = (
|
|
| 14 |
if settings.ENVIRONMENT == "production" and settings.REDIS_URL
|
| 15 |
else None
|
| 16 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 17 |
limiter = Limiter(
|
| 18 |
-
key_func=
|
| 19 |
storage_uri=_storage_uri,
|
| 20 |
)
|
| 21 |
|
|
@@ -23,5 +51,5 @@ limiter = Limiter(
|
|
| 23 |
async def rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded):
|
| 24 |
return JSONResponse(
|
| 25 |
status_code=429,
|
| 26 |
-
content={"detail": "
|
| 27 |
)
|
|
|
|
| 1 |
+
"""Rate limiting (slowapi) shared limiter and 429 handler."""
|
| 2 |
+
|
| 3 |
+
from ipaddress import ip_address, ip_network
|
| 4 |
|
| 5 |
from fastapi import Request
|
| 6 |
from fastapi.responses import JSONResponse
|
| 7 |
from slowapi import Limiter
|
| 8 |
from slowapi.errors import RateLimitExceeded
|
|
|
|
| 9 |
|
| 10 |
from app.core.config import settings
|
| 11 |
|
|
|
|
| 15 |
if settings.ENVIRONMENT == "production" and settings.REDIS_URL
|
| 16 |
else None
|
| 17 |
)
|
| 18 |
+
|
| 19 |
+
_TRUSTED_PROXY_NETS = (
|
| 20 |
+
ip_network("127.0.0.0/8"),
|
| 21 |
+
ip_network("10.0.0.0/8"),
|
| 22 |
+
ip_network("172.16.0.0/12"),
|
| 23 |
+
ip_network("192.168.0.0/16"),
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def _client_ip(request: Request) -> str:
|
| 28 |
+
peer = request.client.host if request.client else ""
|
| 29 |
+
try:
|
| 30 |
+
peer_ip = ip_address(peer)
|
| 31 |
+
except ValueError:
|
| 32 |
+
return peer or "unknown"
|
| 33 |
+
|
| 34 |
+
if any(peer_ip in net for net in _TRUSTED_PROXY_NETS):
|
| 35 |
+
forwarded = request.headers.get("x-forwarded-for", "")
|
| 36 |
+
first = forwarded.split(",", 1)[0].strip()
|
| 37 |
+
if first:
|
| 38 |
+
try:
|
| 39 |
+
return str(ip_address(first))
|
| 40 |
+
except ValueError:
|
| 41 |
+
pass
|
| 42 |
+
return str(peer_ip)
|
| 43 |
+
|
| 44 |
+
|
| 45 |
limiter = Limiter(
|
| 46 |
+
key_func=_client_ip,
|
| 47 |
storage_uri=_storage_uri,
|
| 48 |
)
|
| 49 |
|
|
|
|
| 51 |
async def rate_limit_exceeded_handler(request: Request, exc: RateLimitExceeded):
|
| 52 |
return JSONResponse(
|
| 53 |
status_code=429,
|
| 54 |
+
content={"detail": "Qua nhieu yeu cau. Vui long thu lai sau."},
|
| 55 |
)
|
backend/app/core/upload.py
CHANGED
|
@@ -6,6 +6,9 @@ from fastapi import HTTPException, UploadFile
|
|
| 6 |
|
| 7 |
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
| 8 |
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
|
|
|
|
|
|
|
|
|
|
| 9 |
EXTENSION_MAP = {
|
| 10 |
"image/jpeg": ".jpg",
|
| 11 |
"image/png": ".png",
|
|
@@ -13,6 +16,24 @@ EXTENSION_MAP = {
|
|
| 13 |
}
|
| 14 |
|
| 15 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 16 |
async def validate_and_read_image(file: UploadFile) -> tuple[bytes, str]:
|
| 17 |
"""Validate upload. Returns (content_bytes, safe_filename)."""
|
| 18 |
if file.content_type not in ALLOWED_IMAGE_TYPES:
|
|
@@ -20,7 +41,7 @@ async def validate_and_read_image(file: UploadFile) -> tuple[bytes, str]:
|
|
| 20 |
status_code=400,
|
| 21 |
detail=f"Chỉ chấp nhận JPEG, PNG, WebP. Nhận: {file.content_type}",
|
| 22 |
)
|
| 23 |
-
content = await
|
| 24 |
if len(content) > MAX_AVATAR_SIZE:
|
| 25 |
raise HTTPException(
|
| 26 |
status_code=400,
|
|
|
|
| 6 |
|
| 7 |
ALLOWED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp"}
|
| 8 |
MAX_AVATAR_SIZE = 2 * 1024 * 1024 # 2MB
|
| 9 |
+
MAX_ADMIN_IMAGE_SIZE = 5 * 1024 * 1024 # 5MB
|
| 10 |
+
MAX_AUDIO_UPLOAD_SIZE = 15 * 1024 * 1024 # 15MB
|
| 11 |
+
ALLOWED_AUDIO_EXTENSIONS = {".wav", ".webm", ".m4a", ".mp3", ".ogg"}
|
| 12 |
EXTENSION_MAP = {
|
| 13 |
"image/jpeg": ".jpg",
|
| 14 |
"image/png": ".png",
|
|
|
|
| 16 |
}
|
| 17 |
|
| 18 |
|
| 19 |
+
async def read_upload_limited(file: UploadFile, max_size: int) -> bytes:
|
| 20 |
+
"""Read an UploadFile without allowing unbounded memory growth."""
|
| 21 |
+
chunks: list[bytes] = []
|
| 22 |
+
total = 0
|
| 23 |
+
while True:
|
| 24 |
+
chunk = await file.read(1024 * 1024)
|
| 25 |
+
if not chunk:
|
| 26 |
+
break
|
| 27 |
+
total += len(chunk)
|
| 28 |
+
if total > max_size:
|
| 29 |
+
raise HTTPException(
|
| 30 |
+
status_code=413,
|
| 31 |
+
detail=f"File too large. Maximum size is {max_size // (1024 * 1024)}MB.",
|
| 32 |
+
)
|
| 33 |
+
chunks.append(chunk)
|
| 34 |
+
return b"".join(chunks)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
async def validate_and_read_image(file: UploadFile) -> tuple[bytes, str]:
|
| 38 |
"""Validate upload. Returns (content_bytes, safe_filename)."""
|
| 39 |
if file.content_type not in ALLOWED_IMAGE_TYPES:
|
|
|
|
| 41 |
status_code=400,
|
| 42 |
detail=f"Chỉ chấp nhận JPEG, PNG, WebP. Nhận: {file.content_type}",
|
| 43 |
)
|
| 44 |
+
content = await read_upload_limited(file, MAX_AVATAR_SIZE)
|
| 45 |
if len(content) > MAX_AVATAR_SIZE:
|
| 46 |
raise HTTPException(
|
| 47 |
status_code=400,
|
backend/app/data/translation_seed.py
ADDED
|
@@ -0,0 +1,319 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Translation Practice seed data.
|
| 3 |
+
Progressive curriculum: Band 5.0 → Band 8.0+
|
| 4 |
+
"""
|
| 5 |
+
from __future__ import annotations
|
| 6 |
+
|
| 7 |
+
TRANSLATION_SEED: list[dict] = [
|
| 8 |
+
# ────────────────────────────────────────────────────────────────────────
|
| 9 |
+
# BƯỚC 1 – Cấu trúc câu cơ bản
|
| 10 |
+
# ────────────────────────────────────────────────────────────────────────
|
| 11 |
+
{
|
| 12 |
+
"title": "Cấu trúc câu cơ bản",
|
| 13 |
+
"description": "Luyện dịch các câu đơn từ Việt sang Anh. Tập trung vào việc sử dụng đúng thì, mạo từ và cấu trúc câu S-V-O cơ bản.",
|
| 14 |
+
"badge_label": None,
|
| 15 |
+
"badge_color": "gray",
|
| 16 |
+
"icon_emoji": "✏️",
|
| 17 |
+
"topics": [
|
| 18 |
+
{
|
| 19 |
+
"title": "Hiện tại đơn (Simple Present)",
|
| 20 |
+
"description": "Diễn tả thói quen, sự thật hiển nhiên, lịch trình cố định.",
|
| 21 |
+
"sentences": [
|
| 22 |
+
{"vi": "Tôi học tiếng Anh mỗi ngày.", "en": "I study English every day.", "explain": "Dùng Simple Present với 'every day'. Với 'I' không thêm -s/-es vào động từ."},
|
| 23 |
+
{"vi": "Cô ấy dạy toán ở trường tiểu học.", "en": "She teaches Mathematics at a primary school.", "explain": "'She' → thêm -es vào 'teach'. 'Mathematics' viết hoa."},
|
| 24 |
+
{"vi": "Họ không thích ăn đồ ăn cay.", "en": "They do not like eating spicy food.", "explain": "Phủ định dùng 'do not' với they/we/I/you."},
|
| 25 |
+
{"vi": "Anh ấy thường đi làm bằng xe buýt.", "en": "He usually goes to work by bus.", "explain": "'He' → goes (thêm -es). 'usually' đặt trước động từ chính."},
|
| 26 |
+
{"vi": "Chúng tôi sống ở Hà Nội.", "en": "We live in Hanoi.", "explain": "Dùng 'in' cho thành phố, không dùng 'at'."},
|
| 27 |
+
{"vi": "Con mèo ngủ trên ghế sofa mỗi buổi chiều.", "en": "The cat sleeps on the sofa every afternoon.", "explain": "Dùng 'the' cho danh từ cụ thể đã biết. 'on' cho bề mặt."},
|
| 28 |
+
{"vi": "Em gái tôi chơi đàn piano rất hay.", "en": "My younger sister plays the piano very well.", "explain": "Nhạc cụ dùng với mạo từ 'the'. 'very well' là trạng từ."},
|
| 29 |
+
{"vi": "Nhà máy này sản xuất hàng nghìn sản phẩm mỗi năm.", "en": "This factory produces thousands of products every year.", "explain": "'thousands of' = hàng nghìn. 'products' số nhiều."},
|
| 30 |
+
{"vi": "Anh ấy không bao giờ uống cà phê vào buổi tối.", "en": "He never drinks coffee in the evening.", "explain": "'never' đặt trước động từ chính. 'in the evening' dùng 'the'."},
|
| 31 |
+
{"vi": "Giáo viên luôn giải thích bài học một cách rõ ràng.", "en": "The teacher always explains the lesson clearly.", "explain": "'always' đặt trước động từ chính. 'clearly' = trạng từ."},
|
| 32 |
+
],
|
| 33 |
+
},
|
| 34 |
+
{
|
| 35 |
+
"title": "Hiện tại tiếp diễn (Present Continuous)",
|
| 36 |
+
"description": "Diễn tả hành động đang xảy ra tại thời điểm nói hoặc xu hướng hiện tại.",
|
| 37 |
+
"sentences": [
|
| 38 |
+
{"vi": "Tôi đang học bài cho kỳ thi tuần tới.", "en": "I am studying for next week's exam.", "explain": "am/is/are + V-ing. 'next week's exam' dùng sở hữu cách."},
|
| 39 |
+
{"vi": "Cô ấy đang nấu bữa tối trong bếp.", "en": "She is cooking dinner in the kitchen.", "explain": "'is cooking' = đang nấu. 'in the kitchen' dùng 'in'."},
|
| 40 |
+
{"vi": "Họ đang xây dựng một tòa nhà mới ở trung tâm thành phố.", "en": "They are building a new building in the city centre.", "explain": "'a new building' dùng mạo từ 'a'. 'city centre' = trung tâm."},
|
| 41 |
+
{"vi": "Tại sao em lại khóc vậy?", "en": "Why are you crying?", "explain": "Câu hỏi Present Continuous: Why + are/is/am + S + V-ing?"},
|
| 42 |
+
{"vi": "Trời đang mưa rất to.", "en": "It is raining heavily.", "explain": "Thời tiết dùng 'It'. 'heavily' = to, mạnh."},
|
| 43 |
+
{"vi": "Công ty chúng tôi đang mở rộng sang thị trường châu Á.", "en": "Our company is expanding into the Asian market.", "explain": "'expand into' = mở rộng sang. 'Asian market' viết hoa."},
|
| 44 |
+
{"vi": "Thế giới đang thay đổi rất nhanh nhờ vào công nghệ.", "en": "The world is changing very rapidly due to technology.", "explain": "'due to' = nhờ vào / do. 'rapidly' = nhanh chóng."},
|
| 45 |
+
{"vi": "Nhiều ng��ời trẻ đang chọn làm việc từ xa thay vì đến văn phòng.", "en": "Many young people are choosing to work remotely rather than come to the office.", "explain": "'rather than' = thay vì. 'remotely' = từ xa."},
|
| 46 |
+
],
|
| 47 |
+
},
|
| 48 |
+
{
|
| 49 |
+
"title": "Hiện tại hoàn thành (Present Perfect)",
|
| 50 |
+
"description": "Diễn tả hành động đã xảy ra với kết quả còn liên quan đến hiện tại.",
|
| 51 |
+
"sentences": [
|
| 52 |
+
{"vi": "Tôi đã học tiếng Anh được ba năm rồi.", "en": "I have studied English for three years.", "explain": "have/has + V3. 'for + khoảng thời gian'."},
|
| 53 |
+
{"vi": "Cô ấy vừa mới hoàn thành luận văn tiến sĩ.", "en": "She has just completed her doctoral thesis.", "explain": "'just' đặt giữa have và V3. 'doctoral thesis' = luận văn TS."},
|
| 54 |
+
{"vi": "Họ chưa bao giờ đến thăm Việt Nam.", "en": "They have never visited Vietnam.", "explain": "'never' đặt giữa have và V3 khi dùng Present Perfect."},
|
| 55 |
+
{"vi": "Anh ấy đã sống ở nước ngoài kể từ năm 2020.", "en": "He has lived abroad since 2020.", "explain": "'since + mốc thời gian'. 'abroad' = ở nước ngoài."},
|
| 56 |
+
{"vi": "Chính phủ đã ban hành nhiều chính sách mới để bảo vệ môi trường.", "en": "The government has issued many new policies to protect the environment.", "explain": "'issue policies' = ban hành chính sách. 'to protect' = để bảo vệ."},
|
| 57 |
+
{"vi": "Bao nhiêu lần bạn đã đọc cuốn sách này?", "en": "How many times have you read this book?", "explain": "Câu hỏi PP: How many times + have/has + S + V3?"},
|
| 58 |
+
{"vi": "Khoa học đã đạt được nhiều tiến bộ đáng kể trong thập kỷ qua.", "en": "Science has made significant progress in the past decade.", "explain": "'make progress' = đạt tiến bộ. 'past decade' = thập kỷ qua."},
|
| 59 |
+
{"vi": "Họ đã hoàn thành dự án trước thời hạn.", "en": "They have completed the project ahead of schedule.", "explain": "'ahead of schedule' = trước thời hạn (cụm cố định)."},
|
| 60 |
+
],
|
| 61 |
+
},
|
| 62 |
+
{
|
| 63 |
+
"title": "Quá khứ đơn (Simple Past)",
|
| 64 |
+
"description": "Diễn tả hành động đã hoàn tất trong quá khứ tại một thời điểm xác định.",
|
| 65 |
+
"sentences": [
|
| 66 |
+
{"vi": "Tôi đã đến Hà Nội vào năm ngoái.", "en": "I went to Hanoi last year.", "explain": "'went' = quá khứ bất quy tắc của 'go'. 'last year' xác định thời điểm."},
|
| 67 |
+
{"vi": "Cô ấy đã tốt nghiệp đại học năm 2022.", "en": "She graduated from university in 2022.", "explain": "'graduate from' = tốt nghiệp từ. 'in 2022' = năm cụ thể."},
|
| 68 |
+
{"vi": "Chúng tôi đã gặp nhau lần đầu tại một hội nghị quốc tế.", "en": "We met for the first time at an international conference.", "explain": "'met' = quá khứ bất quy tắc của 'meet'. 'for the first time' = lần đầu."},
|
| 69 |
+
{"vi": "Anh ấy không đến buổi họp hôm qua vì bị ốm.", "en": "He did not attend the meeting yesterday because he was ill.", "explain": "Phủ định quá khứ: did not + V nguyên thể. 'ill' = bệnh (formal hơn 'sick')."},
|
| 70 |
+
{"vi": "Công ty đó được thành lập vào năm 1995.", "en": "That company was founded in 1995.", "explain": "Bị động quá khứ: was/were + V3. 'found → founded'."},
|
| 71 |
+
{"vi": "Trận động đất xảy ra vào lúc 3 giờ sáng.", "en": "The earthquake occurred at 3 o'clock in the morning.", "explain": "'occur' = xảy ra (formal). 'at' dùng với giờ cụ thể."},
|
| 72 |
+
{"vi": "Chính sách mới được thông qua sau nhiều tháng tranh luận.", "en": "The new policy was passed after months of debate.", "explain": "'pass a policy' = thông qua chính sách. 'months of debate' = nhiều tháng."},
|
| 73 |
+
{"vi": "Cô ấy đã cảm thấy rất hồi hộp trước buổi thuyết trình đó.", "en": "She felt very nervous before that presentation.", "explain": "'felt' = quá khứ bất quy tắc của 'feel'. 'nervous' = hồi hộp, lo lắng."},
|
| 74 |
+
],
|
| 75 |
+
},
|
| 76 |
+
{
|
| 77 |
+
"title": "Câu bị động (Passive Voice)",
|
| 78 |
+
"description": "Dùng khi muốn nhấn mạnh đối tượng bị tác động thay vì chủ thể hành động.",
|
| 79 |
+
"sentences": [
|
| 80 |
+
{"vi": "Bức thư này được viết bằng tiếng Pháp.", "en": "This letter is written in French.", "explain": "Bị động hiện tại: is/are + V3. 'in French' = bằng tiếng Pháp."},
|
| 81 |
+
{"vi": "Tòa nhà đó đã được xây dựng vào thế kỷ 19.", "en": "That building was constructed in the 19th century.", "explain": "Bị động quá khứ: was/were + V3. '19th century' viết tắt đúng."},
|
| 82 |
+
{"vi": "Nhiều cây xanh đang bị chặt phá để xây dựng khu đô thị mới.", "en": "Many trees are being cut down to build new residential areas.", "explain": "Bị động tiếp diễn: are being + V3. 'cut down' = chặt phá."},
|
| 83 |
+
{"vi": "Bộ phim này đã được trao giải Oscar.", "en": "This film has been awarded an Oscar.", "explain": "Bị động hoàn thành: has/have been + V3. 'award' = trao."},
|
| 84 |
+
{"vi": "Rác thải nhựa cần được xử lý đúng cách để bảo vệ đại dương.", "en": "Plastic waste needs to be disposed of properly to protect the oceans.", "explain": "'dispose of' = xử lý (phrasal verb). 'properly' = đúng cách."},
|
| 85 |
+
{"vi": "Thuốc này phải được bảo quản ở nhiệt độ thấp.", "en": "This medicine must be stored at a low temperature.", "explain": "Modal passive: must be + V3. 'stored at' = bảo quản ở."},
|
| 86 |
+
{"vi": "Kết quả thi sẽ được công bố vào tuần tới.", "en": "The exam results will be announced next week.", "explain": "Bị động tương lai: will be + V3. 'announce' = công bố."},
|
| 87 |
+
{"vi": "Hệ thống giao thông đang được hiện đại hóa ở nhiều thành phố lớn.", "en": "The transportation system is being modernised in many major cities.", "explain": "'modernise' = hiện đại hóa (British spelling). 'major cities' = thành phố lớn."},
|
| 88 |
+
],
|
| 89 |
+
},
|
| 90 |
+
{
|
| 91 |
+
"title": "Câu so sánh (Comparison)",
|
| 92 |
+
"description": "So sánh hơn, so sánh nhất và so sánh bằng — nền tảng cho IELTS Task 1.",
|
| 93 |
+
"sentences": [
|
| 94 |
+
{"vi": "Đọc sách có ích hơn xem tivi.", "en": "Reading books is more beneficial than watching television.", "explain": "'more + adj + than' cho tính từ dài. 'beneficial' = có ích."},
|
| 95 |
+
{"vi": "Hà Nội đông dân hơn Đà Nẵng nhiều.", "en": "Hanoi is much more densely populated than Da Nang.", "explain": "'much more' nhấn mạnh mức độ hơn. 'densely populated' = đông dân."},
|
| 96 |
+
{"vi": "Học trực tuyến là phương pháp tiện lợi nhất hiện nay.", "en": "Online learning is the most convenient method available today.", "explain": "'the most + adj' = so sánh nhất. 'available' bổ nghĩa cho 'method'."},
|
| 97 |
+
{"vi": "Tình trạng ô nhiễm không khí ngày càng nghiêm trọng hơn.", "en": "Air pollution is becoming increasingly serious.", "explain": "'becoming increasingly + adj' = ngày càng. Không cần 'than' ở đây."},
|
| 98 |
+
{"vi": "Không có gì quý giá hơn sức khỏe.", "en": "Nothing is more valuable than health.", "explain": "'Nothing is more + adj than' = cấu trúc nhấn mạnh so sánh hơn."},
|
| 99 |
+
{"vi": "Phương pháp này đơn giản hơn nhưng kém hiệu quả hơn.", "en": "This method is simpler but less effective.", "explain": "'less + adj' = kém hơn. 'simpler' = tính từ ngắn thêm -er."},
|
| 100 |
+
{"vi": "Công nghệ ngày nay tiên tiến hơn nhiều so với hai mươi năm trước.", "en": "Technology today is far more advanced than it was twenty years ago.", "explain": "'far more' nhấn mạnh mức độ. 'than it was' = so với khi đó."},
|
| 101 |
+
{"vi": "Kỹ năng mềm quan trọng không kém gì kiến thức chuyên môn.", "en": "Soft skills are just as important as professional knowledge.", "explain": "'just as + adj + as' = so sánh bằng. 'soft skills' = kỹ năng mềm."},
|
| 102 |
+
],
|
| 103 |
+
},
|
| 104 |
+
{
|
| 105 |
+
"title": "Mệnh đề quan hệ (Relative Clauses)",
|
| 106 |
+
"description": "Bổ nghĩa cho danh từ bằng who, which, that, where, whose — rất quan trọng trong IELTS.",
|
| 107 |
+
"sentences": [
|
| 108 |
+
{"vi": "Người phụ nữ mà bạn vừa gặp là giám đốc công ty.", "en": "The woman whom you just met is the director of the company.", "explain": "'whom' = who (formal) cho tân ngữ. 'director of' = giám đốc của."},
|
| 109 |
+
{"vi": "Đây là thành phố nơi tôi sinh ra.", "en": "This is the city where I was born.", "explain": "'where' bổ nghĩa cho địa danh. 'was born' = bị động quá khứ."},
|
| 110 |
+
{"vi": "Những học sinh đạt điểm cao sẽ nhận học bổng.", "en": "Students who achieve high scores will receive scholarships.", "explain": "'who' bổ nghĩa cho người. 'scholarships' = học bổng (số nhiều)."},
|
| 111 |
+
{"vi": "Đây là giải pháp mà các chuyên gia khuyến nghị.", "en": "This is the solution that experts recommend.", "explain": "'that' cho người hoặc vật (informal). 'recommend' = khuyến nghị."},
|
| 112 |
+
{"vi": "Lý do tại sao anh ấy từ chối lời mời vẫn còn là bí ẩn.", "en": "The reason why he declined the invitation remains a mystery.", "explain": "'the reason why' = lý do tại sao. 'decline' = từ chối (formal)."},
|
| 113 |
+
{"vi": "Cuốn sách mà tôi đang đọc rất thú vị.", "en": "The book that I am reading is very interesting.", "explain": "Mệnh đề quan hệ xác định (defining RC) không có dấu phẩy."},
|
| 114 |
+
{"vi": "Nhà khoa học này, người đã phát minh ra vắc-xin, đã đoạt giải Nobel.", "en": "This scientist, who invented the vaccine, was awarded the Nobel Prize.", "explain": "Mệnh đề quan hệ không xác định (non-defining) có dấu phẩy."},
|
| 115 |
+
{"vi": "Những quốc gia có nền giáo dục tốt thường có mức sống cao hơn.", "en": "Countries whose education systems are strong tend to have higher living standards.", "explain": "'whose' = có (sở hữu). 'tend to' = có xu hướng."},
|
| 116 |
+
],
|
| 117 |
+
},
|
| 118 |
+
{
|
| 119 |
+
"title": "Câu điều kiện (Conditionals)",
|
| 120 |
+
"description": "If-clauses loại 0, 1, 2, 3 — thiết yếu cho IELTS Writing Task 2.",
|
| 121 |
+
"sentences": [
|
| 122 |
+
{"vi": "Nếu bạn chăm chỉ học, bạn sẽ đạt điểm cao.", "en": "If you study hard, you will achieve high scores.", "explain": "Loại 1: If + present simple, will + V. Khả năng có thể xảy ra."},
|
| 123 |
+
{"vi": "Nếu chúng ta không hành động ngay bây giờ, ô nhiễm sẽ trở nên tồi tệ hơn.", "en": "If we do not act now, pollution will become worse.", "explain": "Loại 1. 'act now' = hành động ngay. 'become worse' = trở nên tồi tệ."},
|
| 124 |
+
{"vi": "Nếu tôi giàu hơn, tôi sẽ du lịch vòng quanh thế giới.", "en": "If I were richer, I would travel around the world.", "explain": "Loại 2: If + were/V-ed, would + V. Tình huống không có thật ở hiện tại."},
|
| 125 |
+
{"vi": "Nếu chính phủ đầu tư nhiều hơn vào giáo dục, chất lượng cuộc sống sẽ được cải thiện.", "en": "If the government invested more in education, the quality of life would improve.", "explain": "Loại 2. 'invest in' = đầu tư vào. 'quality of life' = chất lượng cuộc sống."},
|
| 126 |
+
{"vi": "Nếu anh ấy đã học chăm hơn, anh ấy đã không thi trượt.", "en": "If he had studied harder, he would not have failed the exam.", "explain": "Loại 3: If + had V3, would have V3. Hối tiếc về quá khứ."},
|
| 127 |
+
{"vi": "Trừ khi chúng ta giảm lượng khí thải carbon, biến đổi khí hậu sẽ tiếp tục leo thang.", "en": "Unless we reduce carbon emissions, climate change will continue to escalate.", "explain": "'Unless' = if not. 'carbon emissions' = khí thải carbon. 'escalate' = leo thang."},
|
| 128 |
+
{"vi": "Miễn là bạn có quyết tâm, bạn có thể đạt được bất kỳ mục tiêu nào.", "en": "As long as you have determination, you can achieve any goal.", "explain": "'As long as' = miễn là (điều kiện). 'determination' = quyết tâm."},
|
| 129 |
+
{"vi": "Giả sử chính sách này được thực thi, nền kinh tế sẽ phục hồi nhanh hơn.", "en": "Supposing this policy were implemented, the economy would recover more quickly.", "explain": "'Supposing' = giả sử (formal). Loại 2. 'implement' = thực thi."},
|
| 130 |
+
],
|
| 131 |
+
},
|
| 132 |
+
],
|
| 133 |
+
},
|
| 134 |
+
|
| 135 |
+
# ────────────────────────────────────────────────────────────────────────
|
| 136 |
+
# BƯỚC 2 – Collocations & Từ vựng học thuật
|
| 137 |
+
# ────────────────────────────────────────────────────────────────────────
|
| 138 |
+
{
|
| 139 |
+
"title": "Collocations & Từ vựng học thuật",
|
| 140 |
+
"description": "Dịch các câu sử dụng collocations IELTS phổ biến. Nắm vững các cụm từ này giúp bài thi đạt Band 7+.",
|
| 141 |
+
"badge_label": "VOCAB",
|
| 142 |
+
"badge_color": "blue",
|
| 143 |
+
"icon_emoji": "📚",
|
| 144 |
+
"topics": [
|
| 145 |
+
{
|
| 146 |
+
"title": "Collocations: Giáo dục (Education)",
|
| 147 |
+
"description": "Các cụm từ thiết yếu về chủ đề Giáo dục cho IELTS.",
|
| 148 |
+
"sentences": [
|
| 149 |
+
{"vi": "Giáo dục đóng vai trò then chốt trong việc xây dựng một xã hội văn minh.", "en": "Education plays a pivotal role in building a civilised society.", "explain": "'play a pivotal role' = đóng vai trò then chốt (collocation mạnh)."},
|
| 150 |
+
{"vi": "Nhiều học sinh phải vật lộn với áp lực học tập trong suốt những năm học phổ thông.", "en": "Many students struggle with academic pressure throughout their secondary school years.", "explain": "'struggle with' = vật lộn với. 'academic pressure' = áp lực học tập."},
|
| 151 |
+
{"vi": "Tư duy phản biện là kỹ năng thiết yếu mà hệ thống giáo dục cần bồi dưỡng.", "en": "Critical thinking is an essential skill that the education system needs to cultivate.", "explain": "'critical thinking' = tư duy phản biện. 'cultivate' = bồi dưỡng/nuôi dưỡng."},
|
| 152 |
+
{"vi": "Việc học ngoại ngữ từ sớm mang lại lợi ích lớn cho sự phát triển nhận thức của trẻ.", "en": "Learning foreign languages from an early age brings significant benefits to children's cognitive development.", "explain": "'cognitive development' = phát triển nhận thức. 'from an early age' = từ sớm."},
|
| 153 |
+
{"vi": "Chính sách giáo dục toàn diện cần đáp ứng nhu cầu của tất cả học sinh, kể cả người khuyết tật.", "en": "Inclusive education policies need to address the needs of all students, including those with disabilities.", "explain": "'inclusive education' = giáo dục toàn diện. 'address needs' = đáp ứng nhu cầu."},
|
| 154 |
+
{"vi": "Sự chênh lệch về chất lượng giáo dục giữa thành thị và nông thôn cần được thu hẹp.", "en": "The disparity in educational quality between urban and rural areas needs to be narrowed.", "explain": "'disparity' = sự chênh lệch (academic). 'narrowed' = thu hẹp."},
|
| 155 |
+
{"vi": "Các trường đại học nên trang bị cho sinh viên những kỹ năng thực tiễn phù hợp với yêu cầu thị trường lao động.", "en": "Universities should equip students with practical skills relevant to the demands of the labour market.", "explain": "'equip with' = trang bị. 'relevant to' = phù hợp với. 'labour market' = thị trường lao động."},
|
| 156 |
+
{"vi": "Học tập suốt đời là yếu tố quan trọng để thích nghi với những thay đổi nhanh chóng của xã hội.", "en": "Lifelong learning is an essential factor in adapting to the rapid changes in society.", "explain": "'lifelong learning' = học tập suốt đời. 'adapt to' = thích nghi với."},
|
| 157 |
+
],
|
| 158 |
+
},
|
| 159 |
+
{
|
| 160 |
+
"title": "Collocations: Môi trường (Environment)",
|
| 161 |
+
"description": "Các cụm từ quan trọng về chủ đề Môi trường — chủ đề xuất hiện thường xuyên trong IELTS.",
|
| 162 |
+
"sentences": [
|
| 163 |
+
{"vi": "Biến đổi khí hậu là mối đe dọa nghiêm trọng nhất đối với sự tồn tại của nhân loại.", "en": "Climate change poses the most serious threat to the survival of humanity.", "explain": "'pose a threat' = đặt ra mối đe dọa. 'survival of humanity' = sự tồn tại của nhân loại."},
|
| 164 |
+
{"vi": "Nạn phá rừng đang phá hủy các hệ sinh thái quý giá và đẩy nhanh quá trình mất đa dạng sinh học.", "en": "Deforestation is destroying precious ecosystems and accelerating the loss of biodiversity.", "explain": "'deforestation' = nạn phá rừng. 'biodiversity' = đa dạng sinh học. 'accelerate' = đẩy nhanh."},
|
| 165 |
+
{"vi": "Năng lượng tái tạo đang dần thay thế nhiên liệu hóa thạch ở nhiều quốc gia.", "en": "Renewable energy is gradually replacing fossil fuels in many countries.", "explain": "'renewable energy' = năng lượng tái tạo. 'fossil fuels' = nhiên liệu hóa thạch."},
|
| 166 |
+
{"vi": "Phát triển bền vững đòi hỏi sự cân bằng giữa tăng trưởng kinh tế và bảo tồn thiên nhiên.", "en": "Sustainable development requires a balance between economic growth and the conservation of nature.", "explain": "'sustainable development' = phát triển bền vững. 'conservation' = bảo tồn."},
|
| 167 |
+
{"vi": "Các chính phủ cần áp đặt các quy định nghiêm ngặt hơn đối với các ngành công nghiệp gây ô nhiễm.", "en": "Governments need to impose stricter regulations on polluting industries.", "explain": "'impose regulations' = áp đặt quy định. 'stricter' = so sánh hơn của 'strict'."},
|
| 168 |
+
{"vi": "Ô nhiễm không khí làm trầm trọng thêm các bệnh về đường hô hấp và làm giảm tuổi thọ.", "en": "Air pollution exacerbates respiratory diseases and reduces life expectancy.", "explain": "'exacerbate' = làm trầm trọng thêm (C2). 'life expectancy' = tuổi thọ."},
|
| 169 |
+
{"vi": "Ý thức bảo vệ môi trường của người dân đã được nâng cao đáng kể trong những năm gần đây.", "en": "Public awareness of environmental protection has grown considerably in recent years.", "explain": "'public awareness' = ý thức cộng đồng. 'grow considerably' = tăng đáng kể."},
|
| 170 |
+
{"vi": "Việc giảm lượng khí thải carbon dioxide là ưu tiên hàng đầu trong cuộc chiến chống biến đổi khí hậu.", "en": "Reducing carbon dioxide emissions is a top priority in the fight against climate change.", "explain": "'top priority' = ưu tiên hàng đầu. 'in the fight against' = trong cuộc chiến chống lại."},
|
| 171 |
+
],
|
| 172 |
+
},
|
| 173 |
+
{
|
| 174 |
+
"title": "Collocations: Công nghệ (Technology)",
|
| 175 |
+
"description": "Từ vựng và cụm từ về chủ đề Công nghệ — Band 7+ topic.",
|
| 176 |
+
"sentences": [
|
| 177 |
+
{"vi": "Trí tuệ nhân tạo đang biến đổi cơ bản cách chúng ta làm việc và giao tiếp.", "en": "Artificial intelligence is fundamentally transforming the way we work and communicate.", "explain": "'fundamentally transform' = biến đổi cơ bản. 'the way we' = cách chúng ta."},
|
| 178 |
+
{"vi": "Sự bùng nổ của mạng xã hội đã có tác động sâu sắc đến hành vi của người tiêu dùng.", "en": "The proliferation of social media has had a profound impact on consumer behaviour.", "explain": "'proliferation' = sự bùng nổ/lan rộng (formal). 'profound impact' = tác động sâu sắc."},
|
| 179 |
+
{"vi": "Tự động hóa có thể thay thế nhiều công việc lao động tay chân, dẫn đến thất nghiệp quy mô lớn.", "en": "Automation may replace many manual jobs, leading to large-scale unemployment.", "explain": "'automation' = tự động hóa. 'manual jobs' = công việc chân tay. 'large-scale' = quy mô lớn."},
|
| 180 |
+
{"vi": "Khoảng cách số giữa các quốc gia phát triển và đang phát triển ngày càng mở rộng.", "en": "The digital divide between developed and developing nations is widening.", "explain": "'digital divide' = khoảng cách số (cụm quan trọng). 'widening' = đang mở rộng."},
|
| 181 |
+
{"vi": "Đổi mới công nghệ phải đi đôi với bảo đảm quyền riêng tư và an ninh dữ liệu.", "en": "Technological innovation must go hand in hand with ensuring data privacy and security.", "explain": "'go hand in hand' = đi đôi với (idiom). 'data privacy' = quyền riêng tư dữ liệu."},
|
| 182 |
+
{"vi": "Internet vạn vật đang tạo ra các thành phố thông minh nơi mọi thiết bị đều được kết nối.", "en": "The Internet of Things is creating smart cities where every device is interconnected.", "explain": "'Internet of Things (IoT)' = internet vạn vật. 'interconnected' = kết nối với nhau."},
|
| 183 |
+
{"vi": "Công nghệ giáo dục đang mở ra cơ hội học tập cho hàng triệu người ở vùng sâu vùng xa.", "en": "Educational technology is opening up learning opportunities for millions of people in remote areas.", "explain": "'open up opportunities' = mở ra cơ hội. 'remote areas' = vùng sâu vùng xa."},
|
| 184 |
+
{"vi": "Phụ thuộc quá mức vào thiết bị kỹ thuật số có thể gây ra những hậu quả tiêu cực về mặt tâm lý.", "en": "Excessive reliance on digital devices can have negative psychological consequences.", "explain": "'excessive reliance on' = phụ thuộc quá mức vào. 'psychological' = thuộc về tâm lý."},
|
| 185 |
+
],
|
| 186 |
+
},
|
| 187 |
+
{
|
| 188 |
+
"title": "Collocations: Sức khỏe & Xã hội",
|
| 189 |
+
"description": "Từ vựng học thuật về sức khỏe cộng đồng và các vấn đề xã hội.",
|
| 190 |
+
"sentences": [
|
| 191 |
+
{"vi": "Béo phì là mối quan tâm sức khỏe cộng đồng ngày càng nghiêm trọng ở nhiều quốc gia.", "en": "Obesity is an increasingly pressing public health concern in many countries.", "explain": "'pressing concern' = mối lo ngại cấp bách. 'public health' = sức khỏe cộng đồng."},
|
| 192 |
+
{"vi": "Sức khỏe tâm thần đã được nhìn nhận là một phần không thể tách rời của sức khỏe tổng thể.", "en": "Mental health has been recognised as an integral part of overall well-being.", "explain": "'integral part' = phần không thể tách rời. 'well-being' = sức khỏe tổng thể/hạnh phúc."},
|
| 193 |
+
{"vi": "Bất bình đẳng thu nhập ngày càng gia tăng đe dọa nghiêm trọng đến sự gắn kết xã hội.", "en": "Growing income inequality poses a significant threat to social cohesion.", "explain": "'income inequality' = bất bình đẳng thu nhập. 'social cohesion' = sự gắn kết xã hội."},
|
| 194 |
+
{"vi": "Đại dịch COVID-19 đã phơi bày những điểm yếu nghiêm trọng trong hệ thống y tế toàn cầu.", "en": "The COVID-19 pandemic exposed serious weaknesses in the global healthcare system.", "explain": "'expose weaknesses' = phơi bày điểm yếu. 'healthcare system' = hệ thống y tế."},
|
| 195 |
+
{"vi": "Đô thị hóa nhanh chóng dẫn đến việc hình thành các khu ổ chuột ở vùng ngoại ô của nhiều thành phố.", "en": "Rapid urbanisation leads to the emergence of slums on the outskirts of many cities.", "explain": "'urbanisation' = đô thị hóa. 'slums' = khu ổ chuột. 'outskirts' = ngoại ô."},
|
| 196 |
+
{"vi": "Áp lực công việc cường độ cao có thể dẫn đến kiệt sức và các vấn đề sức khỏe nghiêm trọng.", "en": "High-intensity work pressure can lead to burnout and serious health problems.", "explain": "'lead to burnout' = dẫn đến kiệt sức. 'burnout' = tình trạng kiệt sức (thuật ngữ)."},
|
| 197 |
+
{"vi": "Trách nhiệm xã hội của doanh nghiệp đã trở thành yếu tố quan trọng trong chiến lược kinh doanh hiện đại.", "en": "Corporate social responsibility has become a crucial factor in modern business strategy.", "explain": "'corporate social responsibility (CSR)' = trách nhiệm xã hội doanh nghiệp."},
|
| 198 |
+
{"vi": "Tình nguyện và từ thiện là biểu hiện quan trọng của trách nhiệm công dân và tinh thần cộng đồng.", "en": "Volunteering and philanthropy are vital expressions of civic responsibility and community spirit.", "explain": "'philanthropy' = hoạt động từ thiện. 'civic responsibility' = trách nhiệm công dân."},
|
| 199 |
+
],
|
| 200 |
+
},
|
| 201 |
+
],
|
| 202 |
+
},
|
| 203 |
+
|
| 204 |
+
# ────────────────────────────────────────────────────────────────────────
|
| 205 |
+
# BƯỚC 3 – Dịch đoạn văn Band 6.5
|
| 206 |
+
# ────────────────────────────────────────────────────────────────────────
|
| 207 |
+
{
|
| 208 |
+
"title": "Dịch đoạn văn Band 6.5",
|
| 209 |
+
"description": "Luyện dịch các đoạn văn ngắn từ Việt sang Anh với mục tiêu đạt độ chính xác và mạch lạc ở mức Band 6.5.",
|
| 210 |
+
"badge_label": "BAND 6.5",
|
| 211 |
+
"badge_color": "green",
|
| 212 |
+
"icon_emoji": "🎯",
|
| 213 |
+
"topics": [
|
| 214 |
+
{
|
| 215 |
+
"title": "Đoạn văn: Vai trò của Giáo dục",
|
| 216 |
+
"description": "Dịch các đoạn văn bàn về tầm quan trọng của giáo dục.",
|
| 217 |
+
"sentences": [
|
| 218 |
+
{"vi": "Giáo dục là nền tảng của một xã hội thịnh vượng. Đầu tư vào giáo dục không chỉ nâng cao chất lượng nguồn nhân lực mà còn thúc đẩy sự phát triển kinh tế bền vững.", "en": "Education is the foundation of a prosperous society. Investing in education not only improves the quality of the workforce but also promotes sustainable economic development.", "explain": "'not only ... but also ...' = không chỉ ... mà còn. 'workforce' = lực lượng lao động. 'sustainable' = bền vững."},
|
| 219 |
+
{"vi": "Khoảng cách về cơ hội học tập giữa học sinh thành thị và nông thôn vẫn là vấn đề đáng lo ngại. Học sinh ở vùng nông thôn thường thiếu tiếp cận với cơ sở hạ tầng giáo dục hiện đại và giáo viên có trình độ chuyên môn cao.", "en": "The gap in educational opportunities between urban and rural students remains a concerning issue. Students in rural areas often lack access to modern educational infrastructure and highly qualified teachers.", "explain": "'lack access to' = thiếu tiếp cận với. 'infrastructure' = cơ sở hạ tầng. 'qualified' = có chuyên môn."},
|
| 220 |
+
{"vi": "Trong thế giới ngày càng toàn cầu hóa, khả năng giao tiếp bằng tiếng Anh là lợi thế cạnh tranh quan trọng. Những người thành thạo ngoại ngữ thường có cơ hội nghề nghiệp tốt hơn và thu nhập cao hơn so với những người chỉ biết tiếng mẹ đẻ.", "en": "In an increasingly globalised world, the ability to communicate in English is a significant competitive advantage. Those who are proficient in foreign languages tend to have better career opportunities and higher incomes compared to those who only speak their native language.", "explain": "'proficient in' = thành thạo. 'competitive advantage' = lợi thế cạnh tranh. 'native language' = tiếng mẹ đẻ."},
|
| 221 |
+
{"vi": "Một hệ thống giáo dục chất lượng cao cần phải cân bằng giữa việc truyền đạt kiến thức lý thuyết và phát triển kỹ năng thực hành. Chỉ học thuộc lòng mà không hiểu bản chất vấn đề sẽ cản trở khả năng tư duy sáng tạo của học sinh.", "en": "A high-quality education system needs to strike a balance between imparting theoretical knowledge and developing practical skills. Rote memorisation without genuine understanding will hinder students' capacity for creative thinking.", "explain": "'strike a balance' = cân bằng. 'rote memorisation' = học vẹt. 'hinder' = cản trở."},
|
| 222 |
+
{"vi": "Nhiều chuyên gia lập luận rằng hệ thống giáo dục truyền thống đang lỗi thời trước sự phát triển nhanh chóng của thị trường lao động. Thay vào đó, các trường học nên ưu tiên phát triển tư duy phản biện, kỹ năng giải quyết vấn đề và khả năng thích nghi.", "en": "Many experts argue that the traditional education system is becoming outdated in the face of the rapidly evolving labour market. Instead, schools should prioritise developing critical thinking, problem-solving skills, and adaptability.", "explain": "'argue that' = lập luận rằng. 'outdated' = lỗi thời. 'adaptability' = khả năng thích nghi."},
|
| 223 |
+
],
|
| 224 |
+
},
|
| 225 |
+
{
|
| 226 |
+
"title": "Đoạn văn: Môi trường và Con người",
|
| 227 |
+
"description": "Dịch các đoạn văn về các vấn đề môi trường — chủ đề Band 6.5 phổ biến.",
|
| 228 |
+
"sentences": [
|
| 229 |
+
{"vi": "Ô nhiễm môi trường đang ngày càng trở thành mối lo ngại toàn cầu. Các hoạt động công nghiệp và giao thông vận tải thải ra lượng lớn khí CO₂, góp phần làm tăng nhiệt độ trái đất và gây ra những biến đổi khí hậu nghiêm trọng.", "en": "Environmental pollution is becoming an increasingly global concern. Industrial activities and transportation emit large amounts of CO₂, contributing to rising global temperatures and causing severe climate changes.", "explain": "'emit' = thải ra. 'contributing to' = góp phần vào. 'severe' = nghiêm trọng."},
|
| 230 |
+
{"vi": "Một trong những nguyên nhân chính gây ra ô nhiễm nguồn nước là việc xả thải chất thải công nghiệp chưa qua xử lý ra các con sông và hồ. Điều này không chỉ ảnh hưởng đến hệ sinh thái thủy sinh mà còn đe dọa sức khỏe của cộng đồng dân cư sống xung quanh.", "en": "One of the primary causes of water pollution is the discharge of untreated industrial waste into rivers and lakes. This not only affects aquatic ecosystems but also threatens the health of communities living nearby.", "explain": "'discharge' = xả thải. 'untreated' = chưa qua xử lý. 'aquatic ecosystems' = hệ sinh thái thủy sinh."},
|
| 231 |
+
{"vi": "Việc sử dụng năng lượng tái tạo như điện mặt trời và điện gió đang ngày càng trở nên phổ biến hơn nhờ vào chi phí ngày càng giảm và hiệu quả ngày càng tăng. Nhiều quốc gia đang đặt ra các mục tiêu đầy tham vọng để đạt mức phát thải ròng bằng không vào năm 2050.", "en": "The use of renewable energy sources such as solar and wind power is becoming increasingly popular due to falling costs and rising efficiency. Many countries are setting ambitious targets to achieve net-zero emissions by 2050.", "explain": "'net-zero emissions' = phát thải ròng bằng 0. 'ambitious targets' = mục tiêu đầy tham vọng."},
|
| 232 |
+
{"vi": "Ý thức bảo vệ môi trường cần được giáo dục từ khi còn nhỏ. Khi trẻ em được dạy về tầm quan trọng của việc tiết kiệm năng lượng, phân loại rác thải và bảo vệ thiên nhiên, chúng sẽ lớn lên với tinh thần trách nhiệm cao hơn đối với hành tinh của mình.", "en": "Environmental awareness needs to be taught from an early age. When children are educated about the importance of saving energy, sorting waste, and protecting nature, they will grow up with a stronger sense of responsibility towards their planet.", "explain": "'environmental awareness' = ý thức bảo vệ MT. 'sense of responsibility' = tinh thần trách nhiệm."},
|
| 233 |
+
{"vi": "Nhiều thành phố lớn trên thế giới đang áp dụng mô hình thành phố xanh nhằm giảm lượng khí thải và cải thiện chất lượng cuộc sống của người dân. Các giải pháp như giao thông công cộng bằng điện, không gian xanh và tòa nhà tiết kiệm năng lượng đang được triển khai rộng rãi.", "en": "Many major cities around the world are adopting the green city model to reduce emissions and improve residents' quality of life. Solutions such as electric public transport, green spaces, and energy-efficient buildings are being widely implemented.", "explain": "'adopt a model' = áp dụng mô hình. 'energy-efficient' = tiết kiệm năng lượng. 'implement' = triển khai."},
|
| 234 |
+
],
|
| 235 |
+
},
|
| 236 |
+
{
|
| 237 |
+
"title": "Đoạn văn: Công nghệ & Xã hội",
|
| 238 |
+
"description": "Các đoạn văn thảo luận tác động của công ngh��� đến cuộc sống hiện đại.",
|
| 239 |
+
"sentences": [
|
| 240 |
+
{"vi": "Cuộc cách mạng công nghiệp 4.0 đang thay đổi căn bản mọi lĩnh vực của cuộc sống, từ sản xuất công nghiệp cho đến dịch vụ y tế và giáo dục. Những công nghệ như trí tuệ nhân tạo, blockchain và internet vạn vật đang tái định hình nền kinh tế toàn cầu.", "en": "The Fourth Industrial Revolution is fundamentally changing every aspect of life, from industrial production to healthcare and education services. Technologies such as artificial intelligence, blockchain, and the Internet of Things are reshaping the global economy.", "explain": "'reshape' = tái định hình. 'every aspect of' = mọi lĩnh vực của. 'fundamentally' = căn bản."},
|
| 241 |
+
{"vi": "Mặc dù công nghệ mang lại nhiều lợi ích to lớn, nhưng nó cũng tạo ra những thách thức mới. Vấn đề quyền riêng tư dữ liệu và an ninh mạng ngày càng trở nên cấp bách trong bối cảnh ngày càng có nhiều thông tin cá nhân được lưu trữ và xử lý trực tuyến.", "en": "Although technology brings many significant benefits, it also creates new challenges. The issues of data privacy and cybersecurity are becoming increasingly urgent as more personal information is stored and processed online.", "explain": "'Although' nhượng bộ. 'cybersecurity' = an ninh mạng. 'urgent' = cấp bách."},
|
| 242 |
+
{"vi": "Sự phát triển của truyền thông xã hội đã tạo ra cả những cơ hội và nguy cơ mới. Một mặt, nó cho phép mọi người kết nối và chia sẻ thông tin nhanh chóng hơn bao giờ hết. Mặt khác, nó cũng là mảnh đất màu mỡ cho việc lan truyền thông tin sai lệch và ngôn từ thù hận.", "en": "The development of social media has created both new opportunities and new risks. On the one hand, it enables people to connect and share information faster than ever before. On the other hand, it has also become a fertile ground for the spread of misinformation and hate speech.", "explain": "'On the one hand ... On the other hand' = Một mặt ... Mặt khác. 'misinformation' = thông tin sai lệch. 'fertile ground' = mảnh đất màu mỡ (ẩn dụ)."},
|
| 243 |
+
{"vi": "Làm việc từ xa đã trở nên phổ biến hơn đáng kể kể từ đại dịch COVID-19. Mô hình làm việc linh hoạt này mang lại nhiều lợi ích như tiết kiệm thời gian đi lại và tăng sự cân bằng giữa công việc và cuộc sống, nhưng cũng đặt ra những thách thức trong việc duy trì sự gắn kết nhóm và quản lý năng suất.", "en": "Remote working has become significantly more prevalent since the COVID-19 pandemic. This flexible working model offers several benefits such as saving commuting time and improving work-life balance, but also poses challenges in maintaining team cohesion and managing productivity.", "explain": "'prevalent' = phổ biến. 'work-life balance' = cân bằng công việc-cuộc sống. 'team cohesion' = sự gắn kết nhóm."},
|
| 244 |
+
{"vi": "Tự động hóa và trí tuệ nhân tạo đang thay thế ngày càng nhiều công việc lặp đi lặp lại trong nhiều ngành. Điều này đặt ra câu hỏi cấp bách về việc đào tạo lại lực lượng lao động và đảm bảo rằng những lợi ích của tiến bộ công nghệ được phân bổ công bằng trong xã hội.", "en": "Automation and artificial intelligence are replacing an increasing number of repetitive jobs across many industries. This raises an urgent question about retraining the workforce and ensuring that the benefits of technological progress are equitably distributed across society.", "explain": "'retraining' = đào tạo lại. 'equitably distributed' = phân bổ công bằng. 'across society' = trong toàn xã hội."},
|
| 245 |
+
],
|
| 246 |
+
},
|
| 247 |
+
],
|
| 248 |
+
},
|
| 249 |
+
|
| 250 |
+
# ────────────────────────────────────────────────────────────────────────
|
| 251 |
+
# BƯỚC 4 – Dịch đoạn văn Band 8.0
|
| 252 |
+
# ────────────────────────────────────────────────────────────────────────
|
| 253 |
+
{
|
| 254 |
+
"title": "Dịch đoạn văn Band 8.0",
|
| 255 |
+
"description": "Thử thách dịch các đoạn văn phức tạp, yêu cầu sử dụng từ vựng ít phổ biến và cấu trúc câu linh hoạt của Band 8.0.",
|
| 256 |
+
"badge_label": "BAND 8.0",
|
| 257 |
+
"badge_color": "orange",
|
| 258 |
+
"icon_emoji": "🏆",
|
| 259 |
+
"topics": [
|
| 260 |
+
{
|
| 261 |
+
"title": "Luận điểm nâng cao: Giáo dục & Xã hội",
|
| 262 |
+
"description": "Dịch các đoạn lập luận phức tạp về giáo dục và xã hội ở mức Band 8.0.",
|
| 263 |
+
"sentences": [
|
| 264 |
+
{"vi": "Có một quan điểm ngày càng phổ biến rằng các phương pháp sư phạm truyền thống, vốn đặt giáo viên làm trung tâm và nhấn mạnh vào việc tiếp thu kiến thức thụ động, đang ngăn cản học sinh phát triển tư duy độc lập và kỹ năng giải quyết vấn đề — những năng lực thiết yếu trong thế kỷ 21.", "en": "There is a growing consensus that traditional pedagogical approaches, which are teacher-centred and emphasise passive knowledge acquisition, are inhibiting students from developing independent thinking and problem-solving abilities — competencies that are indispensable in the twenty-first century.", "explain": "'pedagogical' = thuộc sư phạm. 'inhibiting' = ngăn cản. 'indispensable' = thiết yếu/không thể thiếu (C2)."},
|
| 265 |
+
{"vi": "Mặc dù nhiều người lập luận rằng toàn cầu hóa đã thúc đẩy trao đổi văn hóa và hiểu biết lẫn nhau, nhưng không thể phủ nhận rằng nó cũng đã làm xói mòn bản sắc văn hóa địa phương và gia tăng sự phụ thuộc kinh tế của các quốc gia đang phát triển vào các nền kinh tế phát triển.", "en": "While many argue that globalisation has promoted cultural exchange and mutual understanding, it cannot be denied that it has also eroded local cultural identities and increased the economic dependence of developing nations on developed economies.", "explain": "'erode' = xói mòn (hình tượng). 'mutual understanding' = hiểu biết lẫn nhau. 'economic dependence' = phụ thuộc kinh tế."},
|
| 266 |
+
{"vi": "Cuộc khủng hoảng khí hậu không đơn thuần là một vấn đề môi trường mà còn là một cuộc khủng hoảng công bằng xã hội sâu sắc. Những cộng đồng dễ bị tổn thương nhất, đặc biệt là ở các nước đang phát triển, phải gánh chịu những hậu quả nặng nề nhất của một vấn đề mà họ chỉ đóng góp rất ít vào nguyên nhân gây ra.", "en": "The climate crisis is not merely an environmental issue but also a profound social justice crisis. The most vulnerable communities, particularly in developing nations, bear the heaviest consequences of a problem to which they have contributed the least.", "explain": "'profound' = sâu sắc. 'bear the consequences' = gánh chịu hậu quả. 'vulnerable' = dễ bị tổn thương."},
|
| 267 |
+
{"vi": "Trong khi những tiến bộ trong y học đã kéo dài đáng kể tuổi thọ con người, vẫn còn những câu hỏi đạo đức phức tạp xung quanh việc phân bổ nguồn lực y tế khan hiếm. Câu hỏi liệu có nên ưu tiên các biện pháp điều trị kéo dài sự sống tốn kém hay tập trung vào chăm sóc sức khỏe phòng ngừa chi phí thấp cho đông đảo dân số hơn vẫn còn là chủ đề tranh luận gay gắt.", "en": "While advances in medicine have significantly extended human lifespans, complex ethical questions remain surrounding the allocation of scarce medical resources. Whether priority should be given to expensive life-prolonging treatments or to low-cost preventive healthcare for a larger proportion of the population remains a contentious subject.", "explain": "'scarce resources' = nguồn lực khan hiếm. 'allocation' = phân bổ. 'contentious' = gây tranh cãi (C2)."},
|
| 268 |
+
{"vi": "Một số nhà kinh tế học lập luận rằng sự gia tăng của bất bình đẳng kinh tế ở các xã hội hiện đại không phải là một hiện tượng ngẫu nhiên mà là kết quả tất yếu của các chính sách thuế ưu đãi cho vốn hơn lao động, sự suy yếu của công đoàn và việc tự động hóa ưu tiên thay thế lao động giản đơn.", "en": "Some economists argue that the rise of economic inequality in modern societies is not a random phenomenon but rather an inevitable consequence of tax policies favouring capital over labour, the weakening of trade unions, and automation that disproportionately displaces low-skilled workers.", "explain": "'disproportionately' = không cân xứng. 'trade unions' = công đoàn. 'inevitable consequence' = hậu quả tất yếu."},
|
| 269 |
+
],
|
| 270 |
+
},
|
| 271 |
+
{
|
| 272 |
+
"title": "Phân tích chuyên sâu: Khoa học & Đổi mới",
|
| 273 |
+
"description": "Dịch các đoạn văn mang tính học thuật cao về khoa học và công nghệ ở Band 8.0.",
|
| 274 |
+
"sentences": [
|
| 275 |
+
{"vi": "Sự hội tụ của trí tuệ nhân tạo, điện toán lượng tử và công nghệ sinh học đang mở ra những khả năng mà trước đây chỉ tồn tại trong lĩnh vực khoa học viễn tưởng. Tuy nhiên, tốc độ phát triển của những công nghệ này đang vượt xa khả năng của các khung pháp lý hiện hành trong việc giải quyết các rủi ro và tác động đạo đức liên quan.", "en": "The convergence of artificial intelligence, quantum computing, and biotechnology is unlocking possibilities that previously existed only in the realm of science fiction. However, the pace of development of these technologies is far outstripping the capacity of existing regulatory frameworks to address the associated risks and ethical implications.", "explain": "'convergence' = sự hội tụ. 'outstrip' = vượt xa. 'regulatory frameworks' = khung pháp lý."},
|
| 276 |
+
{"vi": "Dữ liệu lớn đã trở thành nguồn tài nguyên chiến lược quan trọng nhất của thế kỷ 21, đôi khi được ví như dầu mỏ của kỷ nguyên số. Sức mạnh kinh tế và chính trị ngày càng tập trung vào tay các công ty công nghệ khổng lồ có khả năng thu thập, phân tích và khai thác lượng dữ liệu khổng lồ về hành vi của hàng tỷ người dùng.", "en": "Big data has become the most strategically important resource of the twenty-first century, often likened to the oil of the digital era. Economic and political power is increasingly concentrated in the hands of technology giants with the capacity to collect, analyse, and exploit vast amounts of data about the behaviour of billions of users.", "explain": "'likened to' = được ví như. 'strategically important' = quan trọng về mặt chiến lược. 'exploit' = khai thác."},
|
| 277 |
+
{"vi": "Cuộc tranh luận về vai trò của nhà nước trong việc quản lý các nền tảng truyền thông xã hội phản ánh sự căng thẳng cơ bản giữa hai giá trị cốt lõi của xã hội dân chủ: tự do ngôn luận và bảo vệ cộng đồng khỏi nội dung có hại. Không có giải pháp nào hoàn hảo cho sự đánh đổi này, và mọi can thiệp đều kéo theo những rủi ro không lường trước được.", "en": "The debate over the role of the state in regulating social media platforms reflects a fundamental tension between two core values of democratic society: freedom of expression and the protection of communities from harmful content. No perfect solution exists for this trade-off, and every intervention carries unforeseen risks.", "explain": "'trade-off' = sự đánh đổi. 'unforeseen' = không lường trước được. 'fundamental tension' = sự căng thẳng cơ bản."},
|
| 278 |
+
{"vi": "Mặc dù cải cách giáo dục thường được coi là chìa khóa giải quyết bất bình đẳng kinh tế, nhưng bằng chứng thực nghiệm cho thấy tác động của giáo dục đến tính di động xã hội bị hạn chế đáng kể bởi các yếu tố cấu trúc rộng hơn như sự tập trung của cải, sự phân tầng địa lý và phân biệt đối xử có hệ thống.", "en": "Although educational reform is often regarded as the key to addressing economic inequality, empirical evidence suggests that education's impact on social mobility is significantly constrained by broader structural factors such as wealth concentration, geographical stratification, and systemic discrimination.", "explain": "'empirical evidence' = bằng chứng thực nghiệm. 'social mobility' = tính di động xã hội. 'systemic' = có hệ thống."},
|
| 279 |
+
{"vi": "Khủng hoảng đa dạng sinh học mà hành tinh chúng ta đang phải đối mặt — với tốc độ tuyệt chủng ước tính cao hơn 1.000 lần so với mức nền tự nhiên — là minh chứng bi thảm nhất cho tác động của hoạt động con người đến hệ sinh thái toàn cầu. Mất mát không thể khắc phục này không chỉ là thảm họa sinh thái mà còn đe dọa trực tiếp đến an ninh lương thực, sức khỏe cộng đồng và sự ổn định của khí hậu.", "en": "The biodiversity crisis facing our planet — with extinction rates estimated to be 1,000 times higher than natural background levels — is the most tragic testament to the impact of human activity on global ecosystems. This irreversible loss is not merely an ecological catastrophe but also poses a direct threat to food security, public health, and climate stability.", "explain": "'testament to' = minh chứng cho. 'irreversible' = không thể khắc phục. 'ecological catastrophe' = thảm họa sinh thái."},
|
| 280 |
+
],
|
| 281 |
+
},
|
| 282 |
+
],
|
| 283 |
+
},
|
| 284 |
+
|
| 285 |
+
# ────────────────────────────────────────────────────────────────────────
|
| 286 |
+
# BƯỚC 5 – Dịch Essay hoàn chỉnh (IELTS Task 2 style)
|
| 287 |
+
# ──────────────────────────────────────────────────────────────��─────────
|
| 288 |
+
{
|
| 289 |
+
"title": "Dịch Essay hoàn chỉnh",
|
| 290 |
+
"description": "Luyện dịch nguyên một đoạn bài Essay từ dàn ý tiếng Việt sang bài viết tiếng Anh học thuật hoàn chỉnh ở mức Band 8.0+.",
|
| 291 |
+
"badge_label": "PREMIUM",
|
| 292 |
+
"badge_color": "purple",
|
| 293 |
+
"icon_emoji": "🎓",
|
| 294 |
+
"topics": [
|
| 295 |
+
{
|
| 296 |
+
"title": "Essay Task 2: Mở bài & Thân bài — Giáo dục",
|
| 297 |
+
"description": "Dịch từng phần của bài IELTS Task 2 về chủ đề giáo dục.",
|
| 298 |
+
"sentences": [
|
| 299 |
+
{"vi": "Ngày nay, câu hỏi liệu chính phủ có nên chịu trách nhiệm chính trong việc tài trợ cho giáo dục đại học hay chi phí này nên được chuyển sang cho cá nhân sinh viên là một chủ đề gây tranh luận sôi nổi. Mặc dù có lập luận thuyết phục cho cả hai quan điểm, tôi tin rằng một hệ thống lai kết hợp tài trợ công và đóng góp của sinh viên là giải pháp thực tế nhất.", "en": "In today's world, the question of whether governments should bear the primary responsibility for funding higher education, or whether this cost should be shifted to individual students, is a hotly contested topic. While there are compelling arguments on both sides, I believe that a hybrid system combining public funding and student contributions represents the most pragmatic solution.", "explain": "'hotly contested' = gây tranh cãi gay gắt. 'compelling arguments' = lập luận thuyết phục. 'pragmatic' = thực tế, thực dụng."},
|
| 300 |
+
{"vi": "Những người ủng hộ giáo dục đại học miễn phí lập luận rằng đây là điều kiện cần thiết để đảm bảo bình đẳng cơ hội trong xã hội. Khi chi phí học phí cao cản trở những sinh viên có tài năng nhưng xuất thân từ các gia đình thu nhập thấp, điều đó không chỉ là bất công với cá nhân mà còn là sự lãng phí tiềm năng nhân lực to lớn của quốc gia.", "en": "Proponents of free higher education argue that it is a prerequisite for ensuring equality of opportunity within society. When high tuition fees deter talented students from low-income backgrounds, this constitutes not only an injustice to the individual but also a significant waste of a nation's human potential.", "explain": "'proponents' = những người ủng hộ. 'prerequisite' = điều kiện tiên quyết. 'deter' = cản trở, làm nản lòng."},
|
| 301 |
+
{"vi": "Tuy nhiên, quan điểm cho rằng gánh nặng chi phí giáo dục đại học chỉ nên do người nộp thuế chịu là không hoàn toàn thuyết phục. Những cá nhân có bằng đại học thường thu nhập cao hơn đáng kể so với những người không có bằng, điều đó có nghĩa là họ sẽ đóng góp nhiều hơn vào ngân sách nhà nước thông qua thuế thu nhập. Do đó, có cơ sở hợp lý để yêu cầu những người hưởng lợi trực tiếp nhất đóng góp một phần chi phí.", "en": "However, the view that the cost of higher education should be borne entirely by taxpayers is not entirely convincing. University graduates typically earn significantly more than those without degrees, meaning they will contribute more to public finances through income tax. There is therefore a reasonable basis for requiring those who benefit most directly to contribute towards the cost.", "explain": "'bear the cost' = chịu gánh nặng chi phí. 'taxpayers' = người nộp thuế. 'reasonable basis' = cơ sở hợp lý."},
|
| 302 |
+
{"vi": "Từ quan điểm thực tiễn, một mô hình học phí hoãn nợ — trong đó sinh viên chỉ bắt đầu hoàn trả sau khi thu nhập của họ vượt ngưỡng nhất định — có thể giải quyết được cả mối lo ngại về bình đẳng lẫn tính bền vững tài chính. Mô hình này, được áp dụng thành công ở Úc và Vương quốc Anh, đảm bảo rằng gánh nặng tài chính không bao giờ trở nên không thể chịu đựng được đối với bất kỳ cá nhân nào.", "en": "From a practical standpoint, a deferred tuition model — in which students begin repayments only once their income exceeds a certain threshold — can address both concerns of equity and financial sustainability. This model, successfully implemented in Australia and the United Kingdom, ensures that the financial burden never becomes unbearable for any individual.", "explain": "'deferred' = hoãn lại. 'threshold' = ngưỡng. 'equity' = sự công bằng (formal, ≠ equality)."},
|
| 303 |
+
{"vi": "Tóm lại, mặc dù giáo dục đại học hoàn toàn miễn phí là một lý tưởng hấp dẫn, nhưng trong bối cảnh nguồn lực công hạn chế, một hệ thống phân chia chi phí công bằng và linh hoạt sẽ phục vụ xã hội tốt hơn. Điều quan trọng nhất là đảm bảo rằng không có sinh viên tài năng nào bị loại ra khỏi giáo dục đại học chỉ vì lý do tài chính.", "en": "In conclusion, while entirely free higher education is an appealing ideal, a fair and flexible cost-sharing system would serve society better given limited public resources. The most important objective is to ensure that no talented student is excluded from higher education purely on financial grounds.", "explain": "'on financial grounds' = vì lý do tài chính. 'appealing ideal' = lý tưởng hấp dẫn. 'cost-sharing' = phân chia chi phí."},
|
| 304 |
+
],
|
| 305 |
+
},
|
| 306 |
+
{
|
| 307 |
+
"title": "Essay Task 2: Mở bài & Thân bài — Môi trường",
|
| 308 |
+
"description": "Dịch từng phần của bài IELTS Task 2 về biến đổi khí hậu và trách nhiệm cá nhân vs. chính phủ.",
|
| 309 |
+
"sentences": [
|
| 310 |
+
{"vi": "Biến đổi khí hậu là thách thức nghiêm trọng nhất mà nhân loại đang phải đối mặt trong thế kỷ này. Trong khi một số người cho rằng chính phủ phải chịu trách nhiệm chính trong việc giải quyết vấn đề này thông qua chính sách và quy định, những người khác lại khẳng định rằng hành động của mỗi cá nhân là yếu tố then chốt để tạo ra sự thay đổi thực sự. Quan điểm của tôi là cả hai đều không thể thiếu và phải hành động song song.", "en": "Climate change is the most formidable challenge facing humanity in this century. While some contend that governments bear the primary responsibility for tackling this issue through policy and regulation, others maintain that individual action is the pivotal factor in bringing about genuine change. My view is that both are indispensable and must act in concert.", "explain": "'formidable' = ghê gớm, đáng sợ. 'contend' = cho rằng, lập luận. 'in concert' = song song, phối hợp."},
|
| 311 |
+
{"vi": "Không thể phủ nhận rằng các chính phủ có đòn bẩy chính sách mà không một cá nhân nào có thể sở hữu. Các cơ chế như thuế carbon, quy định về tiêu chuẩn phát thải và đầu tư vào cơ sở hạ tầng năng lượng tái tạo có thể tạo ra thay đổi có hệ thống ở quy mô mà hành vi cá nhân không bao giờ có thể đạt được. Nếu không có sự can thiệp của nhà nước, thị trường sẽ không tự nhiên chuyển hướng sang các giải pháp thân thiện với môi trường.", "en": "It is undeniable that governments possess policy levers that no individual can wield. Mechanisms such as carbon taxes, emission standards regulations, and investment in renewable energy infrastructure can drive systemic change at a scale that individual behaviour can never achieve. Without state intervention, markets will not naturally transition towards environmentally friendly solutions.", "explain": "'levers' = đòn bẩy (ẩn dụ). 'wield' = sử dụng, thi hành. 'systemic change' = thay đổi có hệ thống."},
|
| 312 |
+
{"vi": "Đồng thời, hành động tập thể của hàng tỷ cá nhân có sức mạnh biến đổi không thể xem thường. Khi người tiêu dùng thay đổi thói quen — giảm tiêu thụ thịt, chọn phương tiện giao thông xanh, ủng hộ các thương hiệu có trách nhiệm với môi trường — họ gửi tín hiệu thị trường mạnh mẽ và góp phần xây dựng áp lực chính trị để buộc các chính phủ hành động quyết liệt hơn.", "en": "At the same time, the collective action of billions of individuals has a transformative power that cannot be underestimated. When consumers change their habits — reducing meat consumption, choosing green transportation, supporting environmentally responsible brands — they send powerful market signals and contribute to building political pressure that compels governments to take more decisive action.", "explain": "'collective action' = hành động tập thể. 'compel' = buộc phải. 'decisive' = quyết liệt, dứt khoát."},
|
| 313 |
+
{"vi": "Điều quan trọng cần nhận ra là trách nhiệm cá nhân và chính sách chính phủ không phải là hai lựa chọn đối lập mà là hai trụ cột bổ trợ cho nhau trong cuộc chiến chống biến đổi khí hậu. Các chính phủ cần tạo ra môi trường chính sách thuận lợi, trong khi cá nhân cần đưa ra những lựa chọn có trách nhiệm hơn trong phạm vi mà chính sách đó cho phép.", "en": "It is crucial to recognise that individual responsibility and government policy are not opposing alternatives but rather two complementary pillars in the fight against climate change. Governments need to create a favourable policy environment, while individuals need to make more responsible choices within the space that such policies enable.", "explain": "'complementary pillars' = các trụ cột bổ trợ. 'favourable policy environment' = môi trường chính sách thuận lợi."},
|
| 314 |
+
{"vi": "Để kết luận, cuộc khủng hoảng khí hậu đòi hỏi một phản ứng đồng bộ ở mọi cấp độ của xã hội. Trong khi chính phủ phải dẫn đầu thông qua các chính sách táo bạo và bắt buộc, mỗi cá nhân cũng phải nhận thức được rằng những lựa chọn hàng ngày của mình có hệ quả thực sự đối với thế giới mà các thế hệ tương lai sẽ thừa hưởng.", "en": "To conclude, the climate crisis demands a coordinated response at every level of society. While governments must take the lead through bold and mandatory policies, individuals must also recognise that their everyday choices have genuine consequences for the world that future generations will inherit.", "explain": "'coordinated response' = phản ứng đồng bộ. 'bold and mandatory' = táo bạo và bắt buộc. 'inherit' = thừa hưởng."},
|
| 315 |
+
],
|
| 316 |
+
},
|
| 317 |
+
],
|
| 318 |
+
},
|
| 319 |
+
]
|
backend/app/db/models.py
CHANGED
|
@@ -40,6 +40,10 @@ class User(Base):
|
|
| 40 |
locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
| 41 |
lock_reason: Mapped[str | None] = mapped_column(Text)
|
| 42 |
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
|
|
|
|
|
|
|
|
|
|
|
|
| 43 |
|
| 44 |
profile: Mapped["UserProfile"] = relationship("UserProfile", back_populates="user", uselist=False, cascade="all, delete-orphan")
|
| 45 |
progress: Mapped[list["Progress"]] = relationship("Progress", back_populates="user", cascade="all, delete-orphan")
|
|
@@ -59,6 +63,9 @@ class User(Base):
|
|
| 59 |
shadowing_history: Mapped[list["ShadowingUserHistory"]] = relationship(
|
| 60 |
"ShadowingUserHistory", back_populates="user", cascade="all, delete-orphan"
|
| 61 |
)
|
|
|
|
|
|
|
|
|
|
| 62 |
vocab_topics: Mapped[list["VocabTopic"]] = relationship(
|
| 63 |
"VocabTopic", back_populates="user", cascade="all, delete-orphan"
|
| 64 |
)
|
|
@@ -429,3 +436,99 @@ class Notification(Base):
|
|
| 429 |
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
| 430 |
|
| 431 |
user: Mapped["User"] = relationship("User", back_populates="notifications")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
locked_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
| 41 |
lock_reason: Mapped[str | None] = mapped_column(Text)
|
| 42 |
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
| 43 |
+
# OAuth / email verification
|
| 44 |
+
google_id: Mapped[str | None] = mapped_column(String(255), unique=True, nullable=True, index=True)
|
| 45 |
+
is_verified: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
| 46 |
+
auth_provider: Mapped[str] = mapped_column(String(20), default="email", nullable=False)
|
| 47 |
|
| 48 |
profile: Mapped["UserProfile"] = relationship("UserProfile", back_populates="user", uselist=False, cascade="all, delete-orphan")
|
| 49 |
progress: Mapped[list["Progress"]] = relationship("Progress", back_populates="user", cascade="all, delete-orphan")
|
|
|
|
| 63 |
shadowing_history: Mapped[list["ShadowingUserHistory"]] = relationship(
|
| 64 |
"ShadowingUserHistory", back_populates="user", cascade="all, delete-orphan"
|
| 65 |
)
|
| 66 |
+
email_verifications: Mapped[list["EmailVerification"]] = relationship(
|
| 67 |
+
"EmailVerification", back_populates="user", cascade="all, delete-orphan"
|
| 68 |
+
)
|
| 69 |
vocab_topics: Mapped[list["VocabTopic"]] = relationship(
|
| 70 |
"VocabTopic", back_populates="user", cascade="all, delete-orphan"
|
| 71 |
)
|
|
|
|
| 436 |
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
| 437 |
|
| 438 |
user: Mapped["User"] = relationship("User", back_populates="notifications")
|
| 439 |
+
|
| 440 |
+
|
| 441 |
+
# ── Translation Practice ──────────────────────────────────────────────────────
|
| 442 |
+
|
| 443 |
+
class TranslationStep(Base):
|
| 444 |
+
"""Learning step (Bước 1 – Cấu trúc cơ bản, etc.)"""
|
| 445 |
+
__tablename__ = "translation_steps"
|
| 446 |
+
|
| 447 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
| 448 |
+
order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
| 449 |
+
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
| 450 |
+
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
| 451 |
+
badge_label: Mapped[str | None] = mapped_column(String(30))
|
| 452 |
+
badge_color: Mapped[str] = mapped_column(String(20), default="gray")
|
| 453 |
+
icon_emoji: Mapped[str] = mapped_column(String(10), default="📝")
|
| 454 |
+
|
| 455 |
+
topics: Mapped[list["TranslationTopic"]] = relationship(
|
| 456 |
+
"TranslationTopic", back_populates="step", cascade="all, delete-orphan",
|
| 457 |
+
order_by="TranslationTopic.order",
|
| 458 |
+
)
|
| 459 |
+
|
| 460 |
+
|
| 461 |
+
class TranslationTopic(Base):
|
| 462 |
+
"""A sub-topic within a step (e.g., 'Simple Present')"""
|
| 463 |
+
__tablename__ = "translation_topics"
|
| 464 |
+
|
| 465 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
| 466 |
+
step_id: Mapped[int] = mapped_column(
|
| 467 |
+
Integer, ForeignKey("translation_steps.id", ondelete="CASCADE"), nullable=False, index=True
|
| 468 |
+
)
|
| 469 |
+
order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
| 470 |
+
title: Mapped[str] = mapped_column(String(200), nullable=False)
|
| 471 |
+
description: Mapped[str] = mapped_column(Text, nullable=False, default="")
|
| 472 |
+
|
| 473 |
+
step: Mapped["TranslationStep"] = relationship("TranslationStep", back_populates="topics")
|
| 474 |
+
sentences: Mapped[list["TranslationSentence"]] = relationship(
|
| 475 |
+
"TranslationSentence", back_populates="topic", cascade="all, delete-orphan",
|
| 476 |
+
order_by="TranslationSentence.order",
|
| 477 |
+
)
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
class TranslationSentence(Base):
|
| 481 |
+
"""One Vietnamese sentence with its English model answer."""
|
| 482 |
+
__tablename__ = "translation_sentences"
|
| 483 |
+
|
| 484 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
| 485 |
+
topic_id: Mapped[int] = mapped_column(
|
| 486 |
+
Integer, ForeignKey("translation_topics.id", ondelete="CASCADE"), nullable=False, index=True
|
| 487 |
+
)
|
| 488 |
+
order: Mapped[int] = mapped_column(Integer, nullable=False, default=0)
|
| 489 |
+
vietnamese: Mapped[str] = mapped_column(Text, nullable=False)
|
| 490 |
+
english: Mapped[str] = mapped_column(Text, nullable=False)
|
| 491 |
+
explanation: Mapped[str | None] = mapped_column(Text)
|
| 492 |
+
|
| 493 |
+
topic: Mapped["TranslationTopic"] = relationship("TranslationTopic", back_populates="sentences")
|
| 494 |
+
attempts: Mapped[list["TranslationAttempt"]] = relationship(
|
| 495 |
+
"TranslationAttempt", back_populates="sentence", cascade="all, delete-orphan"
|
| 496 |
+
)
|
| 497 |
+
|
| 498 |
+
|
| 499 |
+
class TranslationAttempt(Base):
|
| 500 |
+
"""User's translation attempt with AI-graded score/feedback."""
|
| 501 |
+
__tablename__ = "translation_attempts"
|
| 502 |
+
|
| 503 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
| 504 |
+
user_id: Mapped[int] = mapped_column(
|
| 505 |
+
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
| 506 |
+
)
|
| 507 |
+
sentence_id: Mapped[int] = mapped_column(
|
| 508 |
+
Integer, ForeignKey("translation_sentences.id", ondelete="CASCADE"), nullable=False, index=True
|
| 509 |
+
)
|
| 510 |
+
user_translation: Mapped[str] = mapped_column(Text, nullable=False)
|
| 511 |
+
score: Mapped[float | None] = mapped_column(nullable=True)
|
| 512 |
+
feedback: Mapped[str | None] = mapped_column(Text)
|
| 513 |
+
model_answer: Mapped[str | None] = mapped_column(Text)
|
| 514 |
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
| 515 |
+
|
| 516 |
+
sentence: Mapped["TranslationSentence"] = relationship("TranslationSentence", back_populates="attempts")
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
# ── Email Verification ─────────────────────────────────────────────────────────
|
| 520 |
+
|
| 521 |
+
class EmailVerification(Base):
|
| 522 |
+
"""One-time 6-digit OTP for verifying a user's email address."""
|
| 523 |
+
__tablename__ = "email_verifications"
|
| 524 |
+
|
| 525 |
+
id: Mapped[int] = mapped_column(Integer, primary_key=True)
|
| 526 |
+
user_id: Mapped[int] = mapped_column(
|
| 527 |
+
Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
| 528 |
+
)
|
| 529 |
+
code_hash: Mapped[str] = mapped_column(String(64), nullable=False)
|
| 530 |
+
expires_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
| 531 |
+
used_at: Mapped[datetime | None] = mapped_column(DateTime(timezone=True))
|
| 532 |
+
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), server_default=func.now())
|
| 533 |
+
|
| 534 |
+
user: Mapped["User"] = relationship("User", back_populates="email_verifications")
|
backend/app/main.py
CHANGED
|
@@ -42,6 +42,8 @@ from app.routers.vocabulary import router as vocabulary_router, annotations_rout
|
|
| 42 |
from app.routers.leaderboard import router as leaderboard_router
|
| 43 |
from app.routers.shadowing import router as shadowing_router
|
| 44 |
from app.routers.mock_exams import router as mock_exams_router
|
|
|
|
|
|
|
| 45 |
|
| 46 |
# ── Logging ──────────────────────────────────────────────────────────────────
|
| 47 |
logging.basicConfig(
|
|
@@ -98,6 +100,19 @@ async def lifespan(app: FastAPI):
|
|
| 98 |
logger.info("Mock data index warmed up (%d mock tests).", count)
|
| 99 |
except Exception as exc:
|
| 100 |
logger.warning("Mock data index warmup skipped: %s", exc)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
yield
|
| 102 |
logger.info("Shutting down — disposing engine …")
|
| 103 |
await engine.dispose()
|
|
@@ -108,8 +123,9 @@ app = FastAPI(
|
|
| 108 |
title=settings.APP_NAME,
|
| 109 |
description="IELTS Learning Platform API",
|
| 110 |
version="1.0.0",
|
| 111 |
-
docs_url="/docs",
|
| 112 |
-
redoc_url="/redoc",
|
|
|
|
| 113 |
lifespan=lifespan,
|
| 114 |
)
|
| 115 |
|
|
@@ -133,14 +149,31 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|
| 133 |
response.headers["X-Frame-Options"] = "DENY"
|
| 134 |
response.headers["X-XSS-Protection"] = "1; mode=block"
|
| 135 |
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
| 136 |
-
if
|
|
|
|
|
|
|
|
|
|
| 137 |
response.headers["Strict-Transport-Security"] = (
|
| 138 |
"max-age=31536000; includeSubDomains"
|
| 139 |
)
|
| 140 |
return response
|
| 141 |
|
| 142 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
app.add_middleware(SecurityHeadersMiddleware)
|
|
|
|
| 144 |
app.add_middleware(CsrfMiddleware)
|
| 145 |
|
| 146 |
if settings.METRICS_ENABLED:
|
|
@@ -162,6 +195,11 @@ uploads_dir.mkdir(parents=True, exist_ok=True)
|
|
| 162 |
if settings.STORAGE_BACKEND.lower() != "s3":
|
| 163 |
app.mount("/uploads", StaticFiles(directory=str(uploads_dir)), name="uploads")
|
| 164 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 165 |
# ── Routers ───────────────────────────────────────────────────────────────────
|
| 166 |
app.include_router(auth.router)
|
| 167 |
app.include_router(history.router)
|
|
@@ -176,6 +214,8 @@ app.include_router(leaderboard_router)
|
|
| 176 |
app.include_router(shadowing_router)
|
| 177 |
app.include_router(admin.router)
|
| 178 |
app.include_router(mock_exams_router)
|
|
|
|
|
|
|
| 179 |
|
| 180 |
if settings.METRICS_ENABLED:
|
| 181 |
from app.core.metrics import metrics_endpoint
|
|
|
|
| 42 |
from app.routers.leaderboard import router as leaderboard_router
|
| 43 |
from app.routers.shadowing import router as shadowing_router
|
| 44 |
from app.routers.mock_exams import router as mock_exams_router
|
| 45 |
+
from app.routers.translation import router as translation_router
|
| 46 |
+
from app.routers.pronunciation import router as pronunciation_router
|
| 47 |
|
| 48 |
# ── Logging ──────────────────────────────────────────────────────────────────
|
| 49 |
logging.basicConfig(
|
|
|
|
| 100 |
logger.info("Mock data index warmed up (%d mock tests).", count)
|
| 101 |
except Exception as exc:
|
| 102 |
logger.warning("Mock data index warmup skipped: %s", exc)
|
| 103 |
+
|
| 104 |
+
# Seed translation practice data if tables are empty
|
| 105 |
+
try:
|
| 106 |
+
from app.db.database import AsyncSessionLocal
|
| 107 |
+
from app.services.translation_service import TranslationService
|
| 108 |
+
|
| 109 |
+
async with AsyncSessionLocal() as seed_db:
|
| 110 |
+
seeded = await TranslationService(seed_db).seed_if_empty()
|
| 111 |
+
if seeded:
|
| 112 |
+
logger.info("Translation practice seed data loaded.")
|
| 113 |
+
except Exception as exc:
|
| 114 |
+
logger.warning("Translation seed skipped: %s", exc)
|
| 115 |
+
|
| 116 |
yield
|
| 117 |
logger.info("Shutting down — disposing engine …")
|
| 118 |
await engine.dispose()
|
|
|
|
| 123 |
title=settings.APP_NAME,
|
| 124 |
description="IELTS Learning Platform API",
|
| 125 |
version="1.0.0",
|
| 126 |
+
docs_url=None if settings.ENVIRONMENT == "production" else "/docs",
|
| 127 |
+
redoc_url=None if settings.ENVIRONMENT == "production" else "/redoc",
|
| 128 |
+
openapi_url=None if settings.ENVIRONMENT == "production" else "/openapi.json",
|
| 129 |
lifespan=lifespan,
|
| 130 |
)
|
| 131 |
|
|
|
|
| 149 |
response.headers["X-Frame-Options"] = "DENY"
|
| 150 |
response.headers["X-XSS-Protection"] = "1; mode=block"
|
| 151 |
response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
|
| 152 |
+
if settings.ENVIRONMENT == "production":
|
| 153 |
+
response.headers["Content-Security-Policy"] = "default-src 'none'; frame-ancestors 'none'"
|
| 154 |
+
scheme = request.headers.get("x-forwarded-proto", request.url.scheme)
|
| 155 |
+
if scheme == "https" or settings.ENVIRONMENT == "production":
|
| 156 |
response.headers["Strict-Transport-Security"] = (
|
| 157 |
"max-age=31536000; includeSubDomains"
|
| 158 |
)
|
| 159 |
return response
|
| 160 |
|
| 161 |
|
| 162 |
+
class ProxyHeadersMiddleware(BaseHTTPMiddleware):
|
| 163 |
+
"""Trust X-Forwarded-* from reverse proxy for scheme/client IP."""
|
| 164 |
+
|
| 165 |
+
async def dispatch(self, request, call_next):
|
| 166 |
+
forwarded_proto = request.headers.get("x-forwarded-proto")
|
| 167 |
+
if forwarded_proto:
|
| 168 |
+
request.scope["scheme"] = forwarded_proto.split(",", 1)[0].strip()
|
| 169 |
+
forwarded_host = request.headers.get("x-forwarded-host")
|
| 170 |
+
if forwarded_host:
|
| 171 |
+
request.scope["server"] = (forwarded_host.split(",", 1)[0].strip(), 443)
|
| 172 |
+
return await call_next(request)
|
| 173 |
+
|
| 174 |
+
|
| 175 |
app.add_middleware(SecurityHeadersMiddleware)
|
| 176 |
+
app.add_middleware(ProxyHeadersMiddleware)
|
| 177 |
app.add_middleware(CsrfMiddleware)
|
| 178 |
|
| 179 |
if settings.METRICS_ENABLED:
|
|
|
|
| 195 |
if settings.STORAGE_BACKEND.lower() != "s3":
|
| 196 |
app.mount("/uploads", StaticFiles(directory=str(uploads_dir)), name="uploads")
|
| 197 |
|
| 198 |
+
# Serve locally-stored quiz images (UUID-named PNGs from data/assets/images)
|
| 199 |
+
_data_images_dir = Path("data/assets/images")
|
| 200 |
+
if _data_images_dir.exists():
|
| 201 |
+
app.mount("/data-assets/images", StaticFiles(directory=str(_data_images_dir)), name="data_images")
|
| 202 |
+
|
| 203 |
# ── Routers ───────────────────────────────────────────────────────────────────
|
| 204 |
app.include_router(auth.router)
|
| 205 |
app.include_router(history.router)
|
|
|
|
| 214 |
app.include_router(shadowing_router)
|
| 215 |
app.include_router(admin.router)
|
| 216 |
app.include_router(mock_exams_router)
|
| 217 |
+
app.include_router(translation_router)
|
| 218 |
+
app.include_router(pronunciation_router)
|
| 219 |
|
| 220 |
if settings.METRICS_ENABLED:
|
| 221 |
from app.core.metrics import metrics_endpoint
|
backend/app/repositories/email_verification_repository.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
app/repositories/email_verification_repository.py
|
| 3 |
+
──────────────────────────────────────────────────
|
| 4 |
+
CRUD for email OTP verification tokens.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from datetime import datetime, timezone
|
| 10 |
+
|
| 11 |
+
from sqlalchemy import delete, select
|
| 12 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 13 |
+
|
| 14 |
+
from app.db.models import EmailVerification
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class EmailVerificationRepository:
|
| 18 |
+
def __init__(self, db: AsyncSession) -> None:
|
| 19 |
+
self._db = db
|
| 20 |
+
|
| 21 |
+
async def create(self, user_id: int, code_hash: str, expires_at: datetime) -> EmailVerification:
|
| 22 |
+
row = EmailVerification(user_id=user_id, code_hash=code_hash, expires_at=expires_at)
|
| 23 |
+
self._db.add(row)
|
| 24 |
+
await self._db.flush()
|
| 25 |
+
await self._db.refresh(row)
|
| 26 |
+
return row
|
| 27 |
+
|
| 28 |
+
async def get_latest_unused(self, user_id: int) -> EmailVerification | None:
|
| 29 |
+
"""Return the most recent unused (not expired, not used) OTP for a user."""
|
| 30 |
+
now = datetime.now(timezone.utc)
|
| 31 |
+
result = await self._db.execute(
|
| 32 |
+
select(EmailVerification)
|
| 33 |
+
.where(
|
| 34 |
+
EmailVerification.user_id == user_id,
|
| 35 |
+
EmailVerification.used_at.is_(None),
|
| 36 |
+
EmailVerification.expires_at > now,
|
| 37 |
+
)
|
| 38 |
+
.order_by(EmailVerification.created_at.desc())
|
| 39 |
+
.limit(1)
|
| 40 |
+
)
|
| 41 |
+
return result.scalar_one_or_none()
|
| 42 |
+
|
| 43 |
+
async def mark_used(self, row: EmailVerification) -> None:
|
| 44 |
+
row.used_at = datetime.now(timezone.utc)
|
| 45 |
+
self._db.add(row)
|
| 46 |
+
await self._db.flush()
|
| 47 |
+
|
| 48 |
+
async def invalidate_all_for_user(self, user_id: int) -> None:
|
| 49 |
+
"""Expire all existing OTPs for a user before issuing a new one."""
|
| 50 |
+
await self._db.execute(
|
| 51 |
+
delete(EmailVerification).where(EmailVerification.user_id == user_id)
|
| 52 |
+
)
|
| 53 |
+
await self._db.flush()
|
backend/app/repositories/profile_repository.py
CHANGED
|
@@ -25,8 +25,13 @@ class ProfileRepository:
|
|
| 25 |
result = await self._db.execute(select(UserProfile).where(UserProfile.user_id == user_id))
|
| 26 |
return result.scalar_one_or_none()
|
| 27 |
|
| 28 |
-
async def create_empty(
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
self._db.add(profile)
|
| 31 |
await self._db.flush()
|
| 32 |
await self._db.refresh(profile)
|
|
|
|
| 25 |
result = await self._db.execute(select(UserProfile).where(UserProfile.user_id == user_id))
|
| 26 |
return result.scalar_one_or_none()
|
| 27 |
|
| 28 |
+
async def create_empty(
|
| 29 |
+
self,
|
| 30 |
+
user_id: int,
|
| 31 |
+
full_name: str | None = None,
|
| 32 |
+
avatar_url: str | None = None,
|
| 33 |
+
) -> UserProfile:
|
| 34 |
+
profile = UserProfile(user_id=user_id, full_name=full_name, avatar_url=avatar_url)
|
| 35 |
self._db.add(profile)
|
| 36 |
await self._db.flush()
|
| 37 |
await self._db.refresh(profile)
|
backend/app/repositories/translation_repository.py
ADDED
|
@@ -0,0 +1,189 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Translation Practice repository — pure data access layer.
|
| 3 |
+
"""
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
from sqlalchemy import select, func
|
| 7 |
+
from sqlalchemy.orm import selectinload
|
| 8 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 9 |
+
|
| 10 |
+
from app.db.models import (
|
| 11 |
+
TranslationStep,
|
| 12 |
+
TranslationTopic,
|
| 13 |
+
TranslationSentence,
|
| 14 |
+
TranslationAttempt,
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class TranslationRepository:
|
| 19 |
+
def __init__(self, db: AsyncSession) -> None:
|
| 20 |
+
self._db = db
|
| 21 |
+
|
| 22 |
+
# ── Steps ────────────────────────────────────────────────────────────────
|
| 23 |
+
|
| 24 |
+
async def list_steps(self) -> list[TranslationStep]:
|
| 25 |
+
result = await self._db.execute(
|
| 26 |
+
select(TranslationStep).order_by(TranslationStep.order)
|
| 27 |
+
)
|
| 28 |
+
return list(result.scalars().all())
|
| 29 |
+
|
| 30 |
+
async def get_step(self, step_id: int) -> TranslationStep | None:
|
| 31 |
+
result = await self._db.execute(
|
| 32 |
+
select(TranslationStep)
|
| 33 |
+
.where(TranslationStep.id == step_id)
|
| 34 |
+
.options(selectinload(TranslationStep.topics))
|
| 35 |
+
)
|
| 36 |
+
return result.scalar_one_or_none()
|
| 37 |
+
|
| 38 |
+
async def count_steps(self) -> int:
|
| 39 |
+
result = await self._db.execute(select(func.count()).select_from(TranslationStep))
|
| 40 |
+
return result.scalar_one()
|
| 41 |
+
|
| 42 |
+
# ── Topics ───────────────────────────────────────────────────────────────
|
| 43 |
+
|
| 44 |
+
async def list_topics_for_step(self, step_id: int) -> list[TranslationTopic]:
|
| 45 |
+
result = await self._db.execute(
|
| 46 |
+
select(TranslationTopic)
|
| 47 |
+
.where(TranslationTopic.step_id == step_id)
|
| 48 |
+
.order_by(TranslationTopic.order)
|
| 49 |
+
)
|
| 50 |
+
return list(result.scalars().all())
|
| 51 |
+
|
| 52 |
+
async def get_topic(self, topic_id: int) -> TranslationTopic | None:
|
| 53 |
+
result = await self._db.execute(
|
| 54 |
+
select(TranslationTopic).where(TranslationTopic.id == topic_id)
|
| 55 |
+
)
|
| 56 |
+
return result.scalar_one_or_none()
|
| 57 |
+
|
| 58 |
+
async def count_sentences_in_topic(self, topic_id: int) -> int:
|
| 59 |
+
result = await self._db.execute(
|
| 60 |
+
select(func.count())
|
| 61 |
+
.select_from(TranslationSentence)
|
| 62 |
+
.where(TranslationSentence.topic_id == topic_id)
|
| 63 |
+
)
|
| 64 |
+
return result.scalar_one()
|
| 65 |
+
|
| 66 |
+
# ── Sentences ────────────────────────────────────────────────────────────
|
| 67 |
+
|
| 68 |
+
async def list_sentences_for_topic(self, topic_id: int) -> list[TranslationSentence]:
|
| 69 |
+
result = await self._db.execute(
|
| 70 |
+
select(TranslationSentence)
|
| 71 |
+
.where(TranslationSentence.topic_id == topic_id)
|
| 72 |
+
.order_by(TranslationSentence.order)
|
| 73 |
+
)
|
| 74 |
+
return list(result.scalars().all())
|
| 75 |
+
|
| 76 |
+
async def get_sentence(self, sentence_id: int) -> TranslationSentence | None:
|
| 77 |
+
result = await self._db.execute(
|
| 78 |
+
select(TranslationSentence).where(TranslationSentence.id == sentence_id)
|
| 79 |
+
)
|
| 80 |
+
return result.scalar_one_or_none()
|
| 81 |
+
|
| 82 |
+
# ── Attempts ─────────────────────────────────────────────────────────────
|
| 83 |
+
|
| 84 |
+
async def create_attempt(
|
| 85 |
+
self,
|
| 86 |
+
user_id: int,
|
| 87 |
+
sentence_id: int,
|
| 88 |
+
user_translation: str,
|
| 89 |
+
score: float | None,
|
| 90 |
+
feedback: str | None,
|
| 91 |
+
model_answer: str | None,
|
| 92 |
+
) -> TranslationAttempt:
|
| 93 |
+
attempt = TranslationAttempt(
|
| 94 |
+
user_id=user_id,
|
| 95 |
+
sentence_id=sentence_id,
|
| 96 |
+
user_translation=user_translation,
|
| 97 |
+
score=score,
|
| 98 |
+
feedback=feedback,
|
| 99 |
+
model_answer=model_answer,
|
| 100 |
+
)
|
| 101 |
+
self._db.add(attempt)
|
| 102 |
+
await self._db.flush()
|
| 103 |
+
await self._db.refresh(attempt)
|
| 104 |
+
return attempt
|
| 105 |
+
|
| 106 |
+
async def get_user_attempt_for_sentence(
|
| 107 |
+
self, user_id: int, sentence_id: int
|
| 108 |
+
) -> TranslationAttempt | None:
|
| 109 |
+
"""Return the latest attempt for a (user, sentence) pair."""
|
| 110 |
+
result = await self._db.execute(
|
| 111 |
+
select(TranslationAttempt)
|
| 112 |
+
.where(
|
| 113 |
+
TranslationAttempt.user_id == user_id,
|
| 114 |
+
TranslationAttempt.sentence_id == sentence_id,
|
| 115 |
+
)
|
| 116 |
+
.order_by(TranslationAttempt.created_at.desc())
|
| 117 |
+
.limit(1)
|
| 118 |
+
)
|
| 119 |
+
return result.scalar_one_or_none()
|
| 120 |
+
|
| 121 |
+
async def count_completed_sentences(self, user_id: int, topic_id: int) -> int:
|
| 122 |
+
"""How many distinct sentences in this topic the user has attempted."""
|
| 123 |
+
subq = (
|
| 124 |
+
select(TranslationSentence.id)
|
| 125 |
+
.where(TranslationSentence.topic_id == topic_id)
|
| 126 |
+
.scalar_subquery()
|
| 127 |
+
)
|
| 128 |
+
result = await self._db.execute(
|
| 129 |
+
select(func.count(func.distinct(TranslationAttempt.sentence_id)))
|
| 130 |
+
.where(
|
| 131 |
+
TranslationAttempt.user_id == user_id,
|
| 132 |
+
TranslationAttempt.sentence_id.in_(subq),
|
| 133 |
+
)
|
| 134 |
+
)
|
| 135 |
+
return result.scalar_one()
|
| 136 |
+
|
| 137 |
+
# ── Seed helpers ─────────────────────────────────────────────────────────
|
| 138 |
+
|
| 139 |
+
async def create_step(
|
| 140 |
+
self,
|
| 141 |
+
order: int,
|
| 142 |
+
title: str,
|
| 143 |
+
description: str,
|
| 144 |
+
badge_label: str | None,
|
| 145 |
+
badge_color: str,
|
| 146 |
+
icon_emoji: str,
|
| 147 |
+
) -> TranslationStep:
|
| 148 |
+
step = TranslationStep(
|
| 149 |
+
order=order,
|
| 150 |
+
title=title,
|
| 151 |
+
description=description,
|
| 152 |
+
badge_label=badge_label,
|
| 153 |
+
badge_color=badge_color,
|
| 154 |
+
icon_emoji=icon_emoji,
|
| 155 |
+
)
|
| 156 |
+
self._db.add(step)
|
| 157 |
+
await self._db.flush()
|
| 158 |
+
await self._db.refresh(step)
|
| 159 |
+
return step
|
| 160 |
+
|
| 161 |
+
async def create_topic(
|
| 162 |
+
self, step_id: int, order: int, title: str, description: str
|
| 163 |
+
) -> TranslationTopic:
|
| 164 |
+
topic = TranslationTopic(
|
| 165 |
+
step_id=step_id, order=order, title=title, description=description
|
| 166 |
+
)
|
| 167 |
+
self._db.add(topic)
|
| 168 |
+
await self._db.flush()
|
| 169 |
+
await self._db.refresh(topic)
|
| 170 |
+
return topic
|
| 171 |
+
|
| 172 |
+
async def create_sentence(
|
| 173 |
+
self,
|
| 174 |
+
topic_id: int,
|
| 175 |
+
order: int,
|
| 176 |
+
vietnamese: str,
|
| 177 |
+
english: str,
|
| 178 |
+
explanation: str | None,
|
| 179 |
+
) -> TranslationSentence:
|
| 180 |
+
sentence = TranslationSentence(
|
| 181 |
+
topic_id=topic_id,
|
| 182 |
+
order=order,
|
| 183 |
+
vietnamese=vietnamese,
|
| 184 |
+
english=english,
|
| 185 |
+
explanation=explanation,
|
| 186 |
+
)
|
| 187 |
+
self._db.add(sentence)
|
| 188 |
+
await self._db.flush()
|
| 189 |
+
return sentence
|
backend/app/repositories/user_repository.py
CHANGED
|
@@ -16,20 +16,50 @@ class UserRepository:
|
|
| 16 |
self._db = db
|
| 17 |
|
| 18 |
async def get_by_id(self, user_id: int) -> User | None:
|
| 19 |
-
"""Fetch a user by primary key."""
|
| 20 |
result = await self._db.execute(select(User).where(User.id == user_id))
|
| 21 |
return result.scalar_one_or_none()
|
| 22 |
|
| 23 |
async def get_by_email(self, email: str) -> User | None:
|
| 24 |
-
"""Fetch a user by email address (case-sensitive)."""
|
| 25 |
result = await self._db.execute(select(User).where(User.email == email))
|
| 26 |
return result.scalar_one_or_none()
|
| 27 |
|
| 28 |
-
async def
|
| 29 |
-
|
| 30 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
self._db.add(user)
|
| 32 |
-
await self._db.flush()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
await self._db.refresh(user)
|
| 34 |
return user
|
| 35 |
|
|
|
|
| 16 |
self._db = db
|
| 17 |
|
| 18 |
async def get_by_id(self, user_id: int) -> User | None:
|
|
|
|
| 19 |
result = await self._db.execute(select(User).where(User.id == user_id))
|
| 20 |
return result.scalar_one_or_none()
|
| 21 |
|
| 22 |
async def get_by_email(self, email: str) -> User | None:
|
|
|
|
| 23 |
result = await self._db.execute(select(User).where(User.email == email))
|
| 24 |
return result.scalar_one_or_none()
|
| 25 |
|
| 26 |
+
async def get_by_google_id(self, google_id: str) -> User | None:
|
| 27 |
+
result = await self._db.execute(select(User).where(User.google_id == google_id))
|
| 28 |
+
return result.scalar_one_or_none()
|
| 29 |
+
|
| 30 |
+
async def create(
|
| 31 |
+
self,
|
| 32 |
+
email: str,
|
| 33 |
+
password_hash: str,
|
| 34 |
+
role: str = "user",
|
| 35 |
+
google_id: str | None = None,
|
| 36 |
+
is_verified: bool = False,
|
| 37 |
+
auth_provider: str = "email",
|
| 38 |
+
) -> User:
|
| 39 |
+
user = User(
|
| 40 |
+
email=email,
|
| 41 |
+
password_hash=password_hash,
|
| 42 |
+
role=role,
|
| 43 |
+
google_id=google_id,
|
| 44 |
+
is_verified=is_verified,
|
| 45 |
+
auth_provider=auth_provider,
|
| 46 |
+
)
|
| 47 |
+
self._db.add(user)
|
| 48 |
+
await self._db.flush()
|
| 49 |
+
await self._db.refresh(user)
|
| 50 |
+
return user
|
| 51 |
+
|
| 52 |
+
async def mark_verified(self, user: User) -> User:
|
| 53 |
+
user.is_verified = True
|
| 54 |
self._db.add(user)
|
| 55 |
+
await self._db.flush()
|
| 56 |
+
await self._db.refresh(user)
|
| 57 |
+
return user
|
| 58 |
+
|
| 59 |
+
async def update_google_id(self, user: User, google_id: str) -> User:
|
| 60 |
+
user.google_id = google_id
|
| 61 |
+
self._db.add(user)
|
| 62 |
+
await self._db.flush()
|
| 63 |
await self._db.refresh(user)
|
| 64 |
return user
|
| 65 |
|
backend/app/routers/auth.py
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
"""
|
| 2 |
app/routers/auth.py
|
| 3 |
────────────────────
|
| 4 |
-
Auth endpoints: register
|
| 5 |
-
|
| 6 |
"""
|
| 7 |
|
| 8 |
from fastapi import APIRouter, Depends, Request, status
|
|
@@ -20,7 +20,10 @@ from app.schemas import (
|
|
| 20 |
AuthLogoutRequest,
|
| 21 |
AuthRefreshRequest,
|
| 22 |
ForgotPasswordRequest,
|
|
|
|
| 23 |
MessageResponse,
|
|
|
|
|
|
|
| 24 |
ResetPasswordRequest,
|
| 25 |
Token,
|
| 26 |
UserCreate,
|
|
@@ -34,18 +37,64 @@ router = APIRouter(prefix="/auth", tags=["Authentication"])
|
|
| 34 |
|
| 35 |
@router.post(
|
| 36 |
"/register",
|
| 37 |
-
response_model=
|
| 38 |
status_code=status.HTTP_201_CREATED,
|
| 39 |
-
summary="Register a new user",
|
| 40 |
)
|
| 41 |
@limiter.limit("5/minute")
|
| 42 |
async def register(
|
| 43 |
request: Request,
|
| 44 |
payload: UserCreate,
|
| 45 |
db: AsyncSession = Depends(get_db),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
) -> JSONResponse:
|
| 47 |
service = AuthService(db)
|
| 48 |
-
token = await service.
|
| 49 |
return attach_auth_cookies(token)
|
| 50 |
|
| 51 |
|
|
@@ -80,7 +129,6 @@ async def refresh(
|
|
| 80 |
refresh_token = read_refresh_token(request, body_token)
|
| 81 |
if not refresh_token:
|
| 82 |
from fastapi import HTTPException
|
| 83 |
-
|
| 84 |
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing refresh token")
|
| 85 |
service = AuthService(db)
|
| 86 |
token = await service.refresh(refresh_token)
|
|
@@ -107,15 +155,6 @@ async def logout(
|
|
| 107 |
return response
|
| 108 |
|
| 109 |
|
| 110 |
-
@router.post(
|
| 111 |
-
"/verify-email",
|
| 112 |
-
response_model=MessageResponse,
|
| 113 |
-
summary="Verify email token (mock in local dev)",
|
| 114 |
-
)
|
| 115 |
-
async def verify_email(_: VerifyEmailRequest) -> MessageResponse:
|
| 116 |
-
return MessageResponse(message="Email verified (mock)")
|
| 117 |
-
|
| 118 |
-
|
| 119 |
@router.post(
|
| 120 |
"/forgot-password",
|
| 121 |
response_model=MessageResponse,
|
|
|
|
| 1 |
"""
|
| 2 |
app/routers/auth.py
|
| 3 |
────────────────────
|
| 4 |
+
Auth endpoints: register, login, refresh, logout,
|
| 5 |
+
email verification, Google OAuth.
|
| 6 |
"""
|
| 7 |
|
| 8 |
from fastapi import APIRouter, Depends, Request, status
|
|
|
|
| 20 |
AuthLogoutRequest,
|
| 21 |
AuthRefreshRequest,
|
| 22 |
ForgotPasswordRequest,
|
| 23 |
+
GoogleAuthRequest,
|
| 24 |
MessageResponse,
|
| 25 |
+
RegisterResponse,
|
| 26 |
+
ResendVerificationRequest,
|
| 27 |
ResetPasswordRequest,
|
| 28 |
Token,
|
| 29 |
UserCreate,
|
|
|
|
| 37 |
|
| 38 |
@router.post(
|
| 39 |
"/register",
|
| 40 |
+
response_model=RegisterResponse,
|
| 41 |
status_code=status.HTTP_201_CREATED,
|
| 42 |
+
summary="Register a new user (requires email verification)",
|
| 43 |
)
|
| 44 |
@limiter.limit("5/minute")
|
| 45 |
async def register(
|
| 46 |
request: Request,
|
| 47 |
payload: UserCreate,
|
| 48 |
db: AsyncSession = Depends(get_db),
|
| 49 |
+
) -> RegisterResponse:
|
| 50 |
+
service = AuthService(db)
|
| 51 |
+
return await service.register(payload)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@router.post(
|
| 55 |
+
"/verify-email",
|
| 56 |
+
response_model=Token,
|
| 57 |
+
summary="Verify email OTP and receive a token pair",
|
| 58 |
+
)
|
| 59 |
+
@limiter.limit("10/minute")
|
| 60 |
+
async def verify_email(
|
| 61 |
+
request: Request,
|
| 62 |
+
payload: VerifyEmailRequest,
|
| 63 |
+
db: AsyncSession = Depends(get_db),
|
| 64 |
+
) -> JSONResponse:
|
| 65 |
+
service = AuthService(db)
|
| 66 |
+
token = await service.verify_email_otp(payload)
|
| 67 |
+
return attach_auth_cookies(token)
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@router.post(
|
| 71 |
+
"/resend-verification",
|
| 72 |
+
response_model=MessageResponse,
|
| 73 |
+
summary="Re-send the email verification OTP",
|
| 74 |
+
)
|
| 75 |
+
@limiter.limit("3/minute")
|
| 76 |
+
async def resend_verification(
|
| 77 |
+
request: Request,
|
| 78 |
+
payload: ResendVerificationRequest,
|
| 79 |
+
db: AsyncSession = Depends(get_db),
|
| 80 |
+
) -> MessageResponse:
|
| 81 |
+
service = AuthService(db)
|
| 82 |
+
return await service.resend_verification(payload)
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
@router.post(
|
| 86 |
+
"/google",
|
| 87 |
+
response_model=Token,
|
| 88 |
+
summary="Exchange Google authorization code for a token pair",
|
| 89 |
+
)
|
| 90 |
+
@limiter.limit("10/minute")
|
| 91 |
+
async def google_auth(
|
| 92 |
+
request: Request,
|
| 93 |
+
payload: GoogleAuthRequest,
|
| 94 |
+
db: AsyncSession = Depends(get_db),
|
| 95 |
) -> JSONResponse:
|
| 96 |
service = AuthService(db)
|
| 97 |
+
token = await service.google_auth(payload)
|
| 98 |
return attach_auth_cookies(token)
|
| 99 |
|
| 100 |
|
|
|
|
| 129 |
refresh_token = read_refresh_token(request, body_token)
|
| 130 |
if not refresh_token:
|
| 131 |
from fastapi import HTTPException
|
|
|
|
| 132 |
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing refresh token")
|
| 133 |
service = AuthService(db)
|
| 134 |
token = await service.refresh(refresh_token)
|
|
|
|
| 155 |
return response
|
| 156 |
|
| 157 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 158 |
@router.post(
|
| 159 |
"/forgot-password",
|
| 160 |
response_model=MessageResponse,
|
backend/app/routers/history.py
CHANGED
|
@@ -5,7 +5,7 @@ History endpoints: list practice attempts (paginated) and save new results.
|
|
| 5 |
All routes require a valid JWT bearer token.
|
| 6 |
"""
|
| 7 |
|
| 8 |
-
from fastapi import APIRouter, Depends, Query, Request, status
|
| 9 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 10 |
|
| 11 |
from app.core.dependencies import get_current_user
|
|
@@ -59,8 +59,13 @@ async def save_history(
|
|
| 59 |
|
| 60 |
This is the key integration endpoint called after every learning session.
|
| 61 |
"""
|
| 62 |
-
|
| 63 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 64 |
|
| 65 |
|
| 66 |
@router.get(
|
|
|
|
| 5 |
All routes require a valid JWT bearer token.
|
| 6 |
"""
|
| 7 |
|
| 8 |
+
from fastapi import APIRouter, Depends, HTTPException, Query, Request, status
|
| 9 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 10 |
|
| 11 |
from app.core.dependencies import get_current_user
|
|
|
|
| 59 |
|
| 60 |
This is the key integration endpoint called after every learning session.
|
| 61 |
"""
|
| 62 |
+
raise HTTPException(
|
| 63 |
+
status_code=status.HTTP_410_GONE,
|
| 64 |
+
detail=(
|
| 65 |
+
"Direct history submission is disabled. Use the skill-specific submit "
|
| 66 |
+
"endpoints so scores, bands, XP, and progress are computed server-side."
|
| 67 |
+
),
|
| 68 |
+
)
|
| 69 |
|
| 70 |
|
| 71 |
@router.get(
|
backend/app/routers/pronunciation.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Word-level pronunciation scoring (Wav2Vec2 + CMU phonemes)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import asyncio
|
| 6 |
+
import logging
|
| 7 |
+
import re
|
| 8 |
+
|
| 9 |
+
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
| 10 |
+
|
| 11 |
+
from app.core.dependencies import get_current_user
|
| 12 |
+
from app.core.rate_limit import limiter
|
| 13 |
+
from app.core.upload import ALLOWED_AUDIO_EXTENSIONS, MAX_AUDIO_UPLOAD_SIZE, read_upload_limited
|
| 14 |
+
from app.db.models import User
|
| 15 |
+
from app.services.phoneme_scorer import get_expected_word_info, get_phoneme_scorer
|
| 16 |
+
from app.services.pronunciation_audio import audio_bytes_to_waveform
|
| 17 |
+
|
| 18 |
+
logger = logging.getLogger(__name__)
|
| 19 |
+
|
| 20 |
+
router = APIRouter(prefix="/pronunciation", tags=["Pronunciation"])
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def _clean_word(word: str) -> str:
|
| 24 |
+
return re.sub(r"[^a-zA-Z'-]", "", (word or "").strip())
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@router.get("/word/{word}/expected")
|
| 28 |
+
async def get_word_expected_phonemes(
|
| 29 |
+
word: str,
|
| 30 |
+
_user: User = Depends(get_current_user),
|
| 31 |
+
):
|
| 32 |
+
"""Return expected CMU phonemes/IPA for display before recording."""
|
| 33 |
+
cleaned = _clean_word(word)
|
| 34 |
+
if not cleaned:
|
| 35 |
+
raise HTTPException(status_code=400, detail="word is required")
|
| 36 |
+
info = get_expected_word_info(cleaned)
|
| 37 |
+
if not info:
|
| 38 |
+
raise HTTPException(
|
| 39 |
+
status_code=422,
|
| 40 |
+
detail=f"Từ '{cleaned}' không có trong CMU Pronouncing Dictionary.",
|
| 41 |
+
)
|
| 42 |
+
return info
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
@limiter.limit("20/minute")
|
| 46 |
+
@router.post("/word")
|
| 47 |
+
async def score_word_pronunciation(
|
| 48 |
+
request: Request,
|
| 49 |
+
word: str = Form(...),
|
| 50 |
+
audio: UploadFile = File(...),
|
| 51 |
+
_user: User = Depends(get_current_user),
|
| 52 |
+
):
|
| 53 |
+
"""Score single-word pronunciation from browser-recorded audio."""
|
| 54 |
+
cleaned = _clean_word(word)
|
| 55 |
+
if not cleaned:
|
| 56 |
+
raise HTTPException(status_code=400, detail="word is required")
|
| 57 |
+
|
| 58 |
+
if not get_expected_word_info(cleaned):
|
| 59 |
+
raise HTTPException(
|
| 60 |
+
status_code=422,
|
| 61 |
+
detail=f"Từ '{cleaned}' không có trong CMU Pronouncing Dictionary.",
|
| 62 |
+
)
|
| 63 |
+
|
| 64 |
+
suffix = (audio.filename or "audio.webm").rsplit(".", 1)
|
| 65 |
+
ext = f".{suffix[-1].lower()}" if len(suffix) > 1 else ".webm"
|
| 66 |
+
if ext not in ALLOWED_AUDIO_EXTENSIONS:
|
| 67 |
+
raise HTTPException(status_code=400, detail="Unsupported audio file type")
|
| 68 |
+
|
| 69 |
+
audio_bytes = await read_upload_limited(audio, MAX_AUDIO_UPLOAD_SIZE)
|
| 70 |
+
|
| 71 |
+
try:
|
| 72 |
+
waveform, sample_rate = await asyncio.to_thread(
|
| 73 |
+
audio_bytes_to_waveform,
|
| 74 |
+
audio_bytes,
|
| 75 |
+
audio.filename or "audio.webm",
|
| 76 |
+
)
|
| 77 |
+
result = await asyncio.to_thread(
|
| 78 |
+
get_phoneme_scorer().score_word,
|
| 79 |
+
waveform,
|
| 80 |
+
sample_rate,
|
| 81 |
+
cleaned,
|
| 82 |
+
)
|
| 83 |
+
return result.to_dict()
|
| 84 |
+
except ValueError as exc:
|
| 85 |
+
raise HTTPException(status_code=422, detail=str(exc)) from exc
|
| 86 |
+
except Exception as exc:
|
| 87 |
+
logger.exception("pronunciation/word failed for %s", cleaned)
|
| 88 |
+
raise HTTPException(status_code=500, detail=f"Kiểm tra phát âm thất bại: {exc}") from exc
|
backend/app/routers/shadowing.py
CHANGED
|
@@ -10,6 +10,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|
| 10 |
from app.core.config import settings
|
| 11 |
from app.core.dependencies import get_current_user
|
| 12 |
from app.core.rate_limit import limiter
|
|
|
|
| 13 |
from app.db.database import get_db
|
| 14 |
from app.db.models import User
|
| 15 |
from app.repositories.shadowing_repository import ShadowingRepository
|
|
@@ -63,6 +64,7 @@ async def process_video(
|
|
| 63 |
level=body.level,
|
| 64 |
translate=body.translate,
|
| 65 |
user_id=user.id,
|
|
|
|
| 66 |
)
|
| 67 |
except ValueError as e:
|
| 68 |
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
@@ -168,23 +170,39 @@ async def translate_segment(
|
|
| 168 |
|
| 169 |
|
| 170 |
@router.post("/pronunciation/check")
|
|
|
|
| 171 |
async def check_pronunciation(
|
|
|
|
| 172 |
file: UploadFile = File(...),
|
| 173 |
target_text: str = Form(...),
|
| 174 |
_user: User = Depends(get_current_user),
|
| 175 |
):
|
| 176 |
-
"""Whisper transcript + pron_scorer model vs target sentence."""
|
| 177 |
if not (target_text or "").strip():
|
| 178 |
raise HTTPException(status_code=400, detail="target_text is required")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 179 |
try:
|
| 180 |
data = await check_pronunciation_from_bytes(
|
| 181 |
-
|
| 182 |
file.filename or "audio.webm",
|
| 183 |
target_text.strip(),
|
| 184 |
)
|
| 185 |
return data
|
| 186 |
except FileNotFoundError as e:
|
| 187 |
-
raise HTTPException(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 188 |
except Exception as e:
|
| 189 |
logger.exception("pronunciation check failed")
|
| 190 |
-
raise HTTPException(status_code=500, detail=
|
|
|
|
| 10 |
from app.core.config import settings
|
| 11 |
from app.core.dependencies import get_current_user
|
| 12 |
from app.core.rate_limit import limiter
|
| 13 |
+
from app.core.upload import ALLOWED_AUDIO_EXTENSIONS, MAX_AUDIO_UPLOAD_SIZE, read_upload_limited
|
| 14 |
from app.db.database import get_db
|
| 15 |
from app.db.models import User
|
| 16 |
from app.repositories.shadowing_repository import ShadowingRepository
|
|
|
|
| 64 |
level=body.level,
|
| 65 |
translate=body.translate,
|
| 66 |
user_id=user.id,
|
| 67 |
+
force_refresh=body.force_refresh,
|
| 68 |
)
|
| 69 |
except ValueError as e:
|
| 70 |
raise HTTPException(status_code=400, detail=str(e)) from e
|
|
|
|
| 170 |
|
| 171 |
|
| 172 |
@router.post("/pronunciation/check")
|
| 173 |
+
@limiter.limit("10/minute")
|
| 174 |
async def check_pronunciation(
|
| 175 |
+
request: Request,
|
| 176 |
file: UploadFile = File(...),
|
| 177 |
target_text: str = Form(...),
|
| 178 |
_user: User = Depends(get_current_user),
|
| 179 |
):
|
| 180 |
+
"""Whisper transcript + pron_scorer model vs target sentence (same pipeline as speaking)."""
|
| 181 |
if not (target_text or "").strip():
|
| 182 |
raise HTTPException(status_code=400, detail="target_text is required")
|
| 183 |
+
if len(target_text) > 1000:
|
| 184 |
+
raise HTTPException(status_code=400, detail="target_text is too long")
|
| 185 |
+
suffix = (file.filename or "audio.webm").rsplit(".", 1)
|
| 186 |
+
ext = f".{suffix[-1].lower()}" if len(suffix) > 1 else ".webm"
|
| 187 |
+
if ext not in ALLOWED_AUDIO_EXTENSIONS:
|
| 188 |
+
raise HTTPException(status_code=400, detail="Unsupported audio file type")
|
| 189 |
+
audio_bytes = await read_upload_limited(file, MAX_AUDIO_UPLOAD_SIZE)
|
| 190 |
+
if len(audio_bytes) < 512:
|
| 191 |
+
raise HTTPException(status_code=422, detail="File ghi âm quá ngắn hoặc rỗng.")
|
| 192 |
try:
|
| 193 |
data = await check_pronunciation_from_bytes(
|
| 194 |
+
audio_bytes,
|
| 195 |
file.filename or "audio.webm",
|
| 196 |
target_text.strip(),
|
| 197 |
)
|
| 198 |
return data
|
| 199 |
except FileNotFoundError as e:
|
| 200 |
+
raise HTTPException(
|
| 201 |
+
status_code=503,
|
| 202 |
+
detail="Mô hình phát âm chưa sẵn sàng. Đặt pron_scorer_best.pt vào backend/model/.",
|
| 203 |
+
) from e
|
| 204 |
+
except ValueError as e:
|
| 205 |
+
raise HTTPException(status_code=422, detail=str(e)) from e
|
| 206 |
except Exception as e:
|
| 207 |
logger.exception("pronunciation check failed")
|
| 208 |
+
raise HTTPException(status_code=500, detail=f"Kiểm tra phát âm thất bại: {e}") from e
|
backend/app/routers/speaking.py
CHANGED
|
@@ -7,7 +7,6 @@ import tempfile
|
|
| 7 |
from pathlib import Path
|
| 8 |
from typing import Any
|
| 9 |
|
| 10 |
-
import httpx
|
| 11 |
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
| 12 |
from fastapi.responses import JSONResponse
|
| 13 |
from pydantic import BaseModel
|
|
@@ -16,14 +15,14 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|
| 16 |
|
| 17 |
from app.core.config import settings
|
| 18 |
from app.core.dependencies import get_current_user
|
|
|
|
| 19 |
from app.core.rate_limit import limiter
|
|
|
|
| 20 |
from app.db.database import get_db
|
| 21 |
from app.db.models import History, User
|
| 22 |
from app.repositories.profile_repository import ProfileRepository
|
| 23 |
from app.services.speaking_ai_helpers import (
|
| 24 |
-
AI_MODEL,
|
| 25 |
OPENROUTER_KEY,
|
| 26 |
-
OPENROUTER_URL,
|
| 27 |
_call_language_cards,
|
| 28 |
_normalize_grammar_analysis,
|
| 29 |
_normalize_vocabulary_analysis,
|
|
@@ -61,8 +60,8 @@ Use short sections with bullets:
|
|
| 61 |
Avoid long essays unless the user explicitly asks for full sample answer."""
|
| 62 |
|
| 63 |
|
| 64 |
-
@router.post("/chat")
|
| 65 |
@limiter.limit("30/minute")
|
|
|
|
| 66 |
async def speaking_chat(
|
| 67 |
request: Request,
|
| 68 |
body: ChatRequest,
|
|
@@ -70,7 +69,7 @@ async def speaking_chat(
|
|
| 70 |
):
|
| 71 |
"""Proxy chat messages to OpenRouter for speaking coaching (JWT required)."""
|
| 72 |
_ = current_user
|
| 73 |
-
if not
|
| 74 |
return JSONResponse(
|
| 75 |
status_code=503,
|
| 76 |
content={"error": "AI service unavailable: OPENROUTER_API_KEY is missing"},
|
|
@@ -83,27 +82,18 @@ async def speaking_chat(
|
|
| 83 |
"content": f'Current IELTS Speaking question the student is practising: "{body.question_text}"',
|
| 84 |
})
|
| 85 |
for m in body.history[-10:]:
|
| 86 |
-
|
|
|
|
| 87 |
messages.append({"role": "user", "content": body.user_message})
|
| 88 |
|
| 89 |
-
payload = {
|
| 90 |
-
"model": AI_MODEL,
|
| 91 |
-
"messages": messages,
|
| 92 |
-
"temperature": 0.7,
|
| 93 |
-
"max_tokens": 800,
|
| 94 |
-
}
|
| 95 |
-
headers = {
|
| 96 |
-
"Authorization": f"Bearer {OPENROUTER_KEY}",
|
| 97 |
-
"HTTP-Referer": "http://localhost:3000",
|
| 98 |
-
"Content-Type": "application/json",
|
| 99 |
-
"X-Title": "LinguaIELTS",
|
| 100 |
-
}
|
| 101 |
-
|
| 102 |
try:
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
|
|
|
|
|
|
|
|
|
| 107 |
return {"reply": reply}
|
| 108 |
except Exception as exc:
|
| 109 |
logger.warning("Speaking chat OpenRouter error: %s", exc)
|
|
@@ -113,8 +103,8 @@ async def speaking_chat(
|
|
| 113 |
)
|
| 114 |
|
| 115 |
|
| 116 |
-
@router.post("/analyze-language")
|
| 117 |
@limiter.limit("20/minute")
|
|
|
|
| 118 |
async def analyze_language(
|
| 119 |
request: Request,
|
| 120 |
body: TranscriptAnalyzeRequest,
|
|
@@ -163,9 +153,12 @@ async def evaluate_speaking(
|
|
| 163 |
"""HTTP handler: save upload, optionally dispatch Celery, else run pipeline inline."""
|
| 164 |
await ProfileRepository(db).ensure_speaking_eval_allowed(current_user.id)
|
| 165 |
|
| 166 |
-
suffix = Path(file.filename or "audio.webm").suffix or ".webm"
|
|
|
|
|
|
|
|
|
|
| 167 |
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
| 168 |
-
tmp.write(
|
| 169 |
tmp_path = tmp.name
|
| 170 |
|
| 171 |
if settings.CELERY_ENABLED:
|
|
|
|
| 7 |
from pathlib import Path
|
| 8 |
from typing import Any
|
| 9 |
|
|
|
|
| 10 |
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
| 11 |
from fastapi.responses import JSONResponse
|
| 12 |
from pydantic import BaseModel
|
|
|
|
| 15 |
|
| 16 |
from app.core.config import settings
|
| 17 |
from app.core.dependencies import get_current_user
|
| 18 |
+
from app.core.openrouter_client import chat_completion, has_openrouter_keys
|
| 19 |
from app.core.rate_limit import limiter
|
| 20 |
+
from app.core.upload import ALLOWED_AUDIO_EXTENSIONS, MAX_AUDIO_UPLOAD_SIZE, read_upload_limited
|
| 21 |
from app.db.database import get_db
|
| 22 |
from app.db.models import History, User
|
| 23 |
from app.repositories.profile_repository import ProfileRepository
|
| 24 |
from app.services.speaking_ai_helpers import (
|
|
|
|
| 25 |
OPENROUTER_KEY,
|
|
|
|
| 26 |
_call_language_cards,
|
| 27 |
_normalize_grammar_analysis,
|
| 28 |
_normalize_vocabulary_analysis,
|
|
|
|
| 60 |
Avoid long essays unless the user explicitly asks for full sample answer."""
|
| 61 |
|
| 62 |
|
|
|
|
| 63 |
@limiter.limit("30/minute")
|
| 64 |
+
@router.post("/chat")
|
| 65 |
async def speaking_chat(
|
| 66 |
request: Request,
|
| 67 |
body: ChatRequest,
|
|
|
|
| 69 |
):
|
| 70 |
"""Proxy chat messages to OpenRouter for speaking coaching (JWT required)."""
|
| 71 |
_ = current_user
|
| 72 |
+
if not has_openrouter_keys():
|
| 73 |
return JSONResponse(
|
| 74 |
status_code=503,
|
| 75 |
content={"error": "AI service unavailable: OPENROUTER_API_KEY is missing"},
|
|
|
|
| 82 |
"content": f'Current IELTS Speaking question the student is practising: "{body.question_text}"',
|
| 83 |
})
|
| 84 |
for m in body.history[-10:]:
|
| 85 |
+
role = m.role if m.role in {"user", "assistant"} else "user"
|
| 86 |
+
messages.append({"role": role, "content": m.content})
|
| 87 |
messages.append({"role": "user", "content": body.user_message})
|
| 88 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
try:
|
| 90 |
+
reply, _model = await chat_completion(
|
| 91 |
+
messages,
|
| 92 |
+
max_tokens=800,
|
| 93 |
+
temperature=0.7,
|
| 94 |
+
timeout=60.0,
|
| 95 |
+
title="LinguaIELTS Speaking Coach",
|
| 96 |
+
)
|
| 97 |
return {"reply": reply}
|
| 98 |
except Exception as exc:
|
| 99 |
logger.warning("Speaking chat OpenRouter error: %s", exc)
|
|
|
|
| 103 |
)
|
| 104 |
|
| 105 |
|
|
|
|
| 106 |
@limiter.limit("20/minute")
|
| 107 |
+
@router.post("/analyze-language")
|
| 108 |
async def analyze_language(
|
| 109 |
request: Request,
|
| 110 |
body: TranscriptAnalyzeRequest,
|
|
|
|
| 153 |
"""HTTP handler: save upload, optionally dispatch Celery, else run pipeline inline."""
|
| 154 |
await ProfileRepository(db).ensure_speaking_eval_allowed(current_user.id)
|
| 155 |
|
| 156 |
+
suffix = (Path(file.filename or "audio.webm").suffix or ".webm").lower()
|
| 157 |
+
if suffix not in ALLOWED_AUDIO_EXTENSIONS:
|
| 158 |
+
raise HTTPException(status_code=400, detail="Unsupported audio file type")
|
| 159 |
+
audio_bytes = await read_upload_limited(file, MAX_AUDIO_UPLOAD_SIZE)
|
| 160 |
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
| 161 |
+
tmp.write(audio_bytes)
|
| 162 |
tmp_path = tmp.name
|
| 163 |
|
| 164 |
if settings.CELERY_ENABLED:
|
backend/app/routers/translation.py
ADDED
|
@@ -0,0 +1,71 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Translation Practice router — /translation/*
|
| 3 |
+
"""
|
| 4 |
+
from __future__ import annotations
|
| 5 |
+
|
| 6 |
+
from pydantic import BaseModel, Field
|
| 7 |
+
from fastapi import APIRouter, Depends, Request
|
| 8 |
+
from sqlalchemy.ext.asyncio import AsyncSession
|
| 9 |
+
|
| 10 |
+
from app.core.dependencies import get_current_user
|
| 11 |
+
from app.core.rate_limit import limiter
|
| 12 |
+
from app.db.database import get_db
|
| 13 |
+
from app.db.models import User
|
| 14 |
+
from app.services.translation_service import TranslationService
|
| 15 |
+
|
| 16 |
+
router = APIRouter(prefix="/translation", tags=["Translation Practice"])
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
class TranslationCheckRequest(BaseModel):
|
| 20 |
+
sentence_id: int = Field(gt=0)
|
| 21 |
+
user_translation: str = Field(min_length=1, max_length=2000)
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def _svc(db: AsyncSession = Depends(get_db)) -> TranslationService:
|
| 25 |
+
return TranslationService(db)
|
| 26 |
+
|
| 27 |
+
|
| 28 |
+
@router.get("/steps")
|
| 29 |
+
async def list_steps(svc: TranslationService = Depends(_svc)) -> dict:
|
| 30 |
+
"""List all learning steps with topic and sentence counts."""
|
| 31 |
+
steps = await svc.list_steps_with_counts()
|
| 32 |
+
return {"code": 0, "data": steps}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@router.get("/steps/{step_id}/topics")
|
| 36 |
+
async def list_topics(
|
| 37 |
+
step_id: int,
|
| 38 |
+
current_user: User = Depends(get_current_user),
|
| 39 |
+
svc: TranslationService = Depends(_svc),
|
| 40 |
+
) -> dict:
|
| 41 |
+
"""List all topics within a step, with user progress."""
|
| 42 |
+
topics = await svc.list_topics_for_step(step_id, current_user.id)
|
| 43 |
+
return {"code": 0, "data": topics}
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
@router.get("/topics/{topic_id}/sentences")
|
| 47 |
+
async def list_sentences(
|
| 48 |
+
topic_id: int,
|
| 49 |
+
current_user: User = Depends(get_current_user),
|
| 50 |
+
svc: TranslationService = Depends(_svc),
|
| 51 |
+
) -> dict:
|
| 52 |
+
"""List all sentences in a topic with hint words and previous attempt info."""
|
| 53 |
+
sentences = await svc.list_sentences_for_topic(topic_id, current_user.id)
|
| 54 |
+
return {"code": 0, "data": sentences}
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
@limiter.limit("20/minute")
|
| 58 |
+
@router.post("/check")
|
| 59 |
+
async def check_translation(
|
| 60 |
+
request: Request,
|
| 61 |
+
body: TranslationCheckRequest,
|
| 62 |
+
current_user: User = Depends(get_current_user),
|
| 63 |
+
svc: TranslationService = Depends(_svc),
|
| 64 |
+
) -> dict:
|
| 65 |
+
"""Grade user's translation with AI. Returns score, feedback, model_answer."""
|
| 66 |
+
result = await svc.check_translation(
|
| 67 |
+
user_id=current_user.id,
|
| 68 |
+
sentence_id=body.sentence_id,
|
| 69 |
+
user_translation=body.user_translation,
|
| 70 |
+
)
|
| 71 |
+
return {"code": 0, "data": result}
|
backend/app/routers/users.py
CHANGED
|
@@ -1,10 +1,9 @@
|
|
| 1 |
-
import httpx
|
| 2 |
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
| 3 |
from pydantic import BaseModel
|
| 4 |
from sqlalchemy import func, select
|
| 5 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 6 |
|
| 7 |
-
from app.core.
|
| 8 |
from app.core.dependencies import get_current_user
|
| 9 |
from app.core.rate_limit import limiter
|
| 10 |
from app.core.upload import validate_and_read_image
|
|
@@ -38,9 +37,6 @@ from app.services.users_service import UsersService
|
|
| 38 |
|
| 39 |
router = APIRouter(prefix="/users", tags=["Users"])
|
| 40 |
|
| 41 |
-
_OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
|
| 42 |
-
_OPENROUTER_MODEL = "anthropic/claude-3-haiku"
|
| 43 |
-
|
| 44 |
|
| 45 |
class DashboardChatMessage(BaseModel):
|
| 46 |
role: str
|
|
@@ -327,8 +323,8 @@ async def toggle_task_complete(
|
|
| 327 |
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
| 328 |
|
| 329 |
|
| 330 |
-
@router.post("/me/chat")
|
| 331 |
@limiter.limit("10/minute")
|
|
|
|
| 332 |
async def dashboard_chat(
|
| 333 |
request: Request,
|
| 334 |
body: DashboardChatRequest,
|
|
@@ -340,7 +336,7 @@ async def dashboard_chat(
|
|
| 340 |
"""
|
| 341 |
await ProfileRepository(db).ensure_tutor_chat_allowed(current_user.id)
|
| 342 |
|
| 343 |
-
if not
|
| 344 |
return {"error": "OPENROUTER_API_KEY is missing in backend environment."}
|
| 345 |
|
| 346 |
profile_rs = await db.execute(select(UserProfile).where(UserProfile.user_id == current_user.id))
|
|
@@ -389,26 +385,18 @@ async def dashboard_chat(
|
|
| 389 |
{"role": "system", "content": context_prompt},
|
| 390 |
]
|
| 391 |
for m in body.history[-8:]:
|
| 392 |
-
|
|
|
|
| 393 |
messages.append({"role": "user", "content": body.user_message})
|
| 394 |
|
| 395 |
-
payload = {
|
| 396 |
-
"model": _OPENROUTER_MODEL,
|
| 397 |
-
"messages": messages,
|
| 398 |
-
"temperature": 0.5,
|
| 399 |
-
"max_tokens": 700,
|
| 400 |
-
}
|
| 401 |
-
headers = {
|
| 402 |
-
"Authorization": f"Bearer {settings.OPENROUTER_API_KEY}",
|
| 403 |
-
"HTTP-Referer": "http://localhost:5173",
|
| 404 |
-
"X-Title": "LinguaIELTS Dashboard Coach",
|
| 405 |
-
"Content-Type": "application/json",
|
| 406 |
-
}
|
| 407 |
try:
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
|
|
|
|
|
|
|
|
|
| 412 |
await ProfileRepository(db).increment_tutor_chat(current_user.id)
|
| 413 |
return {"reply": reply}
|
| 414 |
except Exception as exc:
|
|
|
|
|
|
|
| 1 |
from fastapi import APIRouter, Depends, File, HTTPException, Request, UploadFile, status
|
| 2 |
from pydantic import BaseModel
|
| 3 |
from sqlalchemy import func, select
|
| 4 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 5 |
|
| 6 |
+
from app.core.openrouter_client import chat_completion, has_openrouter_keys
|
| 7 |
from app.core.dependencies import get_current_user
|
| 8 |
from app.core.rate_limit import limiter
|
| 9 |
from app.core.upload import validate_and_read_image
|
|
|
|
| 37 |
|
| 38 |
router = APIRouter(prefix="/users", tags=["Users"])
|
| 39 |
|
|
|
|
|
|
|
|
|
|
| 40 |
|
| 41 |
class DashboardChatMessage(BaseModel):
|
| 42 |
role: str
|
|
|
|
| 323 |
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
| 324 |
|
| 325 |
|
|
|
|
| 326 |
@limiter.limit("10/minute")
|
| 327 |
+
@router.post("/me/chat")
|
| 328 |
async def dashboard_chat(
|
| 329 |
request: Request,
|
| 330 |
body: DashboardChatRequest,
|
|
|
|
| 336 |
"""
|
| 337 |
await ProfileRepository(db).ensure_tutor_chat_allowed(current_user.id)
|
| 338 |
|
| 339 |
+
if not has_openrouter_keys():
|
| 340 |
return {"error": "OPENROUTER_API_KEY is missing in backend environment."}
|
| 341 |
|
| 342 |
profile_rs = await db.execute(select(UserProfile).where(UserProfile.user_id == current_user.id))
|
|
|
|
| 385 |
{"role": "system", "content": context_prompt},
|
| 386 |
]
|
| 387 |
for m in body.history[-8:]:
|
| 388 |
+
role = m.role if m.role in {"user", "assistant"} else "user"
|
| 389 |
+
messages.append({"role": role, "content": m.content})
|
| 390 |
messages.append({"role": "user", "content": body.user_message})
|
| 391 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 392 |
try:
|
| 393 |
+
reply, _model = await chat_completion(
|
| 394 |
+
messages,
|
| 395 |
+
max_tokens=700,
|
| 396 |
+
temperature=0.5,
|
| 397 |
+
timeout=20.0,
|
| 398 |
+
title="LinguaIELTS Dashboard Coach",
|
| 399 |
+
)
|
| 400 |
await ProfileRepository(db).increment_tutor_chat(current_user.id)
|
| 401 |
return {"reply": reply}
|
| 402 |
except Exception as exc:
|
backend/app/routers/writing.py
CHANGED
|
@@ -1,12 +1,11 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
import httpx
|
| 4 |
from fastapi import APIRouter, Depends, Query, Request
|
| 5 |
from fastapi.responses import JSONResponse
|
| 6 |
from pydantic import BaseModel
|
| 7 |
|
| 8 |
-
from app.core.config import settings
|
| 9 |
from app.core.dependencies import get_current_user
|
|
|
|
| 10 |
from app.core.rate_limit import limiter
|
| 11 |
from app.core.usage_counters import check_and_increment_writing_chat
|
| 12 |
from app.db.database import get_db
|
|
@@ -18,8 +17,6 @@ from sqlalchemy.ext.asyncio import AsyncSession
|
|
| 18 |
|
| 19 |
router = APIRouter(prefix="", tags=["Writing"])
|
| 20 |
|
| 21 |
-
_OPENROUTER_URL = "https://openrouter.ai/api/v1/chat/completions"
|
| 22 |
-
|
| 23 |
_WRITING_SYSTEM = (
|
| 24 |
"You are Catbot, an expert IELTS Writing coach with 10+ years of experience. "
|
| 25 |
"You help students improve their Task 1 and Task 2 writing skills. "
|
|
@@ -40,8 +37,8 @@ class _WritingChatReq(BaseModel):
|
|
| 40 |
history: list[_ChatMsg] = []
|
| 41 |
|
| 42 |
|
| 43 |
-
@router.post("/writing/chat")
|
| 44 |
@limiter.limit("30/minute")
|
|
|
|
| 45 |
async def writing_chat(
|
| 46 |
request: Request,
|
| 47 |
body: _WritingChatReq,
|
|
@@ -49,6 +46,9 @@ async def writing_chat(
|
|
| 49 |
):
|
| 50 |
"""Proxy chat to OpenRouter for writing coaching, with topic context (JWT required)."""
|
| 51 |
check_and_increment_writing_chat(current_user.id)
|
|
|
|
|
|
|
|
|
|
| 52 |
messages: list[dict] = [{"role": "system", "content": _WRITING_SYSTEM}]
|
| 53 |
if body.prompt_text:
|
| 54 |
messages.append({
|
|
@@ -56,26 +56,18 @@ async def writing_chat(
|
|
| 56 |
"content": f'Current IELTS Writing task the student is working on: "{body.prompt_text}"',
|
| 57 |
})
|
| 58 |
for m in body.history[-10:]:
|
| 59 |
-
|
|
|
|
| 60 |
messages.append({"role": "user", "content": body.user_message})
|
| 61 |
|
| 62 |
-
payload = {
|
| 63 |
-
"model": "anthropic/claude-3-haiku",
|
| 64 |
-
"messages": messages,
|
| 65 |
-
"max_tokens": 800,
|
| 66 |
-
"temperature": 0.6,
|
| 67 |
-
}
|
| 68 |
-
headers = {
|
| 69 |
-
"Authorization": f"Bearer {settings.OPENROUTER_API_KEY}",
|
| 70 |
-
"Content-Type": "application/json",
|
| 71 |
-
"HTTP-Referer": "https://cathoven-clone.local",
|
| 72 |
-
"X-Title": "Writing Coach",
|
| 73 |
-
}
|
| 74 |
try:
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
|
|
|
|
|
|
|
|
|
|
| 79 |
return {"reply": reply}
|
| 80 |
except Exception as exc:
|
| 81 |
return JSONResponse(status_code=502, content={"error": f"AI service unavailable: {exc}"})
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
| 3 |
from fastapi import APIRouter, Depends, Query, Request
|
| 4 |
from fastapi.responses import JSONResponse
|
| 5 |
from pydantic import BaseModel
|
| 6 |
|
|
|
|
| 7 |
from app.core.dependencies import get_current_user
|
| 8 |
+
from app.core.openrouter_client import chat_completion, has_openrouter_keys
|
| 9 |
from app.core.rate_limit import limiter
|
| 10 |
from app.core.usage_counters import check_and_increment_writing_chat
|
| 11 |
from app.db.database import get_db
|
|
|
|
| 17 |
|
| 18 |
router = APIRouter(prefix="", tags=["Writing"])
|
| 19 |
|
|
|
|
|
|
|
| 20 |
_WRITING_SYSTEM = (
|
| 21 |
"You are Catbot, an expert IELTS Writing coach with 10+ years of experience. "
|
| 22 |
"You help students improve their Task 1 and Task 2 writing skills. "
|
|
|
|
| 37 |
history: list[_ChatMsg] = []
|
| 38 |
|
| 39 |
|
|
|
|
| 40 |
@limiter.limit("30/minute")
|
| 41 |
+
@router.post("/writing/chat")
|
| 42 |
async def writing_chat(
|
| 43 |
request: Request,
|
| 44 |
body: _WritingChatReq,
|
|
|
|
| 46 |
):
|
| 47 |
"""Proxy chat to OpenRouter for writing coaching, with topic context (JWT required)."""
|
| 48 |
check_and_increment_writing_chat(current_user.id)
|
| 49 |
+
if not has_openrouter_keys():
|
| 50 |
+
return JSONResponse(status_code=503, content={"error": "AI service unavailable: OPENROUTER_API_KEY is missing"})
|
| 51 |
+
|
| 52 |
messages: list[dict] = [{"role": "system", "content": _WRITING_SYSTEM}]
|
| 53 |
if body.prompt_text:
|
| 54 |
messages.append({
|
|
|
|
| 56 |
"content": f'Current IELTS Writing task the student is working on: "{body.prompt_text}"',
|
| 57 |
})
|
| 58 |
for m in body.history[-10:]:
|
| 59 |
+
role = m.role if m.role in {"user", "assistant"} else "user"
|
| 60 |
+
messages.append({"role": role, "content": m.content})
|
| 61 |
messages.append({"role": "user", "content": body.user_message})
|
| 62 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
try:
|
| 64 |
+
reply, _model = await chat_completion(
|
| 65 |
+
messages,
|
| 66 |
+
max_tokens=800,
|
| 67 |
+
temperature=0.6,
|
| 68 |
+
timeout=15.0,
|
| 69 |
+
title="Writing Coach",
|
| 70 |
+
)
|
| 71 |
return {"reply": reply}
|
| 72 |
except Exception as exc:
|
| 73 |
return JSONResponse(status_code=502, content={"error": f"AI service unavailable: {exc}"})
|
backend/app/schemas.py
CHANGED
|
@@ -11,12 +11,12 @@ from pydantic import BaseModel, EmailStr, Field
|
|
| 11 |
# ═══ Auth ════════════════════════════════════
|
| 12 |
class UserCreate(BaseModel):
|
| 13 |
email: EmailStr
|
| 14 |
-
password: str = Field(min_length=
|
| 15 |
full_name: Optional[str] = None
|
| 16 |
|
| 17 |
class UserLogin(BaseModel):
|
| 18 |
email: EmailStr
|
| 19 |
-
password: str = Field(min_length=
|
| 20 |
|
| 21 |
class Token(BaseModel):
|
| 22 |
access_token: str
|
|
@@ -67,7 +67,25 @@ class AuthLogoutRequest(BaseModel):
|
|
| 67 |
|
| 68 |
|
| 69 |
class VerifyEmailRequest(BaseModel):
|
| 70 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
|
| 72 |
|
| 73 |
class ForgotPasswordRequest(BaseModel):
|
|
@@ -76,7 +94,7 @@ class ForgotPasswordRequest(BaseModel):
|
|
| 76 |
|
| 77 |
class ResetPasswordRequest(BaseModel):
|
| 78 |
token: str
|
| 79 |
-
new_password: str = Field(min_length=
|
| 80 |
|
| 81 |
|
| 82 |
# ═══ Progress ════════════════════════════════
|
|
@@ -191,8 +209,8 @@ class UserMeUpdateRequest(BaseModel):
|
|
| 191 |
|
| 192 |
|
| 193 |
class ChangePasswordRequest(BaseModel):
|
| 194 |
-
current_password: str = Field(min_length=
|
| 195 |
-
new_password: str = Field(min_length=
|
| 196 |
|
| 197 |
|
| 198 |
class WritingSubmitRequest(BaseModel):
|
|
@@ -821,6 +839,7 @@ class ShadowingProcessVideoRequest(BaseModel):
|
|
| 821 |
url: str = Field(..., min_length=8)
|
| 822 |
level: str = Field(default="Intermediate", max_length=50)
|
| 823 |
translate: bool = Field(default=True)
|
|
|
|
| 824 |
|
| 825 |
|
| 826 |
class ShadowingTranslateRequest(BaseModel):
|
|
|
|
| 11 |
# ═══ Auth ════════════════════════════════════
|
| 12 |
class UserCreate(BaseModel):
|
| 13 |
email: EmailStr
|
| 14 |
+
password: str = Field(min_length=10, max_length=128)
|
| 15 |
full_name: Optional[str] = None
|
| 16 |
|
| 17 |
class UserLogin(BaseModel):
|
| 18 |
email: EmailStr
|
| 19 |
+
password: str = Field(min_length=1, max_length=128)
|
| 20 |
|
| 21 |
class Token(BaseModel):
|
| 22 |
access_token: str
|
|
|
|
| 67 |
|
| 68 |
|
| 69 |
class VerifyEmailRequest(BaseModel):
|
| 70 |
+
email: EmailStr
|
| 71 |
+
code: str = Field(min_length=6, max_length=6)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class ResendVerificationRequest(BaseModel):
|
| 75 |
+
email: EmailStr
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
class RegisterResponse(BaseModel):
|
| 79 |
+
"""Returned after email registration — directs user to verify inbox."""
|
| 80 |
+
needs_verification: bool = True
|
| 81 |
+
email: str
|
| 82 |
+
message: str = "Mã xác minh đã được gửi đến email của bạn."
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
class GoogleAuthRequest(BaseModel):
|
| 86 |
+
"""Frontend sends the authorization code received from Google."""
|
| 87 |
+
code: str
|
| 88 |
+
redirect_uri: str
|
| 89 |
|
| 90 |
|
| 91 |
class ForgotPasswordRequest(BaseModel):
|
|
|
|
| 94 |
|
| 95 |
class ResetPasswordRequest(BaseModel):
|
| 96 |
token: str
|
| 97 |
+
new_password: str = Field(min_length=10, max_length=128)
|
| 98 |
|
| 99 |
|
| 100 |
# ═══ Progress ════════════════════════════════
|
|
|
|
| 209 |
|
| 210 |
|
| 211 |
class ChangePasswordRequest(BaseModel):
|
| 212 |
+
current_password: str = Field(min_length=1, max_length=128)
|
| 213 |
+
new_password: str = Field(min_length=10, max_length=128)
|
| 214 |
|
| 215 |
|
| 216 |
class WritingSubmitRequest(BaseModel):
|
|
|
|
| 839 |
url: str = Field(..., min_length=8)
|
| 840 |
level: str = Field(default="Intermediate", max_length=50)
|
| 841 |
translate: bool = Field(default=True)
|
| 842 |
+
force_refresh: bool = Field(default=False)
|
| 843 |
|
| 844 |
|
| 845 |
class ShadowingTranslateRequest(BaseModel):
|
backend/app/services/admin_content_service.py
CHANGED
|
@@ -11,6 +11,7 @@ from typing import Any
|
|
| 11 |
|
| 12 |
from fastapi import HTTPException, UploadFile, status
|
| 13 |
|
|
|
|
| 14 |
from app.schemas import (
|
| 15 |
AdminContentListResponse,
|
| 16 |
AdminContentResponse,
|
|
@@ -69,11 +70,9 @@ class AdminContentService:
|
|
| 69 |
status_code=status.HTTP_400_BAD_REQUEST,
|
| 70 |
detail="Only PNG, JPG, JPEG, and WEBP images are supported",
|
| 71 |
)
|
| 72 |
-
content = await
|
| 73 |
if not content:
|
| 74 |
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Image file is empty")
|
| 75 |
-
if len(content) > 5 * 1024 * 1024:
|
| 76 |
-
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Image file must be 5MB or smaller")
|
| 77 |
image_id = uuid.uuid4().hex
|
| 78 |
image_dir = self._data_root / "assets" / "images"
|
| 79 |
image_dir.mkdir(parents=True, exist_ok=True)
|
|
|
|
| 11 |
|
| 12 |
from fastapi import HTTPException, UploadFile, status
|
| 13 |
|
| 14 |
+
from app.core.upload import MAX_ADMIN_IMAGE_SIZE, read_upload_limited
|
| 15 |
from app.schemas import (
|
| 16 |
AdminContentListResponse,
|
| 17 |
AdminContentResponse,
|
|
|
|
| 70 |
status_code=status.HTTP_400_BAD_REQUEST,
|
| 71 |
detail="Only PNG, JPG, JPEG, and WEBP images are supported",
|
| 72 |
)
|
| 73 |
+
content = await read_upload_limited(upload, MAX_ADMIN_IMAGE_SIZE)
|
| 74 |
if not content:
|
| 75 |
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Image file is empty")
|
|
|
|
|
|
|
| 76 |
image_id = uuid.uuid4().hex
|
| 77 |
image_dir = self._data_root / "assets" / "images"
|
| 78 |
image_dir.mkdir(parents=True, exist_ok=True)
|
backend/app/services/auth_service.py
CHANGED
|
@@ -1,18 +1,23 @@
|
|
| 1 |
"""
|
| 2 |
app/services/auth_service.py
|
| 3 |
──────────────────────────────
|
| 4 |
-
Business logic for registration and
|
| 5 |
Orchestrates UserRepository + ProfileRepository and issues JWT tokens.
|
| 6 |
"""
|
| 7 |
|
|
|
|
|
|
|
|
|
|
| 8 |
import logging
|
| 9 |
import secrets
|
| 10 |
from datetime import datetime, timedelta, timezone
|
| 11 |
|
|
|
|
| 12 |
from fastapi import HTTPException, status
|
| 13 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 14 |
|
| 15 |
-
from app.core.config import
|
|
|
|
| 16 |
from app.core.security import (
|
| 17 |
create_access_token,
|
| 18 |
create_refresh_token,
|
|
@@ -21,31 +26,52 @@ from app.core.security import (
|
|
| 21 |
hash_token,
|
| 22 |
verify_password,
|
| 23 |
)
|
|
|
|
| 24 |
from app.repositories.password_reset_repository import PasswordResetRepository
|
| 25 |
from app.repositories.profile_repository import ProfileRepository
|
| 26 |
from app.repositories.refresh_token_repository import RefreshTokenRepository
|
| 27 |
from app.repositories.user_repository import UserRepository
|
| 28 |
-
from app.schemas import
|
| 29 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 30 |
|
| 31 |
logger = logging.getLogger(__name__)
|
| 32 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 33 |
|
| 34 |
class AuthService:
|
| 35 |
def __init__(self, db: AsyncSession) -> None:
|
|
|
|
| 36 |
self._user_repo = UserRepository(db)
|
| 37 |
self._profile_repo = ProfileRepository(db)
|
| 38 |
self._refresh_repo = RefreshTokenRepository(db)
|
| 39 |
self._reset_repo = PasswordResetRepository(db)
|
|
|
|
| 40 |
|
| 41 |
-
async def register(self, payload: UserCreate) ->
|
| 42 |
"""
|
| 43 |
-
Register a new user:
|
| 44 |
1. Check email is not taken
|
| 45 |
2. Hash password
|
| 46 |
-
3. Create user row
|
| 47 |
-
4.
|
| 48 |
-
5. Return
|
| 49 |
"""
|
| 50 |
existing = await self._user_repo.get_by_email(payload.email)
|
| 51 |
if existing:
|
|
@@ -54,6 +80,8 @@ class AuthService:
|
|
| 54 |
detail="Email already registered",
|
| 55 |
)
|
| 56 |
|
|
|
|
|
|
|
| 57 |
try:
|
| 58 |
hashed = hash_password(payload.password)
|
| 59 |
except (ValueError, RuntimeError) as exc:
|
|
@@ -62,21 +90,144 @@ class AuthService:
|
|
| 62 |
status_code=status.HTTP_400_BAD_REQUEST,
|
| 63 |
detail="Invalid password format",
|
| 64 |
) from exc
|
| 65 |
-
|
| 66 |
-
user = await self._user_repo.create(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 67 |
await self._profile_repo.create_empty(user.id, full_name=payload.full_name)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
|
| 69 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
return await self._issue_token_pair(user.id)
|
| 72 |
|
| 73 |
-
async def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
"""
|
| 75 |
-
|
| 76 |
-
|
| 77 |
-
2. Verify bcrypt password
|
| 78 |
-
3. Return JWT token
|
| 79 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
user = await self._user_repo.get_by_email(payload.email)
|
| 81 |
if not user or not verify_password(payload.password, user.password_hash):
|
| 82 |
raise HTTPException(
|
|
@@ -85,16 +236,16 @@ class AuthService:
|
|
| 85 |
headers={"WWW-Authenticate": "Bearer"},
|
| 86 |
)
|
| 87 |
if not user.is_active:
|
|
|
|
|
|
|
|
|
|
| 88 |
raise HTTPException(
|
| 89 |
status_code=status.HTTP_403_FORBIDDEN,
|
| 90 |
-
detail="
|
|
|
|
| 91 |
)
|
| 92 |
-
if user.email.lower() in admin_email_set() and user.role != "admin":
|
| 93 |
-
user.role = "admin"
|
| 94 |
-
logger.info("Auto-promoted admin user: id=%s email=%s", user.id, user.email)
|
| 95 |
|
| 96 |
logger.info("User logged in: id=%s", user.id)
|
| 97 |
-
|
| 98 |
return await self._issue_token_pair(user.id)
|
| 99 |
|
| 100 |
async def refresh(self, refresh_token: str) -> Token:
|
|
@@ -113,9 +264,6 @@ class AuthService:
|
|
| 113 |
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
| 114 |
if not user.is_active:
|
| 115 |
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account is locked")
|
| 116 |
-
if user.email.lower() in admin_email_set() and user.role != "admin":
|
| 117 |
-
user.role = "admin"
|
| 118 |
-
|
| 119 |
await self._refresh_repo.revoke(active)
|
| 120 |
return await self._issue_token_pair(user_id)
|
| 121 |
|
|
@@ -168,6 +316,8 @@ class AuthService:
|
|
| 168 |
if not user:
|
| 169 |
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
| 170 |
|
|
|
|
|
|
|
| 171 |
try:
|
| 172 |
new_hash = hash_password(payload.new_password)
|
| 173 |
except (ValueError, RuntimeError) as exc:
|
|
@@ -181,6 +331,45 @@ class AuthService:
|
|
| 181 |
await self._refresh_repo.revoke_all_for_user(user.id)
|
| 182 |
return MessageResponse(message="Đã đặt lại mật khẩu thành công. Vui lòng đăng nhập lại.")
|
| 183 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
async def _issue_token_pair(self, user_id: int) -> Token:
|
| 185 |
access_token = create_access_token(subject=user_id)
|
| 186 |
raw_refresh = create_refresh_token(subject=user_id)
|
|
|
|
| 1 |
"""
|
| 2 |
app/services/auth_service.py
|
| 3 |
──────────────────────────────
|
| 4 |
+
Business logic for registration, login, Google OAuth, and email verification.
|
| 5 |
Orchestrates UserRepository + ProfileRepository and issues JWT tokens.
|
| 6 |
"""
|
| 7 |
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
|
| 10 |
+
import hashlib
|
| 11 |
import logging
|
| 12 |
import secrets
|
| 13 |
from datetime import datetime, timedelta, timezone
|
| 14 |
|
| 15 |
+
import httpx
|
| 16 |
from fastapi import HTTPException, status
|
| 17 |
from sqlalchemy.ext.asyncio import AsyncSession
|
| 18 |
|
| 19 |
+
from app.core.config import settings
|
| 20 |
+
from app.core.password_policy import assert_password_strength
|
| 21 |
from app.core.security import (
|
| 22 |
create_access_token,
|
| 23 |
create_refresh_token,
|
|
|
|
| 26 |
hash_token,
|
| 27 |
verify_password,
|
| 28 |
)
|
| 29 |
+
from app.repositories.email_verification_repository import EmailVerificationRepository
|
| 30 |
from app.repositories.password_reset_repository import PasswordResetRepository
|
| 31 |
from app.repositories.profile_repository import ProfileRepository
|
| 32 |
from app.repositories.refresh_token_repository import RefreshTokenRepository
|
| 33 |
from app.repositories.user_repository import UserRepository
|
| 34 |
+
from app.schemas import (
|
| 35 |
+
ForgotPasswordRequest,
|
| 36 |
+
GoogleAuthRequest,
|
| 37 |
+
MessageResponse,
|
| 38 |
+
RegisterResponse,
|
| 39 |
+
ResendVerificationRequest,
|
| 40 |
+
ResetPasswordRequest,
|
| 41 |
+
Token,
|
| 42 |
+
UserCreate,
|
| 43 |
+
UserLogin,
|
| 44 |
+
VerifyEmailRequest,
|
| 45 |
+
)
|
| 46 |
+
from app.services.email_service import send_verification_email, send_password_reset_email, smtp_configured
|
| 47 |
|
| 48 |
logger = logging.getLogger(__name__)
|
| 49 |
|
| 50 |
+
_GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
|
| 51 |
+
_GOOGLE_USERINFO_URL = "https://www.googleapis.com/oauth2/v2/userinfo"
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def _hash_otp(code: str) -> str:
|
| 55 |
+
return hashlib.sha256(code.encode()).hexdigest()
|
| 56 |
+
|
| 57 |
|
| 58 |
class AuthService:
|
| 59 |
def __init__(self, db: AsyncSession) -> None:
|
| 60 |
+
self._db = db
|
| 61 |
self._user_repo = UserRepository(db)
|
| 62 |
self._profile_repo = ProfileRepository(db)
|
| 63 |
self._refresh_repo = RefreshTokenRepository(db)
|
| 64 |
self._reset_repo = PasswordResetRepository(db)
|
| 65 |
+
self._verify_repo = EmailVerificationRepository(db)
|
| 66 |
|
| 67 |
+
async def register(self, payload: UserCreate) -> RegisterResponse:
|
| 68 |
"""
|
| 69 |
+
Register a new user with email verification:
|
| 70 |
1. Check email is not taken
|
| 71 |
2. Hash password
|
| 72 |
+
3. Create unverified user row + empty profile
|
| 73 |
+
4. Generate & send 6-digit OTP
|
| 74 |
+
5. Return needs_verification response
|
| 75 |
"""
|
| 76 |
existing = await self._user_repo.get_by_email(payload.email)
|
| 77 |
if existing:
|
|
|
|
| 80 |
detail="Email already registered",
|
| 81 |
)
|
| 82 |
|
| 83 |
+
assert_password_strength(payload.password)
|
| 84 |
+
|
| 85 |
try:
|
| 86 |
hashed = hash_password(payload.password)
|
| 87 |
except (ValueError, RuntimeError) as exc:
|
|
|
|
| 90 |
status_code=status.HTTP_400_BAD_REQUEST,
|
| 91 |
detail="Invalid password format",
|
| 92 |
) from exc
|
| 93 |
+
|
| 94 |
+
user = await self._user_repo.create(
|
| 95 |
+
email=payload.email,
|
| 96 |
+
password_hash=hashed,
|
| 97 |
+
role="user",
|
| 98 |
+
is_verified=False,
|
| 99 |
+
auth_provider="email",
|
| 100 |
+
)
|
| 101 |
await self._profile_repo.create_empty(user.id, full_name=payload.full_name)
|
| 102 |
+
await self._send_otp(user.id, user.email)
|
| 103 |
+
|
| 104 |
+
logger.info("New user registered (pending verification): id=%s email=%s", user.id, user.email)
|
| 105 |
+
return RegisterResponse(email=user.email)
|
| 106 |
|
| 107 |
+
async def verify_email_otp(self, payload: VerifyEmailRequest) -> Token:
|
| 108 |
+
"""Validate the 6-digit OTP and issue a token pair on success."""
|
| 109 |
+
user = await self._user_repo.get_by_email(payload.email)
|
| 110 |
+
if not user:
|
| 111 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Mã xác minh không hợp lệ.")
|
| 112 |
|
| 113 |
+
row = await self._verify_repo.get_latest_unused(user.id)
|
| 114 |
+
if not row or row.code_hash != _hash_otp(payload.code):
|
| 115 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Mã xác minh không đúng hoặc đã hết hạn.")
|
| 116 |
+
|
| 117 |
+
await self._verify_repo.mark_used(row)
|
| 118 |
+
await self._user_repo.mark_verified(user)
|
| 119 |
+
|
| 120 |
+
logger.info("Email verified for user id=%s", user.id)
|
| 121 |
return await self._issue_token_pair(user.id)
|
| 122 |
|
| 123 |
+
async def resend_verification(self, payload: ResendVerificationRequest) -> MessageResponse:
|
| 124 |
+
"""Re-send the verification OTP (always returns generic message)."""
|
| 125 |
+
generic = MessageResponse(message="Nếu email tồn tại và chưa xác minh, chúng tôi đã gửi mã mới.")
|
| 126 |
+
user = await self._user_repo.get_by_email(payload.email)
|
| 127 |
+
if not user or user.is_verified:
|
| 128 |
+
return generic
|
| 129 |
+
|
| 130 |
+
await self._send_otp(user.id, user.email)
|
| 131 |
+
return generic
|
| 132 |
+
|
| 133 |
+
async def google_auth(self, payload: GoogleAuthRequest) -> Token:
|
| 134 |
"""
|
| 135 |
+
Exchange a Google authorization code for user info,
|
| 136 |
+
then find or create the user and issue a token pair.
|
|
|
|
|
|
|
| 137 |
"""
|
| 138 |
+
if not settings.GOOGLE_CLIENT_ID or not settings.GOOGLE_CLIENT_SECRET:
|
| 139 |
+
raise HTTPException(
|
| 140 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 141 |
+
detail="Google OAuth is not configured on this server.",
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
async with httpx.AsyncClient(timeout=10) as client:
|
| 145 |
+
token_resp = await client.post(
|
| 146 |
+
_GOOGLE_TOKEN_URL,
|
| 147 |
+
data={
|
| 148 |
+
"code": payload.code,
|
| 149 |
+
"client_id": settings.GOOGLE_CLIENT_ID,
|
| 150 |
+
"client_secret": settings.GOOGLE_CLIENT_SECRET,
|
| 151 |
+
"redirect_uri": payload.redirect_uri,
|
| 152 |
+
"grant_type": "authorization_code",
|
| 153 |
+
},
|
| 154 |
+
)
|
| 155 |
+
if token_resp.status_code != 200:
|
| 156 |
+
google_err = token_resp.json() if token_resp.headers.get("content-type", "").startswith("application/json") else {}
|
| 157 |
+
google_err_code = google_err.get("error", "")
|
| 158 |
+
logger.warning("Google token exchange failed (%s): %s", token_resp.status_code, token_resp.text)
|
| 159 |
+
if google_err_code == "redirect_uri_mismatch":
|
| 160 |
+
detail = "Redirect URI không khớp. Kiểm tra cấu hình Google Cloud Console."
|
| 161 |
+
elif google_err_code == "invalid_grant":
|
| 162 |
+
detail = "Mã Google đã hết hạn hoặc đã được dùng. Vui lòng thử lại."
|
| 163 |
+
elif google_err_code == "invalid_client":
|
| 164 |
+
detail = "Cấu hình Google OAuth không đúng (client_id/secret)."
|
| 165 |
+
else:
|
| 166 |
+
detail = f"Không thể xác thực với Google ({google_err_code or token_resp.status_code}). Vui lòng thử lại."
|
| 167 |
+
raise HTTPException(
|
| 168 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 169 |
+
detail=detail,
|
| 170 |
+
)
|
| 171 |
+
|
| 172 |
+
google_access = token_resp.json().get("access_token")
|
| 173 |
+
if not google_access:
|
| 174 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Google token invalid.")
|
| 175 |
+
|
| 176 |
+
info_resp = await client.get(
|
| 177 |
+
_GOOGLE_USERINFO_URL,
|
| 178 |
+
headers={"Authorization": f"Bearer {google_access}"},
|
| 179 |
+
)
|
| 180 |
+
if info_resp.status_code != 200:
|
| 181 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Không lấy được thông tin Google.")
|
| 182 |
+
|
| 183 |
+
info = info_resp.json()
|
| 184 |
+
google_id: str = info.get("id", "")
|
| 185 |
+
email: str = info.get("email", "")
|
| 186 |
+
full_name: str = info.get("name", "")
|
| 187 |
+
avatar_url: str = info.get("picture", "")
|
| 188 |
+
|
| 189 |
+
if not google_id or not email:
|
| 190 |
+
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Google trả về thông tin không đầy đủ.")
|
| 191 |
+
|
| 192 |
+
# Find existing account by google_id, then by email
|
| 193 |
+
user = await self._user_repo.get_by_google_id(google_id)
|
| 194 |
+
if not user:
|
| 195 |
+
user = await self._user_repo.get_by_email(email)
|
| 196 |
+
if user:
|
| 197 |
+
# Link Google ID to existing email account
|
| 198 |
+
await self._user_repo.update_google_id(user, google_id)
|
| 199 |
+
if not user.is_verified:
|
| 200 |
+
await self._user_repo.mark_verified(user)
|
| 201 |
+
# Enrich profile with Google data if fields are missing
|
| 202 |
+
await self._update_profile_from_google(user.id, full_name, avatar_url)
|
| 203 |
+
else:
|
| 204 |
+
# Create brand-new Google-authenticated user
|
| 205 |
+
placeholder_hash = hash_password(secrets.token_urlsafe(32))
|
| 206 |
+
user = await self._user_repo.create(
|
| 207 |
+
email=email,
|
| 208 |
+
password_hash=placeholder_hash,
|
| 209 |
+
google_id=google_id,
|
| 210 |
+
is_verified=True,
|
| 211 |
+
auth_provider="google",
|
| 212 |
+
)
|
| 213 |
+
await self._profile_repo.create_empty(
|
| 214 |
+
user.id,
|
| 215 |
+
full_name=full_name,
|
| 216 |
+
avatar_url=avatar_url,
|
| 217 |
+
)
|
| 218 |
+
else:
|
| 219 |
+
# Returning Google user — refresh avatar/name if updated on Google side
|
| 220 |
+
await self._update_profile_from_google(user.id, full_name, avatar_url)
|
| 221 |
+
|
| 222 |
+
if not user.is_active:
|
| 223 |
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Tài khoản đã bị khóa.")
|
| 224 |
+
|
| 225 |
+
logger.info("Google OAuth login: user id=%s email=%s", user.id, user.email)
|
| 226 |
+
return await self._issue_token_pair(user.id)
|
| 227 |
+
|
| 228 |
+
# ── Existing methods (unchanged) ─────────────────────────────────────────
|
| 229 |
+
|
| 230 |
+
async def login(self, payload: UserLogin) -> Token:
|
| 231 |
user = await self._user_repo.get_by_email(payload.email)
|
| 232 |
if not user or not verify_password(payload.password, user.password_hash):
|
| 233 |
raise HTTPException(
|
|
|
|
| 236 |
headers={"WWW-Authenticate": "Bearer"},
|
| 237 |
)
|
| 238 |
if not user.is_active:
|
| 239 |
+
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account is locked")
|
| 240 |
+
|
| 241 |
+
if not user.is_verified and settings.REQUIRE_EMAIL_VERIFICATION:
|
| 242 |
raise HTTPException(
|
| 243 |
status_code=status.HTTP_403_FORBIDDEN,
|
| 244 |
+
detail="email_not_verified",
|
| 245 |
+
headers={"X-Email": user.email},
|
| 246 |
)
|
|
|
|
|
|
|
|
|
|
| 247 |
|
| 248 |
logger.info("User logged in: id=%s", user.id)
|
|
|
|
| 249 |
return await self._issue_token_pair(user.id)
|
| 250 |
|
| 251 |
async def refresh(self, refresh_token: str) -> Token:
|
|
|
|
| 264 |
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
| 265 |
if not user.is_active:
|
| 266 |
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail="Account is locked")
|
|
|
|
|
|
|
|
|
|
| 267 |
await self._refresh_repo.revoke(active)
|
| 268 |
return await self._issue_token_pair(user_id)
|
| 269 |
|
|
|
|
| 316 |
if not user:
|
| 317 |
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="User not found")
|
| 318 |
|
| 319 |
+
assert_password_strength(payload.new_password)
|
| 320 |
+
|
| 321 |
try:
|
| 322 |
new_hash = hash_password(payload.new_password)
|
| 323 |
except (ValueError, RuntimeError) as exc:
|
|
|
|
| 331 |
await self._refresh_repo.revoke_all_for_user(user.id)
|
| 332 |
return MessageResponse(message="Đã đặt lại mật khẩu thành công. Vui lòng đăng nhập lại.")
|
| 333 |
|
| 334 |
+
async def _update_profile_from_google(
|
| 335 |
+
self, user_id: int, google_name: str, google_avatar: str
|
| 336 |
+
) -> None:
|
| 337 |
+
"""Enrich user profile with Google data only when local fields are missing."""
|
| 338 |
+
profile = await self._profile_repo.get_by_user_id(user_id)
|
| 339 |
+
if not profile:
|
| 340 |
+
await self._profile_repo.create_empty(
|
| 341 |
+
user_id, full_name=google_name, avatar_url=google_avatar
|
| 342 |
+
)
|
| 343 |
+
return
|
| 344 |
+
# Only update fields that are still empty — don't overwrite user's own edits
|
| 345 |
+
new_name = google_name if google_name and not profile.full_name else None
|
| 346 |
+
new_avatar = google_avatar if google_avatar and not profile.avatar_url else None
|
| 347 |
+
if new_name is not None or new_avatar is not None:
|
| 348 |
+
await self._profile_repo.update(
|
| 349 |
+
profile,
|
| 350 |
+
full_name=new_name,
|
| 351 |
+
phone=None,
|
| 352 |
+
bio=None,
|
| 353 |
+
avatar_url=new_avatar,
|
| 354 |
+
)
|
| 355 |
+
|
| 356 |
+
async def _send_otp(self, user_id: int, email: str) -> None:
|
| 357 |
+
"""Generate a 6-digit OTP, store its hash, and email the raw code."""
|
| 358 |
+
await self._verify_repo.invalidate_all_for_user(user_id)
|
| 359 |
+
code = f"{secrets.randbelow(1_000_000):06d}"
|
| 360 |
+
expires_at = datetime.now(timezone.utc) + timedelta(minutes=settings.EMAIL_VERIFY_EXPIRE_MINUTES)
|
| 361 |
+
await self._verify_repo.create(user_id=user_id, code_hash=_hash_otp(code), expires_at=expires_at)
|
| 362 |
+
try:
|
| 363 |
+
await send_verification_email(to_email=email, code=code)
|
| 364 |
+
except Exception:
|
| 365 |
+
if settings.DEBUG:
|
| 366 |
+
logger.warning("Dev OTP for %s: %s", email, code)
|
| 367 |
+
else:
|
| 368 |
+
raise HTTPException(
|
| 369 |
+
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
|
| 370 |
+
detail="Không gửi được email xác minh. Vui lòng thử lại.",
|
| 371 |
+
) from None
|
| 372 |
+
|
| 373 |
async def _issue_token_pair(self, user_id: int) -> Token:
|
| 374 |
access_token = create_access_token(subject=user_id)
|
| 375 |
raw_refresh = create_refresh_token(subject=user_id)
|
backend/app/services/email_service.py
CHANGED
|
@@ -51,6 +51,45 @@ async def send_password_reset_email(*, to_email: str, reset_url: str) -> None:
|
|
| 51 |
raise
|
| 52 |
|
| 53 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
async def send_daily_study_reminder_email(
|
| 55 |
*,
|
| 56 |
to_email: str,
|
|
|
|
| 51 |
raise
|
| 52 |
|
| 53 |
|
| 54 |
+
async def send_verification_email(*, to_email: str, code: str) -> None:
|
| 55 |
+
"""Send a 6-digit OTP to verify the user's email address."""
|
| 56 |
+
subject = "LinguaIELTS — Xác minh địa chỉ email"
|
| 57 |
+
body = (
|
| 58 |
+
"Chào bạn,\n\n"
|
| 59 |
+
"Mã xác minh email LinguaIELTS của bạn là:\n\n"
|
| 60 |
+
f" {code}\n\n"
|
| 61 |
+
f"Mã có hiệu lực trong 15 phút.\n"
|
| 62 |
+
"Nếu bạn không đăng ký tài khoản, hãy bỏ qua email này.\n\n"
|
| 63 |
+
"— LinguaIELTS"
|
| 64 |
+
)
|
| 65 |
+
|
| 66 |
+
if not smtp_configured():
|
| 67 |
+
logger.warning(
|
| 68 |
+
"SMTP not configured — verification code for %s: %s",
|
| 69 |
+
to_email,
|
| 70 |
+
code,
|
| 71 |
+
)
|
| 72 |
+
return
|
| 73 |
+
|
| 74 |
+
msg = EmailMessage()
|
| 75 |
+
msg["Subject"] = subject
|
| 76 |
+
msg["From"] = settings.SMTP_FROM
|
| 77 |
+
msg["To"] = to_email
|
| 78 |
+
msg.set_content(body)
|
| 79 |
+
|
| 80 |
+
try:
|
| 81 |
+
with smtplib.SMTP(settings.SMTP_HOST, settings.SMTP_PORT, timeout=15) as server:
|
| 82 |
+
if settings.SMTP_USE_TLS:
|
| 83 |
+
server.starttls()
|
| 84 |
+
if settings.SMTP_USER and settings.SMTP_PASSWORD:
|
| 85 |
+
server.login(settings.SMTP_USER, settings.SMTP_PASSWORD)
|
| 86 |
+
server.send_message(msg)
|
| 87 |
+
logger.info("Verification email sent to %s", to_email)
|
| 88 |
+
except Exception as exc:
|
| 89 |
+
logger.error("Failed to send verification email: %s", exc)
|
| 90 |
+
raise
|
| 91 |
+
|
| 92 |
+
|
| 93 |
async def send_daily_study_reminder_email(
|
| 94 |
*,
|
| 95 |
to_email: str,
|
backend/app/services/phoneme_recognizer.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Acoustic phoneme recognition — Allosaurus (IPA) + Whisper word check."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
import re
|
| 7 |
+
import tempfile
|
| 8 |
+
from difflib import SequenceMatcher
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
logger = logging.getLogger(__name__)
|
| 14 |
+
|
| 15 |
+
_ALLOSUARUS = None
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def _normalize_ipa(symbol: str) -> str:
|
| 19 |
+
s = symbol.strip().lower()
|
| 20 |
+
aliases = {
|
| 21 |
+
"g": "ɡ",
|
| 22 |
+
"ɾ": "r",
|
| 23 |
+
"ʔ": "",
|
| 24 |
+
"ɝ": "ɜːr",
|
| 25 |
+
"ɚ": "ɜːr",
|
| 26 |
+
"ɜ": "ɜːr",
|
| 27 |
+
"ɜː": "ɜːr",
|
| 28 |
+
"u": "uː", # allosaurus often short; CMU uses uː for UW
|
| 29 |
+
"i": "iː",
|
| 30 |
+
"o": "oʊ",
|
| 31 |
+
"e": "eɪ",
|
| 32 |
+
"a": "ɑ",
|
| 33 |
+
"ə": "ʌ", # schwa ↔ AH in many American words
|
| 34 |
+
}
|
| 35 |
+
return aliases.get(s, s)
|
| 36 |
+
|
| 37 |
+
|
| 38 |
+
def waveform_to_wav_path(waveform_16k: np.ndarray) -> str:
|
| 39 |
+
from pydub import AudioSegment
|
| 40 |
+
|
| 41 |
+
samples = (np.clip(waveform_16k, -1.0, 1.0) * 32_767).astype(np.int16)
|
| 42 |
+
seg = AudioSegment(
|
| 43 |
+
samples.tobytes(),
|
| 44 |
+
frame_rate=16_000,
|
| 45 |
+
sample_width=2,
|
| 46 |
+
channels=1,
|
| 47 |
+
)
|
| 48 |
+
tmp = tempfile.NamedTemporaryFile(delete=False, suffix=".wav")
|
| 49 |
+
tmp_path = tmp.name
|
| 50 |
+
tmp.close()
|
| 51 |
+
seg.export(tmp_path, format="wav")
|
| 52 |
+
return tmp_path
|
| 53 |
+
|
| 54 |
+
|
| 55 |
+
def safe_unlink(path: str) -> None:
|
| 56 |
+
try:
|
| 57 |
+
Path(path).unlink(missing_ok=True)
|
| 58 |
+
except OSError:
|
| 59 |
+
pass
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _get_allosaurus():
|
| 63 |
+
global _ALLOSUARUS
|
| 64 |
+
if _ALLOSUARUS is None:
|
| 65 |
+
from allosaurus.app import read_recognizer
|
| 66 |
+
|
| 67 |
+
logger.info("Loading Allosaurus phoneme recognizer …")
|
| 68 |
+
_ALLOSUARUS = read_recognizer()
|
| 69 |
+
logger.info("Allosaurus ready.")
|
| 70 |
+
return _ALLOSUARUS
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def recognize_ipa_phonemes(wav_path: str) -> list[str]:
|
| 74 |
+
"""Extract IPA phoneme sequence from audio (Allosaurus — ELSA-style acoustic check)."""
|
| 75 |
+
try:
|
| 76 |
+
model = _get_allosaurus()
|
| 77 |
+
raw = model.recognize(wav_path, lang_id="eng", timestamp=False) or ""
|
| 78 |
+
tokens = [_normalize_ipa(t) for t in raw.split() if t.strip()]
|
| 79 |
+
tokens = [t for t in tokens if t]
|
| 80 |
+
logger.info("Allosaurus IPA: %s", tokens)
|
| 81 |
+
return tokens
|
| 82 |
+
except Exception as exc:
|
| 83 |
+
logger.warning("Allosaurus phoneme recognition failed: %s", exc)
|
| 84 |
+
return []
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
def _best_word_from_transcript(full_text: str, expected_clean: str) -> tuple[str, float]:
|
| 88 |
+
"""Pick the token from Whisper output closest to the expected word."""
|
| 89 |
+
if not expected_clean:
|
| 90 |
+
return "", 0.0
|
| 91 |
+
|
| 92 |
+
full_clean = re.sub(r"[^a-z]", "", full_text.lower())
|
| 93 |
+
if full_clean == expected_clean:
|
| 94 |
+
return expected_clean, 1.0
|
| 95 |
+
if expected_clean in full_clean:
|
| 96 |
+
return expected_clean, 0.98
|
| 97 |
+
|
| 98 |
+
candidates = re.findall(r"[a-z]+", full_text.lower())
|
| 99 |
+
best_tok = full_clean
|
| 100 |
+
best_ratio = SequenceMatcher(None, expected_clean, full_clean).ratio() if full_clean else 0.0
|
| 101 |
+
|
| 102 |
+
for tok in candidates:
|
| 103 |
+
ratio = SequenceMatcher(None, expected_clean, tok).ratio()
|
| 104 |
+
if ratio > best_ratio:
|
| 105 |
+
best_ratio = ratio
|
| 106 |
+
best_tok = tok
|
| 107 |
+
|
| 108 |
+
return best_tok, best_ratio
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def transcribe_single_word(wav_path: str, expected_word: str) -> tuple[str, float]:
|
| 112 |
+
"""Whisper single-word transcription; returns best-matching token + confidence 0-1."""
|
| 113 |
+
from ml.model_registry import get_whisper_model
|
| 114 |
+
|
| 115 |
+
expected_clean = re.sub(r"[^a-z]", "", expected_word.lower())
|
| 116 |
+
|
| 117 |
+
try:
|
| 118 |
+
model = get_whisper_model()
|
| 119 |
+
result = model.transcribe(
|
| 120 |
+
wav_path,
|
| 121 |
+
language="en",
|
| 122 |
+
word_timestamps=True,
|
| 123 |
+
verbose=False,
|
| 124 |
+
condition_on_previous_text=False,
|
| 125 |
+
fp16=False,
|
| 126 |
+
temperature=0,
|
| 127 |
+
no_speech_threshold=0.45,
|
| 128 |
+
logprob_threshold=-1.0,
|
| 129 |
+
compression_ratio_threshold=2.8,
|
| 130 |
+
)
|
| 131 |
+
|
| 132 |
+
full_text = (result.get("text") or "").strip()
|
| 133 |
+
best_tok, match_ratio = _best_word_from_transcript(full_text, expected_clean)
|
| 134 |
+
|
| 135 |
+
# Word-level probability from timestamps (if available)
|
| 136 |
+
ts_conf = 0.0
|
| 137 |
+
for seg in result.get("segments") or []:
|
| 138 |
+
for w in seg.get("words") or []:
|
| 139 |
+
w_clean = re.sub(r"[^a-z]", "", (w.get("word") or "").lower())
|
| 140 |
+
if not w_clean:
|
| 141 |
+
continue
|
| 142 |
+
prob = float(w.get("probability") or 0.0)
|
| 143 |
+
r = SequenceMatcher(None, expected_clean, w_clean).ratio()
|
| 144 |
+
if r >= match_ratio - 0.05:
|
| 145 |
+
ts_conf = max(ts_conf, min(prob, 1.0))
|
| 146 |
+
|
| 147 |
+
confidence = max(match_ratio, ts_conf)
|
| 148 |
+
if match_ratio >= 0.92:
|
| 149 |
+
confidence = max(confidence, 0.9)
|
| 150 |
+
elif match_ratio >= 0.78:
|
| 151 |
+
confidence = max(confidence, match_ratio * 0.88)
|
| 152 |
+
|
| 153 |
+
logger.info(
|
| 154 |
+
"Whisper raw=%r best=%r match=%.2f conf=%.2f",
|
| 155 |
+
full_text,
|
| 156 |
+
best_tok,
|
| 157 |
+
match_ratio,
|
| 158 |
+
confidence,
|
| 159 |
+
)
|
| 160 |
+
return best_tok, confidence
|
| 161 |
+
|
| 162 |
+
except Exception as exc:
|
| 163 |
+
logger.warning("Whisper word transcription failed: %s", exc)
|
| 164 |
+
return "", 0.0
|
backend/app/services/phoneme_scorer.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Word-level phoneme scoring — Allosaurus + CMU + Whisper (ELSA-style)."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
import logging
|
| 6 |
+
import re
|
| 7 |
+
from dataclasses import asdict, dataclass, field
|
| 8 |
+
from difflib import SequenceMatcher
|
| 9 |
+
from typing import Any
|
| 10 |
+
|
| 11 |
+
import numpy as np
|
| 12 |
+
|
| 13 |
+
from app.services.phoneme_recognizer import (
|
| 14 |
+
recognize_ipa_phonemes,
|
| 15 |
+
safe_unlink,
|
| 16 |
+
transcribe_single_word,
|
| 17 |
+
waveform_to_wav_path,
|
| 18 |
+
)
|
| 19 |
+
from app.services.pronunciation_audio import prepare_speech_waveform
|
| 20 |
+
from app.services.speaking_audio_utils import has_speech
|
| 21 |
+
|
| 22 |
+
logger = logging.getLogger(__name__)
|
| 23 |
+
|
| 24 |
+
ARPABET_TO_IPA: dict[str, str] = {
|
| 25 |
+
"AA": "ɑ", "AE": "æ", "AH": "ʌ", "AO": "ɔ", "AW": "aʊ", "AY": "aɪ",
|
| 26 |
+
"B": "b", "CH": "tʃ", "D": "d", "DH": "ð", "EH": "ɛ", "ER": "ɜːr",
|
| 27 |
+
"EY": "eɪ", "F": "f", "G": "ɡ", "HH": "h", "IH": "ɪ", "IY": "iː",
|
| 28 |
+
"JH": "dʒ", "K": "k", "L": "l", "M": "m", "N": "n", "NG": "ŋ",
|
| 29 |
+
"OW": "oʊ", "OY": "ɔɪ", "P": "p", "R": "r", "S": "s", "SH": "ʃ",
|
| 30 |
+
"T": "t", "TH": "θ", "UH": "ʊ", "UW": "uː", "V": "v", "W": "w",
|
| 31 |
+
"Y": "j", "Z": "z", "ZH": "ʒ",
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
PHONEME_TIPS: dict[str, str] = {
|
| 35 |
+
"TH": "Đặt đầu lưỡi nhẹ giữa hai hàm răng, thổi hơi ra — không phát âm thành /t/ hay /d/",
|
| 36 |
+
"DH": "Giống /θ/ nhưng có tiếng vang (voiced) — lưỡi giữa răng + rung thanh quản",
|
| 37 |
+
"R": "Cuộn lưỡi về phía sau, không chạm vòm miệng",
|
| 38 |
+
"V": "Môi dưới chạm nhẹ răng trên, thổi hơi có tiếng — không phát âm thành /b/ hay /w/",
|
| 39 |
+
"AE": "Mở miệng rộng, kéo khóe miệng sang hai bên — 'cat' không phải 'cet'",
|
| 40 |
+
"ER": "Cuộn lưỡi giữa chừng, giữ nguyên — âm 'bird', 'her'",
|
| 41 |
+
"IY": "Kéo dài hơn /i/ trong tiếng Việt — 'see', 'feel'",
|
| 42 |
+
}
|
| 43 |
+
|
| 44 |
+
IPA_SIMILAR: set[frozenset[str]] = {
|
| 45 |
+
frozenset({"θ", "t", "f"}),
|
| 46 |
+
frozenset({"ð", "d", "v", "z"}),
|
| 47 |
+
frozenset({"ʃ", "s", "tʃ", "ʒ"}),
|
| 48 |
+
frozenset({"ɪ", "i", "iː", "iy", "ə"}),
|
| 49 |
+
frozenset({"ʊ", "u", "uː", "uw", "oʊ", "ow"}),
|
| 50 |
+
frozenset({"ʌ", "ə", "ɑ", "ah", "a", "ae", "æ"}),
|
| 51 |
+
frozenset({"ɛ", "e", "æ", "eh", "eɪ", "ey"}),
|
| 52 |
+
frozenset({"ɔ", "o", "ao", "oh", "ɔɪ", "oy"}),
|
| 53 |
+
frozenset({"r", "ɹ", "ɜːr", "er", "l"}),
|
| 54 |
+
frozenset({"ŋ", "n", "ng", "m"}),
|
| 55 |
+
frozenset({"b", "p"}),
|
| 56 |
+
frozenset({"g", "ɡ", "k"}),
|
| 57 |
+
frozenset({"f", "v"}),
|
| 58 |
+
frozenset({"s", "z"}),
|
| 59 |
+
frozenset({"w", "v", "u"}),
|
| 60 |
+
frozenset({"j", "y", "dʒ"}),
|
| 61 |
+
frozenset({"aɪ", "ay", "ɑɪ", "a"}),
|
| 62 |
+
frozenset({"aʊ", "aw"}),
|
| 63 |
+
frozenset({"oʊ", "ow", "ou", "o"}),
|
| 64 |
+
frozenset({"eɪ", "ey", "ei", "e"}),
|
| 65 |
+
frozenset({"ɔɪ", "oy", "oi"}),
|
| 66 |
+
frozenset({"tʃ", "ch", "ʃ"}),
|
| 67 |
+
frozenset({"dʒ", "jh", "j"}),
|
| 68 |
+
}
|
| 69 |
+
|
| 70 |
+
_CMUDICT: dict[str, list[list[str]]] | None = None
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
def _base_symbol(symbol: str) -> str:
|
| 74 |
+
return re.sub(r"\d+$", "", symbol.upper())
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def _arpabet_to_ipa(symbol: str) -> str:
|
| 78 |
+
return ARPABET_TO_IPA.get(_base_symbol(symbol), symbol.lower())
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
def _ipa_string(phonemes: list[str]) -> str:
|
| 82 |
+
if not phonemes:
|
| 83 |
+
return "/?/"
|
| 84 |
+
if phonemes[0].isupper():
|
| 85 |
+
parts = [_arpabet_to_ipa(p) for p in phonemes]
|
| 86 |
+
else:
|
| 87 |
+
parts = list(phonemes)
|
| 88 |
+
return "/" + "".join(parts) + "/"
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
def _ensure_cmudict() -> dict[str, list[list[str]]]:
|
| 92 |
+
global _CMUDICT
|
| 93 |
+
if _CMUDICT is not None:
|
| 94 |
+
return _CMUDICT
|
| 95 |
+
import nltk
|
| 96 |
+
from nltk.corpus import cmudict
|
| 97 |
+
|
| 98 |
+
try:
|
| 99 |
+
nltk.data.find("corpora/cmudict")
|
| 100 |
+
except LookupError:
|
| 101 |
+
nltk.download("cmudict", quiet=True)
|
| 102 |
+
_CMUDICT = cmudict.dict()
|
| 103 |
+
return _CMUDICT
|
| 104 |
+
|
| 105 |
+
|
| 106 |
+
def lookup_word_phonemes(word: str) -> list[str] | None:
|
| 107 |
+
cleaned = re.sub(r"[^a-z'-]", "", word.lower())
|
| 108 |
+
if not cleaned:
|
| 109 |
+
return None
|
| 110 |
+
pronunciations = _ensure_cmudict().get(cleaned)
|
| 111 |
+
return list(pronunciations[0]) if pronunciations else None
|
| 112 |
+
|
| 113 |
+
|
| 114 |
+
def get_expected_word_info(word: str) -> dict[str, Any] | None:
|
| 115 |
+
phonemes = lookup_word_phonemes(word)
|
| 116 |
+
if not phonemes:
|
| 117 |
+
return None
|
| 118 |
+
ipa_list = [_arpabet_to_ipa(p) for p in phonemes]
|
| 119 |
+
return {"word": word.lower(), "ipa": _ipa_string(phonemes), "phonemes": phonemes, "ipa_list": ipa_list}
|
| 120 |
+
|
| 121 |
+
|
| 122 |
+
def _ipa_similar(a: str, b: str) -> bool:
|
| 123 |
+
if not a or not b:
|
| 124 |
+
return False
|
| 125 |
+
if a == b:
|
| 126 |
+
return True
|
| 127 |
+
for group in IPA_SIMILAR:
|
| 128 |
+
if a in group and b in group:
|
| 129 |
+
return True
|
| 130 |
+
return False
|
| 131 |
+
|
| 132 |
+
|
| 133 |
+
def _ipa_pair_score(expected: str, predicted: str | None) -> float:
|
| 134 |
+
if not predicted:
|
| 135 |
+
return 0.0
|
| 136 |
+
if expected == predicted:
|
| 137 |
+
return 1.0
|
| 138 |
+
if _ipa_similar(expected, predicted):
|
| 139 |
+
return 0.7
|
| 140 |
+
# partial: same first character (e.g. k/kw)
|
| 141 |
+
if expected and predicted and expected[0] == predicted[0]:
|
| 142 |
+
return 0.45
|
| 143 |
+
return 0.0
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
def _align_ipa_scores(expected_ipa: list[str], predicted_ipa: list[str]) -> list[float]:
|
| 147 |
+
if not expected_ipa:
|
| 148 |
+
return []
|
| 149 |
+
if not predicted_ipa:
|
| 150 |
+
return [0.0] * len(expected_ipa)
|
| 151 |
+
|
| 152 |
+
matcher = SequenceMatcher(None, expected_ipa, predicted_ipa, autojunk=False)
|
| 153 |
+
scores = [0.0] * len(expected_ipa)
|
| 154 |
+
|
| 155 |
+
for tag, i1, i2, j1, j2 in matcher.get_opcodes():
|
| 156 |
+
if tag == "equal":
|
| 157 |
+
for k in range(i2 - i1):
|
| 158 |
+
scores[i1 + k] = _ipa_pair_score(expected_ipa[i1 + k], predicted_ipa[j1 + k])
|
| 159 |
+
elif tag == "replace":
|
| 160 |
+
span = min(i2 - i1, j2 - j1)
|
| 161 |
+
for k in range(span):
|
| 162 |
+
scores[i1 + k] = _ipa_pair_score(expected_ipa[i1 + k], predicted_ipa[j1 + k])
|
| 163 |
+
|
| 164 |
+
return scores
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
def _merge_scores(*score_lists: list[float]) -> list[float]:
|
| 168 |
+
if not score_lists:
|
| 169 |
+
return []
|
| 170 |
+
n = max(len(s) for s in score_lists)
|
| 171 |
+
merged = [0.0] * n
|
| 172 |
+
for scores in score_lists:
|
| 173 |
+
for i, s in enumerate(scores):
|
| 174 |
+
merged[i] = max(merged[i], s)
|
| 175 |
+
return merged
|
| 176 |
+
|
| 177 |
+
|
| 178 |
+
def _phoneme_scores_to_letters(word: str, phoneme_scores: list[float]) -> list["LetterDetail"]:
|
| 179 |
+
n, m = len(word), len(phoneme_scores)
|
| 180 |
+
if n == 0:
|
| 181 |
+
return []
|
| 182 |
+
if m == 0:
|
| 183 |
+
return [LetterDetail(char=c, score=0.0, correct=False) for c in word]
|
| 184 |
+
return [
|
| 185 |
+
LetterDetail(
|
| 186 |
+
char=ch,
|
| 187 |
+
score=phoneme_scores[min(int(i * m / n), m - 1)],
|
| 188 |
+
correct=phoneme_scores[min(int(i * m / n), m - 1)] >= 0.65,
|
| 189 |
+
)
|
| 190 |
+
for i, ch in enumerate(word)
|
| 191 |
+
]
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
def _verdict(overall: float) -> str:
|
| 195 |
+
if overall >= 85:
|
| 196 |
+
return "Excellent "
|
| 197 |
+
if overall >= 70:
|
| 198 |
+
return "Good "
|
| 199 |
+
if overall >= 50:
|
| 200 |
+
return "Needs Practice "
|
| 201 |
+
return "Keep Trying "
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def _resample_if_needed(waveform: np.ndarray, sample_rate: int) -> np.ndarray:
|
| 205 |
+
if sample_rate == 16_000:
|
| 206 |
+
return waveform.astype(np.float32)
|
| 207 |
+
import librosa
|
| 208 |
+
|
| 209 |
+
return librosa.resample(waveform.astype(np.float32), orig_sr=sample_rate, target_sr=16_000)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
@dataclass
|
| 213 |
+
class LetterDetail:
|
| 214 |
+
char: str
|
| 215 |
+
score: float
|
| 216 |
+
correct: bool
|
| 217 |
+
|
| 218 |
+
|
| 219 |
+
@dataclass
|
| 220 |
+
class PhonemeDetail:
|
| 221 |
+
symbol: str
|
| 222 |
+
ipa: str
|
| 223 |
+
expected: bool
|
| 224 |
+
score: float
|
| 225 |
+
correct: bool
|
| 226 |
+
tip: str | None = None
|
| 227 |
+
|
| 228 |
+
|
| 229 |
+
@dataclass
|
| 230 |
+
class PhonemeResult:
|
| 231 |
+
word: str
|
| 232 |
+
overall_score: float
|
| 233 |
+
phonemes: list[PhonemeDetail] = field(default_factory=list)
|
| 234 |
+
letters: list[LetterDetail] = field(default_factory=list)
|
| 235 |
+
decoded_text: str = ""
|
| 236 |
+
ipa_expected: str = ""
|
| 237 |
+
ipa_predicted: str = ""
|
| 238 |
+
verdict: str = ""
|
| 239 |
+
|
| 240 |
+
def to_dict(self) -> dict[str, Any]:
|
| 241 |
+
data = asdict(self)
|
| 242 |
+
data["phonemes"] = [
|
| 243 |
+
{"symbol": p.symbol, "ipa": p.ipa, "score": round(p.score, 2), "correct": p.correct, "tip": p.tip}
|
| 244 |
+
for p in self.phonemes
|
| 245 |
+
]
|
| 246 |
+
data["letters"] = [
|
| 247 |
+
{"char": l.char, "score": round(l.score, 2), "correct": l.correct} for l in self.letters
|
| 248 |
+
]
|
| 249 |
+
data["overall_score"] = round(self.overall_score, 1)
|
| 250 |
+
return data
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
class PhonemeScorer:
|
| 254 |
+
"""Score pronunciation: Allosaurus acoustic phonemes vs CMU, Whisper word sanity check."""
|
| 255 |
+
|
| 256 |
+
def score_word(
|
| 257 |
+
self,
|
| 258 |
+
waveform_np: np.ndarray,
|
| 259 |
+
sample_rate: int,
|
| 260 |
+
expected_word: str,
|
| 261 |
+
) -> PhonemeResult:
|
| 262 |
+
expected_arpabet = lookup_word_phonemes(expected_word)
|
| 263 |
+
if not expected_arpabet:
|
| 264 |
+
raise ValueError(f"Từ '{expected_word}' không có trong CMU Pronouncing Dictionary.")
|
| 265 |
+
|
| 266 |
+
audio = _resample_if_needed(waveform_np, sample_rate)
|
| 267 |
+
audio = prepare_speech_waveform(audio, 16_000)
|
| 268 |
+
if audio.size == 0:
|
| 269 |
+
raise ValueError("Audio rỗng hoặc không hợp lệ.")
|
| 270 |
+
if not has_speech(audio):
|
| 271 |
+
raise ValueError("Không phát hiện giọng nói. Hãy nói to hơn hoặc kiểm tra micro.")
|
| 272 |
+
|
| 273 |
+
expected_clean = re.sub(r"[^a-z]", "", expected_word.lower())
|
| 274 |
+
expected_ipa = [_arpabet_to_ipa(p) for p in expected_arpabet]
|
| 275 |
+
n_expected = len(expected_ipa)
|
| 276 |
+
|
| 277 |
+
wav_path = waveform_to_wav_path(audio)
|
| 278 |
+
try:
|
| 279 |
+
acoustic_ipa = recognize_ipa_phonemes(wav_path)
|
| 280 |
+
whisper_text, whisper_conf = transcribe_single_word(wav_path, expected_clean)
|
| 281 |
+
word_match = (
|
| 282 |
+
SequenceMatcher(None, expected_clean, whisper_text).ratio()
|
| 283 |
+
if whisper_text
|
| 284 |
+
else 0.0
|
| 285 |
+
)
|
| 286 |
+
|
| 287 |
+
# Acoustic alignment (Allosaurus)
|
| 288 |
+
acoustic_scores = _align_ipa_scores(expected_ipa, acoustic_ipa)
|
| 289 |
+
|
| 290 |
+
# CMU alignment from heard word (Whisper)
|
| 291 |
+
heard_arpabet = lookup_word_phonemes(whisper_text) if whisper_text else None
|
| 292 |
+
heard_ipa = [_arpabet_to_ipa(p) for p in heard_arpabet] if heard_arpabet else []
|
| 293 |
+
whisper_scores = _align_ipa_scores(expected_ipa, heard_ipa) if heard_ipa else []
|
| 294 |
+
|
| 295 |
+
per_phoneme = _merge_scores(acoustic_scores, whisper_scores)
|
| 296 |
+
if len(per_phoneme) != n_expected:
|
| 297 |
+
per_phoneme = (per_phoneme + [0.0] * n_expected)[:n_expected]
|
| 298 |
+
|
| 299 |
+
# Whisper heard the right word → boost (short clips often confuse Allosaurus)
|
| 300 |
+
if word_match >= 0.95 or whisper_conf >= 0.88:
|
| 301 |
+
floor = min(0.82 + 0.15 * max(word_match, whisper_conf), 1.0)
|
| 302 |
+
per_phoneme = [max(s, floor) for s in per_phoneme]
|
| 303 |
+
elif word_match >= 0.82:
|
| 304 |
+
floor = min(0.68 + 0.22 * word_match, 1.0)
|
| 305 |
+
per_phoneme = [max(s, floor) for s in per_phoneme]
|
| 306 |
+
elif word_match >= 0.65:
|
| 307 |
+
floor = 0.5 + 0.25 * word_match
|
| 308 |
+
per_phoneme = [max(s, floor) for s in per_phoneme]
|
| 309 |
+
|
| 310 |
+
# Partial acoustic match — blend with current scores
|
| 311 |
+
if acoustic_ipa and acoustic_scores:
|
| 312 |
+
ac_mean = float(np.mean(acoustic_scores))
|
| 313 |
+
if ac_mean >= 0.35:
|
| 314 |
+
whisper_pad = (
|
| 315 |
+
whisper_scores
|
| 316 |
+
if len(whisper_scores) == n_expected
|
| 317 |
+
else [0.0] * n_expected
|
| 318 |
+
)
|
| 319 |
+
per_phoneme = [
|
| 320 |
+
max(p, ac * 0.9)
|
| 321 |
+
for p, ac in zip(per_phoneme, acoustic_scores, strict=False)
|
| 322 |
+
]
|
| 323 |
+
|
| 324 |
+
overall = float(np.mean(per_phoneme) * 100) if per_phoneme else 0.0
|
| 325 |
+
|
| 326 |
+
logger.info(
|
| 327 |
+
"SCORE word=%s acoustic=%s whisper=%s match=%.2f per=%s overall=%.1f",
|
| 328 |
+
expected_clean,
|
| 329 |
+
acoustic_ipa,
|
| 330 |
+
whisper_text,
|
| 331 |
+
word_match,
|
| 332 |
+
[round(s, 2) for s in per_phoneme],
|
| 333 |
+
overall,
|
| 334 |
+
)
|
| 335 |
+
|
| 336 |
+
details: list[PhonemeDetail] = []
|
| 337 |
+
for arpabet, ipa, score in zip(expected_arpabet, expected_ipa, per_phoneme, strict=False):
|
| 338 |
+
base = _base_symbol(arpabet)
|
| 339 |
+
correct = score >= 0.65
|
| 340 |
+
details.append(
|
| 341 |
+
PhonemeDetail(
|
| 342 |
+
symbol=arpabet,
|
| 343 |
+
ipa=ipa,
|
| 344 |
+
expected=True,
|
| 345 |
+
score=score,
|
| 346 |
+
correct=correct,
|
| 347 |
+
tip=None if correct else PHONEME_TIPS.get(base),
|
| 348 |
+
)
|
| 349 |
+
)
|
| 350 |
+
|
| 351 |
+
predicted_ipa_display = acoustic_ipa or heard_ipa
|
| 352 |
+
return PhonemeResult(
|
| 353 |
+
word=expected_word.lower(),
|
| 354 |
+
overall_score=overall,
|
| 355 |
+
phonemes=details,
|
| 356 |
+
letters=_phoneme_scores_to_letters(expected_word, per_phoneme),
|
| 357 |
+
decoded_text=whisper_text or (" ".join(acoustic_ipa) if acoustic_ipa else ""),
|
| 358 |
+
ipa_expected=_ipa_string(expected_arpabet),
|
| 359 |
+
ipa_predicted=_ipa_string(predicted_ipa_display),
|
| 360 |
+
verdict=_verdict(overall),
|
| 361 |
+
)
|
| 362 |
+
finally:
|
| 363 |
+
safe_unlink(wav_path)
|
| 364 |
+
|
| 365 |
+
|
| 366 |
+
_SCORER: PhonemeScorer | None = None
|
| 367 |
+
|
| 368 |
+
|
| 369 |
+
def get_phoneme_scorer() -> PhonemeScorer:
|
| 370 |
+
global _SCORER
|
| 371 |
+
if _SCORER is None:
|
| 372 |
+
_SCORER = PhonemeScorer()
|
| 373 |
+
return _SCORER
|
backend/app/services/pronunciation_audio.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Convert uploaded browser audio to mono float32 waveform for phoneme scoring."""
|
| 2 |
+
|
| 3 |
+
from __future__ import annotations
|
| 4 |
+
|
| 5 |
+
from io import BytesIO
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def prepare_speech_waveform(waveform: np.ndarray, sample_rate: int) -> np.ndarray:
|
| 12 |
+
"""Peak-normalize, trim silence, pad short clips — helps Whisper/Allosaurus on browser recordings."""
|
| 13 |
+
from pydub import AudioSegment
|
| 14 |
+
from pydub.silence import detect_nonsilent
|
| 15 |
+
|
| 16 |
+
if waveform.size == 0:
|
| 17 |
+
return waveform
|
| 18 |
+
|
| 19 |
+
audio = waveform.astype(np.float32)
|
| 20 |
+
peak = float(np.max(np.abs(audio)))
|
| 21 |
+
if peak > 1e-6:
|
| 22 |
+
audio = (audio / peak) * 0.92
|
| 23 |
+
|
| 24 |
+
samples_int = (np.clip(audio, -1.0, 1.0) * 32_767).astype(np.int16)
|
| 25 |
+
seg = AudioSegment(
|
| 26 |
+
samples_int.tobytes(),
|
| 27 |
+
frame_rate=sample_rate,
|
| 28 |
+
sample_width=2,
|
| 29 |
+
channels=1,
|
| 30 |
+
)
|
| 31 |
+
|
| 32 |
+
try:
|
| 33 |
+
thresh = seg.dBFS - 14 if seg.dBFS != float("-inf") else -40
|
| 34 |
+
nonsilent = detect_nonsilent(seg, min_silence_len=100, silence_thresh=thresh, seek_step=10)
|
| 35 |
+
if nonsilent:
|
| 36 |
+
start = max(0, nonsilent[0][0] - 40)
|
| 37 |
+
end = min(len(seg), nonsilent[-1][1] + 60)
|
| 38 |
+
seg = seg[start:end]
|
| 39 |
+
except Exception:
|
| 40 |
+
pass
|
| 41 |
+
|
| 42 |
+
min_ms = 700
|
| 43 |
+
if len(seg) < min_ms:
|
| 44 |
+
seg = seg + AudioSegment.silent(duration=min_ms - len(seg), frame_rate=sample_rate)
|
| 45 |
+
|
| 46 |
+
out = np.array(seg.get_array_of_samples(), dtype=np.float32) / 32_767.0
|
| 47 |
+
return out.astype(np.float32)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def audio_bytes_to_waveform(audio_bytes: bytes, filename: str) -> tuple[np.ndarray, int]:
|
| 51 |
+
"""Load WebM/WAV/OGG bytes via pydub; validate duration; return (waveform, sample_rate)."""
|
| 52 |
+
from pydub import AudioSegment
|
| 53 |
+
|
| 54 |
+
if len(audio_bytes) < 64:
|
| 55 |
+
raise ValueError("File ghi âm quá ngắn hoặc rỗng.")
|
| 56 |
+
|
| 57 |
+
suffix = (Path(filename or "audio.webm").suffix or ".webm").lower().lstrip(".")
|
| 58 |
+
fmt = None if suffix in {"webm", "bin"} else suffix
|
| 59 |
+
|
| 60 |
+
seg = AudioSegment.from_file(BytesIO(audio_bytes), format=fmt)
|
| 61 |
+
duration_s = len(seg) / 1000.0
|
| 62 |
+
if duration_s < 0.3:
|
| 63 |
+
raise ValueError("Âm thanh quá ngắn (<0.3 giây). Hãy nói rõ hơn.")
|
| 64 |
+
if duration_s > 10.0:
|
| 65 |
+
raise ValueError("Âm thanh quá dài (>10 giây). Hãy chỉ nói một từ.")
|
| 66 |
+
|
| 67 |
+
if seg.channels > 1:
|
| 68 |
+
seg = seg.set_channels(1)
|
| 69 |
+
|
| 70 |
+
samples = np.array(seg.get_array_of_samples(), dtype=np.float32)
|
| 71 |
+
max_val = float(2 ** (8 * seg.sample_width - 1))
|
| 72 |
+
if max_val > 0:
|
| 73 |
+
samples /= max_val
|
| 74 |
+
|
| 75 |
+
samples = prepare_speech_waveform(samples, int(seg.frame_rate))
|
| 76 |
+
return samples, int(seg.frame_rate)
|