AnhviNguyen commited on
Commit
b3e4ba6
·
1 Parent(s): 1c2ed83

update reading-listening + phát âm vob

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .claude/skills/gitnexus/gitnexus-cli/SKILL.md +83 -0
  2. .claude/skills/gitnexus/gitnexus-debugging/SKILL.md +89 -0
  3. .claude/skills/gitnexus/gitnexus-exploring/SKILL.md +78 -0
  4. .claude/skills/gitnexus/gitnexus-guide/SKILL.md +64 -0
  5. .claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md +97 -0
  6. .claude/skills/gitnexus/gitnexus-refactoring/SKILL.md +121 -0
  7. .env.production.example +2 -1
  8. AGENTS.md +43 -0
  9. CLAUDE.md +43 -0
  10. backend/.env.example +21 -2
  11. backend/README.md +48 -2
  12. backend/alembic/versions/002_password_reset.py +20 -11
  13. backend/alembic/versions/003_history_archive.py +26 -18
  14. backend/alembic/versions/004_adaptive_notifications.py +63 -49
  15. backend/alembic/versions/005_admin_user_columns.py +59 -0
  16. backend/alembic/versions/006_google_oauth_email_verify.py +68 -0
  17. backend/alembic/versions/007_translation_practice.py +78 -0
  18. backend/alembic/versions/20260524_initial_schema.py +398 -9
  19. backend/app/cli/__init__.py +1 -0
  20. backend/app/cli/promote_admin.py +55 -0
  21. backend/app/core/auth_cookies.py +5 -0
  22. backend/app/core/config.py +60 -10
  23. backend/app/core/dependencies.py +0 -10
  24. backend/app/core/metrics.py +17 -2
  25. backend/app/core/openrouter_client.py +253 -0
  26. backend/app/core/password_policy.py +55 -0
  27. backend/app/core/rate_limit.py +32 -4
  28. backend/app/core/upload.py +22 -1
  29. backend/app/data/translation_seed.py +319 -0
  30. backend/app/db/models.py +103 -0
  31. backend/app/main.py +43 -3
  32. backend/app/repositories/email_verification_repository.py +53 -0
  33. backend/app/repositories/profile_repository.py +7 -2
  34. backend/app/repositories/translation_repository.py +189 -0
  35. backend/app/repositories/user_repository.py +36 -6
  36. backend/app/routers/auth.py +54 -15
  37. backend/app/routers/history.py +8 -3
  38. backend/app/routers/pronunciation.py +88 -0
  39. backend/app/routers/shadowing.py +22 -4
  40. backend/app/routers/speaking.py +19 -26
  41. backend/app/routers/translation.py +71 -0
  42. backend/app/routers/users.py +12 -24
  43. backend/app/routers/writing.py +14 -22
  44. backend/app/schemas.py +25 -6
  45. backend/app/services/admin_content_service.py +2 -3
  46. backend/app/services/auth_service.py +214 -25
  47. backend/app/services/email_service.py +39 -0
  48. backend/app/services/phoneme_recognizer.py +164 -0
  49. backend/app/services/phoneme_scorer.py +373 -0
  50. 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
- ADMIN_EMAILS=admin@gmail.com
 
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 the API (**current directory must be `backend/`** so `import app` works):
 
 
 
 
 
 
 
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 LLM cards (grammar/vocabulary highlights):
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.create_table(
14
- "password_reset_tokens",
15
- sa.Column("id", sa.Integer(), primary_key=True),
16
- sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
17
- sa.Column("token_hash", sa.String(255), nullable=False),
18
- sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
19
- sa.Column("used_at", sa.DateTime(timezone=True), nullable=True),
20
- sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now()),
21
- )
22
- op.create_index("ix_password_reset_tokens_user_id", "password_reset_tokens", ["user_id"])
23
- op.create_index("ix_password_reset_tokens_token_hash", "password_reset_tokens", ["token_hash"], unique=True)
 
 
 
 
 
 
 
 
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.create_table(
14
- "history_archive",
15
- sa.Column("id", sa.Integer(), primary_key=True),
16
- sa.Column("user_id", sa.Integer(), nullable=False),
17
- sa.Column("quiz_id", sa.String(100), nullable=True),
18
- sa.Column("practice_session_id", sa.Integer(), nullable=True),
19
- sa.Column("subject", sa.String(100), nullable=True),
20
- sa.Column("score", sa.Integer(), nullable=True),
21
- sa.Column("total_questions", sa.Integer(), nullable=True),
22
- sa.Column("percentage", sa.Float(), nullable=True),
23
- sa.Column("band_score", sa.Float(), nullable=True),
24
- sa.Column("mode", sa.String(20), nullable=True),
25
- sa.Column("duration_seconds", sa.Integer(), nullable=True),
26
- sa.Column("answers", sa.JSON(), nullable=True),
27
- sa.Column("completed_at", sa.DateTime(timezone=True), nullable=False),
28
- sa.Column("archived_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
29
- )
30
- op.create_index("idx_history_archive_user_date", "history_archive", ["user_id", "completed_at"])
 
 
 
 
 
 
 
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.add_column(
14
- "study_plan_tasks",
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
- op.create_table(
23
- "skill_adaptive_states",
24
- sa.Column("id", sa.Integer(), primary_key=True),
25
- sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
26
- sa.Column("skill", sa.String(30), nullable=False),
27
- sa.Column("srs_ease", sa.Float(), server_default="2.5", nullable=False),
28
- sa.Column("srs_interval_days", sa.Integer(), server_default="1", nullable=False),
29
- sa.Column("srs_repetitions", sa.Integer(), server_default="0", nullable=False),
30
- sa.Column("srs_next_review_at", sa.DateTime(timezone=True), nullable=True),
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
- op.create_table(
41
- "notification_settings",
42
- sa.Column("id", sa.Integer(), primary_key=True),
43
- sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False, unique=True),
44
- sa.Column("reminder_enabled", sa.Boolean(), server_default=sa.true(), nullable=False),
45
- sa.Column("reminder_time", sa.Time(), nullable=True),
46
- sa.Column("channel", sa.String(20), server_default="in_app", nullable=False),
47
- sa.Column("email_daily_digest", sa.Boolean(), server_default=sa.false(), nullable=False),
48
- sa.Column("push_enabled", sa.Boolean(), server_default=sa.false(), nullable=False),
49
- sa.Column("timezone", sa.String(64), server_default="Asia/Ho_Chi_Minh", nullable=False),
50
- sa.Column("updated_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
51
- )
 
 
 
 
 
 
 
 
 
52
 
53
- op.create_table(
54
- "notifications",
55
- sa.Column("id", sa.Integer(), primary_key=True),
56
- sa.Column("user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="CASCADE"), nullable=False),
57
- sa.Column("type", sa.String(40), nullable=False),
58
- sa.Column("title", sa.String(200), nullable=False),
59
- sa.Column("body", sa.Text(), nullable=False),
60
- sa.Column("link_path", sa.String(200), nullable=True),
61
- sa.Column("is_read", sa.Boolean(), server_default=sa.false(), nullable=False),
62
- sa.Column("created_at", sa.DateTime(timezone=True), server_default=sa.func.now(), nullable=False),
63
- )
64
- op.create_index("idx_notifications_user_read", "notifications", ["user_id", "is_read", "created_at"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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 — all tables from SQLAlchemy models.
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
- bind = op.get_bind()
25
- Base.metadata.create_all(bind=bind)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
 
27
 
28
  def downgrade() -> None:
29
- bind = op.get_bind()
30
- Base.metadata.drop_all(bind=bind)
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- async def metrics_endpoint() -> Response:
 
 
 
 
 
 
 
 
 
 
 
 
 
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) shared limiter and 429 handler."""
 
 
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=get_remote_address,
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": "Quá nhiều yêu cầu. Vui lòng thử lại sau."},
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 file.read()
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 request.url.scheme == "https":
 
 
 
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(self, user_id: int, full_name: str | None = None) -> UserProfile:
29
- profile = UserProfile(user_id=user_id, full_name=full_name)
 
 
 
 
 
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 create(self, email: str, password_hash: str, role: str = "user") -> User:
29
- """Insert a new user row and return the persisted object."""
30
- user = User(email=email, password_hash=password_hash, role=role)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
31
  self._db.add(user)
32
- await self._db.flush() # get auto-assigned id without committing
 
 
 
 
 
 
 
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 and login.
5
- No JWT required — these are public routes.
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=Token,
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.register(payload)
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
- service = HistoryService(db)
63
- return await service.save_practice_result(current_user, payload)
 
 
 
 
 
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
- await file.read(),
182
  file.filename or "audio.webm",
183
  target_text.strip(),
184
  )
185
  return data
186
  except FileNotFoundError as e:
187
- raise HTTPException(status_code=503, detail=str(e)) from e
 
 
 
 
 
188
  except Exception as e:
189
  logger.exception("pronunciation check failed")
190
- raise HTTPException(status_code=500, detail=str(e)) from e
 
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 OPENROUTER_KEY:
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
- messages.append({"role": m.role, "content": m.content})
 
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
- async with httpx.AsyncClient(timeout=60.0) as client:
104
- resp = await client.post(OPENROUTER_URL, json=payload, headers=headers)
105
- resp.raise_for_status()
106
- reply = resp.json()["choices"][0]["message"]["content"]
 
 
 
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(await file.read())
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.config import settings
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 settings.OPENROUTER_API_KEY:
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
- messages.append({"role": m.role, "content": m.content})
 
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
- async with httpx.AsyncClient(timeout=20.0) as client:
409
- resp = await client.post(_OPENROUTER_URL, json=payload, headers=headers)
410
- resp.raise_for_status()
411
- reply = resp.json()["choices"][0]["message"]["content"]
 
 
 
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
- messages.append({"role": m.role, "content": m.content})
 
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
- async with httpx.AsyncClient(timeout=15.0) as client:
76
- resp = await client.post(_OPENROUTER_URL, json=payload, headers=headers)
77
- resp.raise_for_status()
78
- reply = resp.json()["choices"][0]["message"]["content"]
 
 
 
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=6, max_length=128)
15
  full_name: Optional[str] = None
16
 
17
  class UserLogin(BaseModel):
18
  email: EmailStr
19
- password: str = Field(min_length=6, max_length=128)
20
 
21
  class Token(BaseModel):
22
  access_token: str
@@ -67,7 +67,25 @@ class AuthLogoutRequest(BaseModel):
67
 
68
 
69
  class VerifyEmailRequest(BaseModel):
70
- token: str
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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=6, max_length=128)
80
 
81
 
82
  # ═══ Progress ════════════════════════════════
@@ -191,8 +209,8 @@ class UserMeUpdateRequest(BaseModel):
191
 
192
 
193
  class ChangePasswordRequest(BaseModel):
194
- current_password: str = Field(min_length=6, max_length=128)
195
- new_password: str = Field(min_length=6, max_length=128)
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 upload.read()
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 login.
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 admin_email_set, settings
 
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 ForgotPasswordRequest, MessageResponse, ResetPasswordRequest, Token, UserCreate, UserLogin
29
- from app.services.email_service import send_password_reset_email, smtp_configured
 
 
 
 
 
 
 
 
 
 
 
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) -> Token:
42
  """
43
- Register a new user:
44
  1. Check email is not taken
45
  2. Hash password
46
- 3. Create user row
47
- 4. Create empty profile (with optional full_name)
48
- 5. Return JWT token so the user is immediately logged in
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
- role = "admin" if payload.email.lower() in admin_email_set() else "user"
66
- user = await self._user_repo.create(email=payload.email, password_hash=hashed, role=role)
 
 
 
 
 
 
67
  await self._profile_repo.create_empty(user.id, full_name=payload.full_name)
 
 
 
 
68
 
69
- logger.info("New user registered: id=%s email=%s", user.id, user.email)
 
 
 
 
70
 
 
 
 
 
 
 
 
 
71
  return await self._issue_token_pair(user.id)
72
 
73
- async def login(self, payload: UserLogin) -> Token:
 
 
 
 
 
 
 
 
 
 
74
  """
75
- Authenticate a user:
76
- 1. Look up by email
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="Account is locked",
 
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)