MinhTai commited on
Commit
9d58139
·
0 Parent(s):

deploy: 387d60b

Browse files
This view is limited to 50 files because it contains too many changes.   See raw diff
Files changed (50) hide show
  1. .dockerignore +51 -0
  2. .env.example +7 -0
  3. .github/workflows/admin-key-log.yml +33 -0
  4. .github/workflows/demo-video.yml +92 -0
  5. .github/workflows/test.yml +137 -0
  6. .gitignore +94 -0
  7. .mcp.json +16 -0
  8. AGENTS.md +87 -0
  9. BAO_CAO_NGHIEN_CUU.md +706 -0
  10. CLAUDE.md +335 -0
  11. Dockerfile +27 -0
  12. README.md +15 -0
  13. backend/.env.example +22 -0
  14. backend/app/__init__.py +0 -0
  15. backend/app/config.py +22 -0
  16. backend/app/db.py +214 -0
  17. backend/app/main.py +184 -0
  18. backend/pyproject.toml +2 -0
  19. backend/tests/__init__.py +0 -0
  20. backend/tests/test_exams.py +81 -0
  21. demo-video/ai-enhancement.md +180 -0
  22. demo-video/debug-demo.spec.ts +58 -0
  23. demo-video/demo-data-plan.md +286 -0
  24. demo-video/demo-strategy.md +89 -0
  25. demo-video/narration.md +186 -0
  26. demo-video/playwright-demo.spec.ts +808 -0
  27. demo-video/playwright.config.debug.js +12 -0
  28. demo-video/playwright.config.demo.js +76 -0
  29. demo-video/product-analysis.md +114 -0
  30. demo-video/recording-plan.md +168 -0
  31. demo-video/review.md +132 -0
  32. demo-video/storyboard.md +198 -0
  33. deploy-hf.sh +33 -0
  34. design-system/.design-sync/NOTES.md +29 -0
  35. design-system/.design-sync/config.json +7 -0
  36. design-system/.design-sync/previews/AIErrorBoundary.tsx +11 -0
  37. design-system/.design-sync/previews/AccountPageSkeleton.tsx +9 -0
  38. design-system/.design-sync/previews/AchievementCeremony.tsx +43 -0
  39. design-system/.design-sync/previews/CreditsTooltip.tsx +64 -0
  40. design-system/.design-sync/previews/ExamSelectSkeleton.tsx +9 -0
  41. design-system/.design-sync/previews/FormulaDrawer.tsx +68 -0
  42. design-system/.design-sync/previews/HistoryPageSkeleton.tsx +9 -0
  43. design-system/.design-sync/previews/HomePageSkeleton.tsx +9 -0
  44. design-system/.design-sync/previews/InstallPrompt.tsx +53 -0
  45. design-system/.design-sync/previews/LockedFeatureCard.tsx +20 -0
  46. design-system/.design-sync/previews/MarkdownProse.tsx +67 -0
  47. design-system/.design-sync/previews/MathBlock.tsx +35 -0
  48. design-system/.design-sync/previews/MathText.tsx +32 -0
  49. design-system/.design-sync/previews/NavbarSkeleton.tsx +7 -0
  50. design-system/.design-sync/previews/NumberTicker.tsx +42 -0
.dockerignore ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Python caches
2
+ **/__pycache__/
3
+ **/*.py[cod]
4
+ **/*.pyo
5
+ .venv/
6
+ venv/
7
+ *.egg-info/
8
+ dist/
9
+ build/
10
+ .pytest_cache/
11
+ .mypy_cache/
12
+ .ruff_cache/
13
+
14
+ # Node
15
+ node_modules/
16
+ exam-app/
17
+ !exam-app/src/data/exams.json
18
+ !exam-app/src/data/questions.json
19
+
20
+ # Env files (set secrets in Koyeb dashboard, not baked into image)
21
+ .env
22
+ *.env.local
23
+
24
+ # Local wiki artifacts (rebuilt at runtime)
25
+ math_wiki.db
26
+ math_wiki.db-shm
27
+ math_wiki.db-wal
28
+ math_wiki.bm25.pkl
29
+ math_wiki.faiss
30
+ math_wiki.meta.pkl
31
+ backend/math_wiki.db
32
+
33
+ # Dev/test artifacts
34
+ *.png
35
+ *.pen
36
+ test-results/
37
+ tests/
38
+ docs/
39
+ core/
40
+ validators/
41
+ exam-app-plan.md
42
+ response-*.json
43
+ .gitnexus/
44
+ .playwright-mcp/
45
+ .claude/
46
+ .kilo/
47
+ playwright.config.js
48
+
49
+ # Git
50
+ .git/
51
+ .gitignore
.env.example ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ ANTHROPIC_BASE_URL=https://ai-router.locdo.tech
2
+ ANTHROPIC_AUTH_TOKEN=your-auth-token-here
3
+ ANTHROPIC_DEFAULT_OPUS_MODEL=claude-opus-4.6
4
+ ANTHROPIC_DEFAULT_SONNET_MODEL=claude-sonnet-4.6
5
+ ANTHROPIC_DEFAULT_HAIKU_MODEL=claude-haiku-4.5
6
+ EMBEDDING_MODEL_NAME=BAAI/bge-m3
7
+ EMBEDDING_DIM=1024
.github/workflows/admin-key-log.yml ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Admin Key Log (fallback)
2
+
3
+ # Fallback scheduler — fires if cron-job.org misses a run.
4
+ # Primary scheduler: cron-job.org (POST /admin/generate-key-log, X-Cron-Secret header)
5
+ # This workflow is a safety net only; cron-job.org is preferred.
6
+ #
7
+ # Required GitHub repo secrets:
8
+ # HF_SPACE_URL — e.g. https://your-space.hf.space
9
+ # CRON_SECRET — same value as CRON_SECRET in HF Secrets (≥32 chars)
10
+ #
11
+ # Schedule: 20:05 UTC Sunday = 03:05 ICT Monday (5 min after cron-job.org fires at 20:00)
12
+ # If cron-job.org already ran, the backend will just append a duplicate line — harmless.
13
+
14
+ on:
15
+ schedule:
16
+ - cron: "5 20 * * 0"
17
+ workflow_dispatch:
18
+
19
+ jobs:
20
+ trigger-key-log:
21
+ runs-on: ubuntu-latest
22
+ steps:
23
+ - name: Trigger key log generation
24
+ run: |
25
+ HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \
26
+ -X POST "${{ secrets.HF_SPACE_URL }}/admin/generate-key-log" \
27
+ -H "X-Cron-Secret: ${{ secrets.CRON_SECRET }}" \
28
+ -H "Content-Type: application/json")
29
+ echo "Response status: $HTTP_STATUS"
30
+ if [ "$HTTP_STATUS" != "200" ]; then
31
+ echo "Key log generation failed with status $HTTP_STATUS"
32
+ exit 1
33
+ fi
.github/workflows/demo-video.yml ADDED
@@ -0,0 +1,92 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Demo Video Recording
2
+
3
+ on:
4
+ # Run automatically after Cloudflare Pages deploy completes
5
+ workflow_run:
6
+ workflows: ['Deploy to Cloudflare Pages']
7
+ types: [completed]
8
+ branches: [master]
9
+
10
+ # Also allow manual trigger for on-demand re-recording
11
+ workflow_dispatch:
12
+ inputs:
13
+ base_url:
14
+ description: 'App URL to record against (leave empty for production)'
15
+ required: false
16
+ default: 'https://exam-app-ey0.pages.dev'
17
+
18
+ # Never block production deploys — this job is informational only
19
+ jobs:
20
+ record-demo:
21
+ name: Record product demo
22
+ runs-on: ubuntu-latest
23
+ # Only run if the triggering workflow succeeded (or manual trigger)
24
+ if: >
25
+ github.event_name == 'workflow_dispatch' ||
26
+ github.event.workflow_run.conclusion == 'success'
27
+
28
+ # Non-blocking: demo recording failures do NOT fail the overall CI run
29
+ continue-on-error: true
30
+
31
+ env:
32
+ DEMO_BASE_URL: ${{ github.event.inputs.base_url || 'https://exam-app-ey0.pages.dev' }}
33
+
34
+ steps:
35
+ - name: Checkout
36
+ uses: actions/checkout@v4
37
+
38
+ - name: Setup Node.js
39
+ uses: actions/setup-node@v4
40
+ with:
41
+ node-version: '20'
42
+ cache: 'npm'
43
+ cache-dependency-path: 'exam-app/package-lock.json'
44
+
45
+ - name: Install Playwright and Chromium
46
+ run: npx playwright install chromium --with-deps
47
+
48
+ - name: Wait for production URL to be responsive
49
+ run: |
50
+ echo "Waiting for $DEMO_BASE_URL to respond..."
51
+ for i in $(seq 1 30); do
52
+ if curl -sf --max-time 10 "$DEMO_BASE_URL" > /dev/null 2>&1; then
53
+ echo "App is live after $((i * 10))s"
54
+ break
55
+ fi
56
+ echo "Attempt $i/30 — retrying in 10s..."
57
+ sleep 10
58
+ done
59
+
60
+ - name: Run demo recording
61
+ run: |
62
+ npx playwright test demo-video/playwright-demo.spec.ts \
63
+ --config demo-video/playwright.config.demo.js \
64
+ --project=chromium
65
+ env:
66
+ DEMO_BASE_URL: ${{ env.DEMO_BASE_URL }}
67
+
68
+ - name: Upload screenshots artifact
69
+ if: always()
70
+ uses: actions/upload-artifact@v4
71
+ with:
72
+ name: demo-screenshots-${{ github.sha }}
73
+ path: demo-video/screenshots/
74
+ retention-days: 90
75
+ if-no-files-found: warn
76
+
77
+ - name: Upload video recording artifact
78
+ if: always()
79
+ uses: actions/upload-artifact@v4
80
+ with:
81
+ name: demo-video-recording-${{ github.sha }}
82
+ path: test-results/
83
+ retention-days: 90
84
+ if-no-files-found: warn
85
+
86
+ - name: Upload HTML report on failure
87
+ if: failure()
88
+ uses: actions/upload-artifact@v4
89
+ with:
90
+ name: demo-playwright-report-${{ github.sha }}
91
+ path: playwright-report/
92
+ retention-days: 14
.github/workflows/test.yml ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Tests
2
+
3
+ on:
4
+ push:
5
+ branches: [master, main]
6
+ paths:
7
+ - 'backend/**'
8
+ - 'exam-app/**'
9
+ - 'requirements*.txt'
10
+ - '.github/workflows/test.yml'
11
+ pull_request:
12
+ branches: [master, main]
13
+ paths:
14
+ - 'backend/**'
15
+ - 'exam-app/**'
16
+ - 'requirements*.txt'
17
+
18
+ jobs:
19
+ # ── Backend unit + integration tests ────────────────────────────────────────
20
+ backend-tests:
21
+ name: Backend Tests (Python ${{ matrix.python-version }})
22
+ runs-on: ubuntu-latest
23
+ strategy:
24
+ fail-fast: false
25
+ matrix:
26
+ python-version: ["3.12"]
27
+
28
+ steps:
29
+ - uses: actions/checkout@v4
30
+
31
+ - name: Set up Python ${{ matrix.python-version }}
32
+ uses: actions/setup-python@v5
33
+ with:
34
+ python-version: ${{ matrix.python-version }}
35
+ cache: pip
36
+
37
+ - name: Install dependencies
38
+ run: |
39
+ pip install -r requirements.txt
40
+ pip install -r requirements-dev.txt
41
+
42
+ - name: Run fast test suite (no live AI, no fault injection)
43
+ env:
44
+ ANTHROPIC_AUTH_TOKEN: test-token-ci
45
+ JWT_SECRET: ${{ secrets.JWT_SECRET || 'ci-test-secret-at-least-32-chars-long' }}
46
+ ADMIN_KEY: ci-admin-key
47
+ ADMIN_MASTER_SECRET: ""
48
+ SQLITE_PATH: ":memory:"
49
+ run: |
50
+ PYTHONPATH=backend python3 -m pytest backend/tests/ \
51
+ -m "not live_ai and not fault_injection" \
52
+ --tb=short \
53
+ -q \
54
+ --randomly-seed=0 \
55
+ -x
56
+
57
+ - name: Run fault injection tests
58
+ env:
59
+ ANTHROPIC_AUTH_TOKEN: test-token-ci
60
+ JWT_SECRET: ${{ secrets.JWT_SECRET || 'ci-test-secret-at-least-32-chars-long' }}
61
+ ADMIN_KEY: ci-admin-key
62
+ ADMIN_MASTER_SECRET: ""
63
+ SQLITE_PATH: ":memory:"
64
+ run: |
65
+ PYTHONPATH=backend python3 -m pytest backend/tests/ \
66
+ -m "fault_injection" \
67
+ --tb=short \
68
+ -q
69
+ continue-on-error: true # fault injection is informational until stabilised
70
+
71
+ # ── Frontend unit tests ──────────────────────────────────────────────────────
72
+ frontend-tests:
73
+ name: Frontend Tests (Node ${{ matrix.node-version }})
74
+ runs-on: ubuntu-latest
75
+ strategy:
76
+ matrix:
77
+ node-version: ["20"]
78
+
79
+ defaults:
80
+ run:
81
+ working-directory: exam-app
82
+
83
+ steps:
84
+ - uses: actions/checkout@v4
85
+
86
+ - name: Set up Node ${{ matrix.node-version }}
87
+ uses: actions/setup-node@v4
88
+ with:
89
+ node-version: ${{ matrix.node-version }}
90
+ cache: npm
91
+ cache-dependency-path: exam-app/package-lock.json
92
+
93
+ - name: Install dependencies
94
+ run: npm ci
95
+
96
+ - name: Run Vitest unit tests
97
+ run: npm test
98
+
99
+ # ── Mutation score gate (weekly, manual, or on demand) ───────────────────────
100
+ mutation-score:
101
+ name: Mutation Score Gate
102
+ runs-on: ubuntu-latest
103
+ if: github.event_name == 'workflow_dispatch' || github.event.schedule != ''
104
+ # Run manually via: gh workflow run test.yml
105
+ # Or add to a scheduled cron job by uncommenting below:
106
+ # on:
107
+ # schedule:
108
+ # - cron: '0 3 * * 1' # Monday 03:00 UTC
109
+
110
+ steps:
111
+ - uses: actions/checkout@v4
112
+
113
+ - uses: actions/setup-python@v5
114
+ with:
115
+ python-version: "3.12"
116
+ cache: pip
117
+
118
+ - name: Install dependencies
119
+ run: |
120
+ pip install -r requirements.txt
121
+ pip install -r requirements-dev.txt
122
+
123
+ - name: Run mutation testing
124
+ env:
125
+ ANTHROPIC_AUTH_TOKEN: test-token-ci
126
+ JWT_SECRET: ci-test-secret-at-least-32-chars-long
127
+ ADMIN_KEY: ci-admin-key
128
+ ADMIN_MASTER_SECRET: ""
129
+ run: |
130
+ PYTHONPATH=backend mutmut run \
131
+ --paths-to-mutate backend/app/agent/ \
132
+ --tests-dir backend/tests/ \
133
+ || true # mutmut exits non-zero when mutations survive — handled below
134
+
135
+ - name: Check mutation score gate
136
+ run: |
137
+ PYTHONPATH=backend python3 tools/check_mutation_score.py --min 70
.gitignore ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Environment
2
+ .env
3
+ *.env.local
4
+
5
+ # Python
6
+ __pycache__/
7
+ *.py[cod]
8
+ *.pyo
9
+ .venv/
10
+ venv/
11
+ *.egg-info/
12
+ dist/
13
+ build/
14
+ .pytest_cache/
15
+ .mypy_cache/
16
+ .ruff_cache/
17
+
18
+ # Node
19
+ node_modules/
20
+ exam-app/dist/
21
+
22
+ # Claude Code local settings (machine-specific)
23
+ .claude/settings.local.json
24
+
25
+ # GitNexus index (regenerated via `gitnexus analyze`)
26
+ .gitnexus/
27
+
28
+ # Ingestion state (local run progress, not source)
29
+ exam-app/scripts/ingest/state.json
30
+ backend/scripts/ingest/ingest_state.json
31
+
32
+ # Cloudflare Pages / Wrangler local state
33
+ .wrangler/
34
+
35
+ # Misc
36
+ *.pen
37
+ .DS_Store
38
+ Thumbs.db
39
+
40
+ # Screenshots (all root-level PNGs are dev/test artifacts)
41
+ *.png
42
+ # Exception: question figure images are static assets shipped with the app
43
+ !exam-app/public/images/questions/*.png
44
+
45
+ # Math wiki local artifacts (regenerated by ingest pipeline)
46
+ math_wiki.db
47
+ math_wiki.db-shm
48
+ math_wiki.db-wal
49
+ math_wiki.bm25.pkl
50
+ math_wiki.faiss
51
+ math_wiki.meta.pkl
52
+ backend/math_wiki.db
53
+
54
+ # Crawl runtime state
55
+ scripts/crawl_progress.json
56
+
57
+ # Question image audit / crawl artifacts
58
+ exam-app/src/data/questions.json.bak*
59
+ tools/.kangaroo_pdfs/
60
+
61
+ # Test results
62
+ test-results/
63
+
64
+ # Demo video artifacts (screenshots and recordings are CI artifacts, not source)
65
+ demo-video/screenshots/
66
+ demo-video/test-results/
67
+ demo-video/*.mp4
68
+ demo-video/*.webm
69
+
70
+ # Playwright MCP cache
71
+ .playwright-mcp/
72
+
73
+ # Local dev/agent artifacts
74
+ .claude/
75
+ docs/
76
+ exam-app-plan.md
77
+
78
+ # Kilo Code editor state
79
+ .kilo/
80
+
81
+ # Standalone agent framework (not integrated into the app)
82
+ core/
83
+
84
+ # Unused standalone validators (not imported by backend or exam-app)
85
+ validators/
86
+
87
+ # Empty local dev API response dumps
88
+ response-*.json
89
+
90
+ # Runtime caches
91
+ scripts/.pauls_sentences_cache.json
92
+
93
+ # Serena MCP local state
94
+ .serena/
.mcp.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "mcpServers": {
3
+ "playwright": {
4
+ "command": "npx",
5
+ "args": ["@playwright/mcp@latest", "--browser", "chromium"]
6
+ },
7
+ "shadcnspace": {
8
+ "type": "http",
9
+ "url": "https://mcp.shadcnspace.com"
10
+ },
11
+ "typeui": {
12
+ "type": "http",
13
+ "url": "https://mcp.typeui.sh"
14
+ }
15
+ }
16
+ }
AGENTS.md ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ## Auth & Credit System
2
+
3
+ ### Auth-required endpoints
4
+ All AI endpoints (`/analyze`, `/hint`, `/explain`, `/study-plan`) require a valid JWT in `Authorization: Bearer <token>`. The token is obtained from `POST /auth/google`.
5
+
6
+ ### Credit deduction per feature
7
+ | Feature | Endpoint | Credits |
8
+ |---|---|---|
9
+ | Socratic hint | POST /hint | 1 |
10
+ | Answer explanation | POST /explain | 1 |
11
+ | Result analysis | POST /analyze | 3 |
12
+ | Study plan | POST /study-plan | 5 |
13
+
14
+ `/study-plan` also requires `subscription_tier` ∈ {student, complete} — returns 403 `tier_required` otherwise.
15
+
16
+ ### Getting the current admin key
17
+
18
+ Admin keys rotate automatically (default: weekly). Get the current key from either:
19
+ 1. **HF Spaces** → Files tab → `/data/admin_keys.txt` → copy the latest line's key
20
+ 2. **Local fallback**: `python tools/gen_admin_key.py` (prompts for `ADMIN_MASTER_SECRET`)
21
+
22
+ ### Granting manual top-ups (admin)
23
+ ```
24
+ POST /admin/users/{user_id}/credits
25
+ X-Admin-Key: <current derived key from admin_keys.txt or gen_admin_key.py>
26
+ {"amount": 500, "reason": "manual_topup_bank_transfer"}
27
+ ```
28
+
29
+ ### Activating subscriptions (admin)
30
+ ```
31
+ POST /admin/users/{user_id}/subscription
32
+ X-Admin-Key: <current derived key>
33
+ {"tier": "student", "period": "monthly", "expires_at": "2026-06-15T00:00:00Z", "bonus_credits": 0}
34
+ ```
35
+
36
+ ### Suspending abusive accounts (admin)
37
+ ```
38
+ POST /admin/users/{user_id}/suspend
39
+ X-Admin-Key: <current derived key>
40
+ {"reason": "credit_velocity abuse"}
41
+ ```
42
+
43
+ The abuse detector (`backend/app/abuse_detector.py`) runs every 5 minutes and auto-suspends on HIGH-confidence signals (credit velocity, burst >100 req/10min). MEDIUM-confidence events are logged to `security_events` for manual review via `GET /admin/security-events`.
44
+
45
+ <!-- gitnexus:start -->
46
+ # GitNexus — Code Intelligence
47
+
48
+ This project is indexed by GitNexus as **AI-Agent-App** (2571 symbols, 3957 relationships, 79 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
49
+
50
+ > Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
51
+
52
+ ## Always Do
53
+
54
+ - **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
55
+ - **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: `detect_changes({scope: "compare", base_ref: "main"})`.
56
+ - **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
57
+ - When exploring unfamiliar code, use `query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
58
+ - When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
59
+
60
+ ## Never Do
61
+
62
+ - NEVER edit a function, class, or method without first running `impact` on it.
63
+ - NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
64
+ - NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
65
+ - NEVER commit changes without running `detect_changes()` to check affected scope.
66
+
67
+ ## Resources
68
+
69
+ | Resource | Use for |
70
+ |----------|---------|
71
+ | `gitnexus://repo/AI-Agent-App/context` | Codebase overview, check index freshness |
72
+ | `gitnexus://repo/AI-Agent-App/clusters` | All functional areas |
73
+ | `gitnexus://repo/AI-Agent-App/processes` | All execution flows |
74
+ | `gitnexus://repo/AI-Agent-App/process/{name}` | Step-by-step execution trace |
75
+
76
+ ## CLI
77
+
78
+ | Task | Read this skill file |
79
+ |------|---------------------|
80
+ | Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
81
+ | Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
82
+ | Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
83
+ | Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
84
+ | Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
85
+ | Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
86
+
87
+ <!-- gitnexus:end -->
BAO_CAO_NGHIEN_CUU.md ADDED
@@ -0,0 +1,706 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Zenith — Báo Cáo Sản Phẩm Toàn Diện
2
+
3
+ **Dự án:** Zenith — Nền tảng luyện thi Toán thích ứng dành cho học sinh Việt Nam
4
+ **Đối tượng đọc:** Lãnh đạo, quản lý sản phẩm — không yêu cầu kiến thức kỹ thuật
5
+ **Ngày:** tháng 6 năm 2026
6
+ **Phiên bản:** 2.0
7
+
8
+ ---
9
+
10
+ ## Tóm Tắt Điều Hành
11
+
12
+ Zenith là ứng dụng luyện thi Toán dành cho học sinh từ lớp 9 đến lớp 12 tại Việt Nam, tập trung vào kỳ thi tuyển sinh lớp 10 và kỳ thi THPT Quốc gia. Điểm khác biệt cốt lõi là **hệ thống học thích ứng**: thay vì cho tất cả học sinh làm cùng một bộ đề, Zenith tự động phân tích điểm yếu của từng em và điều chỉnh nội dung luyện tập theo thời gian thực.
13
+
14
+ Nền tảng bao gồm **24 tính năng chính** trải dài trên toàn bộ hành trình học tập — từ bài kiểm tra đầu vào, làm đề thi, phân tích kết quả, luyện tập thích ứng, đến ôn tập thông minh và theo dõi tiến độ dài hạn. Tất cả tính năng được hỗ trợ bởi AI (mô hình Claude của Anthropic) và được xây dựng dựa trên các phương pháp giáo dục đã được nghiên cứu khoa học kiểm chứng.
15
+
16
+ **Kho đề thi:** 86 đề từ 47 nguồn thực (Bộ GD&ĐT, Sở GD&ĐT các tỉnh, AMC Mỹ, CEMC Canada, SEC Ireland, KICE Hàn Quốc), với 2.075+ câu hỏi được phân loại theo chủ đề và mức độ khó.
17
+
18
+ ---
19
+
20
+ ## Mục Lục
21
+
22
+ 1. [Hành Trình Học Sinh](#1-hành-trình-học-sinh)
23
+ 2. [Bước 1 — Đánh Giá Năng Lực Đầu Vào](#2-bước-1--đánh-giá-năng-lực-đầu-vào)
24
+ 3. [Bước 2 — Làm Bài Thi](#3-bước-2--làm-bài-thi)
25
+ 4. [Bước 3 — Phân Tích Kết Quả và Đề Xuất Trường](#4-bước-3--phân-tích-kết-quả-và-đề-xuất-trường)
26
+ 5. [Bước 4 — Kế Hoạch Học Tập Cá Nhân Hóa](#5-bước-4--kế-hoạch-học-tập-cá-nhân-hóa)
27
+ 6. [Bước 5 — Luyện Tập Thích Ứng Hàng Ngày](#6-bước-5--luyện-tập-thích-ứng-hàng-ngày)
28
+ 7. [Bước 6 — Ôn Tập Thông Minh (Spaced Repetition)](#7-bước-6--ôn-tập-thông-minh-spaced-repetition)
29
+ 8. [Bước 7 — Oracle: Gia Sư Toán AI](#8-bước-7--oracle-gia-sư-toán-ai)
30
+ 9. [Bước 8 — Theo Dõi Tiến Độ và Bản Đồ Khái Niệm](#9-bước-8--theo-dõi-tiến-độ-và-bản-đồ-khái-niệm)
31
+ 10. [Tính Năng Cộng Đồng và Gamification](#10-tính-năng-cộng-đồng-và-gamification)
32
+ 11. [Tài Khoản và Thống Kê Cá Nhân](#11-tài-khoản-và-thống-kê-cá-nhân)
33
+ 12. [Kho Đề Thi và Dữ Liệu](#12-kho-đề-thi-và-dữ-liệu)
34
+ 13. [Gói Dịch Vụ và Hệ Thống Tín Dụng AI](#13-gói-dịch-vụ-và-hệ-thống-tín-dụng-ai)
35
+ 14. [Cơ Sở Khoa Học](#14-cơ-sở-khoa-học)
36
+ 15. [Tài Liệu Tham Khảo](#15-tài-liệu-tham-khảo)
37
+
38
+ ---
39
+
40
+ ## 1. Hành Trình Học Sinh
41
+
42
+ Dưới đây là toàn bộ luồng trải nghiệm của một học sinh trên Zenith:
43
+
44
+ ```
45
+ Lần đầu vào app
46
+
47
+
48
+ [Kiểm tra đầu vào] ──→ AI phân tích điểm yếu
49
+
50
+
51
+ [Chọn đề thi] ──→ 86 đề từ THPT/thi lớp 10/quốc tế
52
+
53
+
54
+ [Làm bài] ──→ Hẹn giờ, gợi ý AI, chống gian lận
55
+
56
+
57
+ [Kết quả] ──→ Điểm số, biểu đồ, đề xuất trường, kế hoạch
58
+
59
+ ├──→ [Kế hoạch học tập 4 tuần] (AI tạo tự động)
60
+
61
+ ├──→ [Luyện tập thích ứng] (15 câu tập trung vào điểm yếu)
62
+
63
+ ├──→ [Ôn tập spaced repetition] (nhắc đúng lúc quên)
64
+
65
+ ├──→ [Oracle - hỏi đáp Toán AI] (giải thích + gợi ý)
66
+
67
+ └──→ [Tiến độ & Bản đồ khái niệm] (58 khái niệm Toán)
68
+ ```
69
+
70
+ ---
71
+
72
+ ## 2. Bước 1 — Đánh Giá Năng Lực Đầu Vào
73
+
74
+ ### 2.1 Bài Kiểm Tra Xếp Lớp (Placement Test)
75
+
76
+ **Trang:** `/placement`
77
+
78
+ Khi học sinh lần đầu đăng ký, Zenith mời làm **bài kiểm tra 10 câu** bao phủ 10 chủ đề Toán khác nhau (đại số, hàm số, hình học, xác suất, tổ hợp…). Mỗi câu được chọn ngẫu nhiên từ mức độ trung bình để tránh quá dễ hoặc quá khó.
79
+
80
+ **Kết quả:** Hệ thống tính điểm ước lượng năng lực ban đầu của học sinh trên thang điểm chuẩn, sử dụng làm cơ sở cho tất cả đề xuất sau đó.
81
+
82
+ ### 2.2 Bài Kiểm Tra Chẩn Đoán (Diagnostic Test)
83
+
84
+ **Trang:** `/diagnostic`
85
+
86
+ Sau khi xếp lớp, học sinh có thể làm thêm **bài kiểm tra chẩn đoán 12 câu**, phân phối đều cho 6 chủ đề trọng tâm (Đại số, Hình học, Thống kê, Tổ hợp, Lượng giác, Hàm số — 2 câu mỗi chủ đề).
87
+
88
+ **Kết quả:**
89
+ Hệ thống tính **"trọng số điểm yếu"** cho từng chủ đề: chủ đề nào học sinh làm sai nhiều hơn sẽ nhận trọng số cao hơn, và sẽ xuất hiện nhiều hơn trong các buổi luyện tập thích ứng sau đó.
90
+
91
+ **Tại sao cần bước này?**
92
+ Nghiên cứu từ năm 2011 cho thấy các hệ thống gia sư thông minh có thể đạt hiệu quả tương đương gia sư một-một khi được cá nhân hóa đúng cách — nhưng điều kiện tiên quyết là hệ thống phải biết học sinh đang ở đâu trước khi bắt đầu dạy. Bước chẩn đoán là chìa khóa để mở tính năng này [1].
93
+
94
+ ---
95
+
96
+ ## 3. Bước 2 — Làm Bài Thi
97
+
98
+ ### 3.1 Chọn Đề Thi (ExamSelect)
99
+
100
+ **Trang:** `/exams`
101
+
102
+ Học sinh duyệt **86 đề thi** chia thành 3 danh mục:
103
+
104
+ | Danh mục | Số đề | Đối tượng |
105
+ |----------|-------|-----------|
106
+ | Thi vào lớp 10 | 47 đề | Học sinh lớp 9 |
107
+ | THPT Quốc gia | 29 đề | Học sinh lớp 10–12 |
108
+ | Ứng dụng / Quốc tế | 10 đề | Nâng cao, AMC, CEMC |
109
+
110
+ Giao diện cho phép **lọc theo lớp và gói đăng ký**. Học sinh chưa đăng ký được làm **1 đề thử miễn phí** (guest trial). Đề đã làm được đánh dấu để tránh trùng lặp.
111
+
112
+ **Nguồn đề:** Bộ GD&ĐT, các Sở GD&ĐT (Hà Nội, TP.HCM, Cà Mau, Bình Dương…), AMC 8/AMC 10 (Mỹ), Gauss (Canada), Junior Math Challenge (Anh), Leaving Certificate (Ireland), CSAT (Hàn Quốc).
113
+
114
+ > Toàn bộ câu hỏi đến từ nguồn thực do con người tạo ra. Zenith không dùng AI để tạo nội dung câu hỏi.
115
+
116
+ ### 3.2 Giao Diện Thi (TestInterface)
117
+
118
+ **Trang:** `/test/:examId`
119
+
120
+ **Tính năng trong phòng thi:**
121
+
122
+ - **Bộ đếm giờ** — đếm ngược, tự nộp bài khi hết giờ
123
+ - **Ngăn chuyển tab** — phát hiện và ghi lại số lần học sinh chuyển ra khỏi trang thi
124
+ - **Bảng công thức** — kéo ra ngay trong khi thi, không cần mở tab khác
125
+ - **Theo dõi thời gian từng câu** — ghi lại thời gian học sinh dừng ở mỗi câu
126
+ - **Phím tắt bàn phím** — điều hướng nhanh không cần chuột
127
+ - **Toàn màn hình** — chế độ không phân tâm
128
+ - **Gợi ý AI (⚡1 Tia)** — học sinh có thể xin gợi ý cho câu đang làm (AI không đưa đáp án, chỉ hỏi ngược để dẫn dắt)
129
+ - **Nền sao chuyển động** — hiệu ứng thị giác giúp tạo cảm giác tập trung, tương tự "vùng dòng chảy" (flow state)
130
+
131
+ ---
132
+
133
+ ## 4. Bước 3 — Phân Tích Kết Quả và Đề Xuất Trường
134
+
135
+ **Trang:** `/results/:id`
136
+
137
+ Ngay sau khi nộp bài, học sinh nhận được:
138
+
139
+ ### 4.1 Bảng Điểm và Phân Tích AI
140
+
141
+ - **Điểm số** hiển thị với hoạt ảnh đếm lên (tạo cảm xúc tích cực)
142
+ - **Biểu đồ radar** thể hiện hiệu suất theo 6–8 chủ đề Toán
143
+ - **Phân tích AI streaming** — AI đọc toàn bộ bài làm và trả lời theo dòng (như gõ thư), nêu:
144
+ - Điểm mạnh cụ thể ("Em làm tốt phần Hàm số")
145
+ - Điểm yếu cần tập trung
146
+ - Các lỗi sai phổ biến trong bài vừa làm
147
+ - Đề xuất học tập cụ thể cho tuần tới
148
+
149
+ ### 4.2 Đề Xuất Trường Theo Tỉnh
150
+
151
+ Dựa trên điểm số và tỉnh thành học sinh đã khai báo, AI đề xuất **3–5 trường phù hợp** với mức điểm chuẩn địa phương. Dữ liệu ngưỡng điểm được cập nhật theo thực tế tuyển sinh của từng tỉnh (4 cấp độ cạnh tranh: D1–D4, từ ít cạnh tranh đến Hà Nội/TP.HCM).
152
+
153
+ ### 4.3 Dự Đoán Điểm Thi Thực Tế
154
+
155
+ Hệ thống sử dụng **bộ lọc Kalman** — một thuật toán đã được NASA dùng để theo dõi quỹ đạo tàu vũ trụ, được ứng dụng vào giáo dục để ước lượng điểm thi — tính ra khoảng điểm dự kiến với độ tin cậy thay đổi theo số lần học sinh đã thi. Học sinh thi càng nhiều, khoảng dự báo càng chính xác.
156
+
157
+ ### 4.4 Chia Sẻ và Thách Đấu
158
+
159
+ - **Chia sẻ kết quả** — tạo hình ảnh đẹp để đăng mạng xã hội
160
+ - **Thách đấu bạn bè** — gửi link, bạn bè vào làm cùng đề và so sánh điểm
161
+
162
+ ---
163
+
164
+ ## 5. Bước 4 — Kế Hoạch Học Tập Cá Nhân Hóa
165
+
166
+ ### 5.1 Kế Hoạch Phục Hồi (StudyPlan)
167
+
168
+ **Trang:** `/study-plan/:resultId`
169
+
170
+ Sau khi xem kết quả, học sinh bấm **"Tạo Kế Hoạch"** (5 Tia). AI đọc toàn bộ bài làm và tạo **kế hoạch phục hồi** chi tiết:
171
+
172
+ - **3–4 vùng trọng tâm** cần cải thiện (sắp xếp theo mức độ ưu tiên)
173
+ - **Mục tiêu checkpoint** cho từng vùng (ví dụ: "Trả lời đúng 3 câu liên tiếp về Hàm số")
174
+ - **Liên kết trực tiếp** đến bộ câu hỏi luyện tập cho từng vùng
175
+ - **Tiến độ checkbox** lưu trên máy, học sinh đánh dấu hoàn thành từng bước
176
+
177
+ ### 5.2 Kế Hoạch Thích Ứng (AdaptiveStudyPlan)
178
+
179
+ **Trang:** `/adaptive-plan`
180
+
181
+ Kế hoạch nâng cao, cập nhật liên tục theo tiến độ thực tế:
182
+
183
+ - **Điểm dự kiến** ngày thi (hiển thị rõ "Đúng hướng ↗" hay "Cần tăng tốc ⚠")
184
+ - **Số khái niệm đã vững** / tổng số khái niệm cần nắm
185
+ - **Số ngày còn lại** đến ngày thi (học sinh khai báo ngày thi khi đăng ký)
186
+ - **Danh sách khái niệm theo giai đoạn** — từ "Chưa học" (xám) đến "Thành thạo" (xanh lá)
187
+
188
+ **Các giai đoạn thành thạo khái niệm:**
189
+
190
+ | Màu | Giai đoạn | Ý nghĩa |
191
+ |-----|-----------|---------|
192
+ | Xám | Chưa học | Chưa gặp khái niệm này |
193
+ | Xanh dương | Mới tiếp cận | Đã thấy nhưng chưa làm được |
194
+ | Vàng cam | Đang học | Làm được một phần |
195
+ | Vàng | Luyện tập | Làm được nhưng chưa ổn định |
196
+ | Xanh lá nhạt | Vững | Làm được đáng tin cậy |
197
+ | Xanh lá đậm | Thành thạo | Làm đúng nhất quán, kể cả câu khó |
198
+
199
+ ---
200
+
201
+ ## 6. Bước 5 — Luyện Tập Thích Ứng Hàng Ngày
202
+
203
+ ### 6.1 Luyện Tập Thích Ứng (AdaptivePractice)
204
+
205
+ **Trang:** `/practice/adaptive`
206
+
207
+ Đây là tính năng học tập cốt lõi của Zenith. Mỗi buổi luyện tập gồm **15 câu hỏi**, được chọn thông minh từ kho 2.075+ câu:
208
+
209
+ **Cách hệ thống chọn câu hỏi:**
210
+
211
+ 1. Đọc toàn bộ lịch sử làm bài của học sinh (hoặc kết quả Diagnostic nếu chưa có lịch sử)
212
+ 2. Tính **"trọng số điểm yếu"** cho từng chủ đề — chủ đề nào sai nhiều hơn được ưu tiên hơn
213
+ 3. Chọn câu hỏi từ các chủ đề yếu nhiều hơn, xen kẽ chủ đề mạnh
214
+ 4. **Xáo trộn** thứ tự chủ đề để tránh học thuộc theo nhóm (kỹ thuật "interleaving" có căn cứ nghiên cứu)
215
+
216
+ **Tại sao hiệu quả hơn học bình thường?**
217
+ Nghiên cứu meta-phân tích (US Department of Education, 2010) cho thấy học thích ứng cho kết quả **tốt hơn đáng kể** so với học một-kích-thước-cho-tất-cả, đặc biệt khi học sinh có nền tảng không đồng đều [2].
218
+
219
+ Thêm vào đó, kỹ thuật xen kẽ chủ đề (**interleaving**) — thay vì học hết Đại số rồi mới sang Hình học — được nghiên cứu tại các trường đại học Mỹ xác nhận cải thiện khả năng vận dụng kiến thức trong bài thi đến **43%** [3].
220
+
221
+ ### 6.2 Thử Thách Hàng Ngày (DailyChallenge)
222
+
223
+ **Trang:** `/daily`
224
+
225
+ Mỗi ngày, Zenith đưa ra **1 câu hỏi thử thách** được chọn từ kho đề. Học sinh trả lời và nhận phản hồi ngay lập tức.
226
+
227
+ **Tính năng streak (chuỗi ngày):** Hệ thống theo dõi số ngày liên tiếp học sinh hoàn thành thử thách. Nếu bỏ lỡ một ngày, có thể dùng **Streak Freeze** (đóng băng chuỗi) để bảo toàn kỷ lục.
228
+
229
+ **Tại sao quan trọng?**
230
+ Kết quả nghiên cứu về gamification trong giáo dục (Hamari, Koivisto & Sarsa, 2014) cho thấy các cơ chế như streak và thách thức hàng ngày tăng tỷ lệ học sinh quay lại ứng dụng và duy trì thói quen học đều đặn [4].
231
+
232
+ ### 6.3 Tạo Đề Thi AI (GenerateExam)
233
+
234
+ **Trang:** `/generate-exam`
235
+
236
+ Học sinh (hoặc giáo viên) có thể yêu cầu AI tạo **đề thi mới hoàn toàn** theo yêu cầu:
237
+
238
+ - Chọn chủ đề (1 hoặc nhiều trong số 15 chủ đề Toán)
239
+ - Chọn số câu (10–50 câu)
240
+ - Chọn mức độ khó (dễ / trung bình / khó / hỗn hợp)
241
+
242
+ AI tạo câu hỏi trực tiếp theo luồng (streaming) — từng câu xuất hiện trên màn hình ngay khi AI tạo xong, không cần chờ toàn bộ.
243
+
244
+ > **Lưu ý:** Đây là tính năng thực hành bổ sung. Câu hỏi do AI tạo được đánh dấu riêng và không được tính vào kho đề thi chính thức, vốn chỉ chứa câu hỏi từ nguồn thực.
245
+
246
+ ---
247
+
248
+ ## 7. Bước 6 — Ôn Tập Thông Minh (Spaced Repetition)
249
+
250
+ **Trang:** `/review`
251
+
252
+ ### 7.1 Nguyên Lý Hoạt Động
253
+
254
+ Đây là một trong những tính năng khoa học nhất của Zenith. Hệ thống dựa trên **"đường cong quên lãng" của Ebbinghaus** (1885): sau khi học xong, kiến thức bắt đầu phai dần theo thời gian, nhưng mỗi lần ôn tập lại đúng thời điểm sẽ kéo dài thêm độ bền của ký ức.
255
+
256
+ **Cách Zenith hoạt động:**
257
+ Mỗi câu hỏi học sinh đã làm được theo dõi riêng. Sau khi học sinh trả lời, hệ thống tính toán **ngày ôn tập tối ưu tiếp theo** dựa trên:
258
+
259
+ - Học sinh nhớ bài này tốt đến đâu (đánh giá qua 3 mức: Đoán / Khá / Chắc)
260
+ - Độ khó của câu hỏi đó
261
+ - Khoảng thời gian đã trôi qua kể từ lần ôn gần nhất
262
+
263
+ ### 7.2 Thuật Toán FSRS
264
+
265
+ Zenith sử dụng thuật toán **FSRS v5** — một trong những thuật toán lặp lại ngắt quãng hiện đại nhất, được xây dựng và kiểm chứng trên dữ liệu của hàng triệu người dùng ứng dụng học thẻ Anki trên toàn thế giới.
266
+
267
+ **Bằng chứng đã kiểm chứng (3/3 phiếu bầu trong nghiên cứu phản biện độc lập):**
268
+
269
+ > FSRS khi được **cá nhân hóa theo từng học sinh** dự đoán thời điểm quên chính xác hơn **39%** so với khi dùng thông số mặc định.
270
+
271
+ *Nguồn: Kho dữ liệu open-source srs-benchmark — hơn 350 triệu lượt ôn tập từ 9.999 người dùng thực* [5]
272
+
273
+ **Hàm ý:** Mỗi học sinh có tốc độ quên khác nhau. Một học sinh giỏi Hình học có thể ôn lại sau 14 ngày mà vẫn nhớ, trong khi học sinh khác cần ôn lại sau 3 ngày. FSRS tự điều chỉnh cho từng người.
274
+
275
+ ### 7.3 Giao Diện Ôn Tập
276
+
277
+ - Hiển thị **câu hỏi đến hạn ôn** (chỉ những câu sắp quên, không ôn lại cái đã nhớ vững)
278
+ - Học sinh chọn mức tự đánh giá: **Đoán** (sẽ ôn lại sớm) / **Khá** (ôn sau vài ngày) / **Chắc** (ôn sau vài tuần)
279
+ - Thanh tiến độ cho thấy còn bao nhiêu câu trong ngày
280
+
281
+ ---
282
+
283
+ ## 8. Bước 7 — Oracle: Gia Sư Toán AI
284
+
285
+ **Trang:** `/oracle`
286
+
287
+ ### 8.1 Oracle là gì?
288
+
289
+ Oracle là trợ lý Toán AI tích hợp sâu nhất trong Zenith — không chỉ trả lời câu hỏi mà còn nhớ bối cảnh cuộc trò chuyện và điều chỉnh cách giải thích theo từng học sinh.
290
+
291
+ ### 8.2 Tính Năng
292
+
293
+ **Đặt câu hỏi Toán tự do**
294
+ Học sinh gõ câu hỏi bằng tiếng Việt hoặc tiếng Anh, kết hợp công thức toán học. AI trả lời theo dòng (streaming) với:
295
+ - Giải thích từng bước
296
+ - Công thức được render đẹp bằng LaTeX
297
+ - Các trường hợp đặc biệt cần lưu ý
298
+
299
+ **Chụp ảnh bài toán (OCR)**
300
+ Học sinh chụp ảnh trang sách hoặc đề thi, tải lên — Oracle đọc công thức từ ảnh và giải thích.
301
+
302
+ **Nhập liệu bằng giọng nói**
303
+ Học sinh nói câu hỏi thay vì gõ — tiện lợi khi tay đang cầm bút.
304
+
305
+ **Bảng ký hiệu Toán**
306
+ Palette ký hiệu toán học (∑, √, π, ∫, α, β…) và chữ Hy Lạp — click để chèn vào câu hỏi.
307
+
308
+ **Chuẩn hóa công thức tự động**
309
+ Khi dán công thức từ nguồn bên ngoài (PDF, Word), Oracle tự động chuyển đổi về định dạng chuẩn LaTeX.
310
+
311
+ **Lịch sử trò chuyện**
312
+ Oracle nhớ những gì đã được giải thích trong buổi chat, tránh giải thích lại điều đã biết.
313
+
314
+ **Lọc theo chủ đề**
315
+ Học sinh có thể giới hạn Oracle chỉ trả lời về một chủ đề cụ thể (ví dụ: chỉ về Tích phân).
316
+
317
+ ### 8.3 Phương Pháp Sư Phạm
318
+
319
+ Oracle được thiết kế theo **phương pháp Socrates**: thay vì đưa đáp án ngay, AI đặt câu hỏi dẫn dắt để học sinh tự tìm ra cách giải. Nghiên cứu giáo dục ghi nhận phương pháp này giúp học sinh hiểu sâu hơn và nhớ lâu hơn so với học vẹt.
320
+
321
+ Khi học sinh bấm nút **Gợi ý (⚡1 Tia)** trong lúc làm bài, Oracle cũng sử dụng phong cách này — không cho đáp án, chỉ gợi mở hướng suy nghĩ.
322
+
323
+ ---
324
+
325
+ ## 9. Bước 8 — Theo Dõi Tiến Độ và Bản Đồ Khái Niệm
326
+
327
+ ### 9.1 Trang Tiến Độ (Progress)
328
+
329
+ **Trang:** `/progress`
330
+
331
+ Học sinh thấy **bức tranh toàn cảnh** về năng lực Toán của mình:
332
+
333
+ **Điểm chuẩn theo tỉnh:**
334
+ So sánh điểm trung bình của học sinh với **ngưỡng điển hình và ngưỡng top** của tỉnh đó. Ví dụ: Hà Nội cần ~8.0 điểm để vào trường tốt, trong khi Hà Giang chỉ cần ~5.0. Zenith hiển thị học sinh đang ở đâu so với mặt bằng tỉnh nhà (dữ liệu 63 tỉnh thành).
335
+
336
+ **Biểu đồ theo chủ đề:**
337
+ Điểm theo từng trong số 15 chủ đề Toán — Đại số, Hình học, Giải tích, Xác suất, Lượng giác, Dãy số, Số phức, Logarit…
338
+
339
+ **Điểm thành thạo khái niệm (BKT):**
340
+ Dựa trên thuật toán **Bayesian Knowledge Tracing** — một phương pháp khoa học từ năm 1994 được dùng trong các hệ thống gia sư thông minh hàng đầu thế giới (như Carnegie Learning). Zenith tính xác suất "học sinh thực sự biết khái niệm này" thay vì chỉ đếm số câu đúng/sai.
341
+
342
+ ### 9.2 Bản Đồ Khái Niệm (ConceptMap)
343
+
344
+ **Trang:** `/concept-map`
345
+
346
+ Đây là tính năng trực quan hóa độc đáo nhất của Zenith: **đồ thị tương tác gồm 58 nút khái niệm Toán**, được sắp xếp theo quan hệ điều kiện tiên quyết (khái niệm nào cần học trước khái niệm nào).
347
+
348
+ **Cách đọc bản đồ:**
349
+
350
+ | Màu nút | Ý nghĩa |
351
+ |---------|---------|
352
+ | Xám đậm | Chưa từng thử |
353
+ | Đỏ | Đã thử nhưng còn yếu (< 40%) |
354
+ | Vàng/cam | Đang tiến bộ (40–70%) |
355
+ | Xanh lá | Đã vững (> 70%) |
356
+
357
+ **Cách sử dụng:**
358
+ Click vào bất kỳ nút nào để xem chi tiết khái niệm và bắt đầu luyện tập tập trung vào khái niệm đó. Các mũi tên cho thấy mối quan hệ học trước/sau (ví dụ: phải hiểu Phương trình bậc nhất trước khi học Hệ phương trình).
359
+
360
+ **Cơ sở khoa học:**
361
+ Nghiên cứu về bản đồ khái niệm trong giáo dục (Novak & Cañas, 2006) cho thấy học sinh sử dụng biểu diễn trực quan kiến thức hiểu bài sâu hơn và nhớ lâu hơn so với học theo danh sách tuyến tính [6].
362
+
363
+ ### 9.3 Phân Tích Lỗi Sai (ErrorAnalysis)
364
+
365
+ **Trang:** `/error-analysis`
366
+
367
+ Tổng hợp tất cả lỗi sai của học sinh qua tất cả bài thi, phân loại thành **5 nhóm lỗi**:
368
+
369
+ | Loại lỗi | Ý nghĩa thực tế |
370
+ |----------|----------------|
371
+ | Sai dấu | Nhầm dấu cộng/trừ khi biến đổi |
372
+ | Nhầm công thức | Áp dụng sai công thức |
373
+ | Sai quy trình | Đúng hướng nhưng sai bước giữa |
374
+ | Lỗ hổng khái niệm | Chưa hiểu bản chất |
375
+ | Tính toán sai | Sai số học đơn thuần |
376
+
377
+ Lỗi gần đây được tính trọng số cao hơn lỗi cũ (thuật toán giảm dần theo thời gian — "temporal decay"). Điều này giúp phân biệt lỗi học sinh đã khắc phục và lỗi vẫn còn tồn tại.
378
+
379
+ **Biểu đồ radar:** Hiển thị phân phối lỗi theo chủ đề — một cái nhìn nhanh về "cần sửa gì, ở đâu".
380
+
381
+ ### 9.4 Sổ Lỗi Sai (Mistakes)
382
+
383
+ **Trang:** `/mistakes`
384
+
385
+ Danh sách toàn bộ câu hỏi học sinh đã làm sai, nhóm theo chủ đề. Với mỗi câu sai:
386
+
387
+ - Xem lại câu hỏi và đáp án đúng
388
+ - **Giải thích chi tiết (1 Tia)** — AI giải thích tại sao đáp án đó đúng, phân tích lỗi học sinh mắc phải
389
+ - **Gắn nhãn lỗi** — học sinh tự phân loại lỗi (giúp tự nhận thức metacognitive)
390
+
391
+ ---
392
+
393
+ ## 10. Tính Năng Cộng Đồng và Gamification
394
+
395
+ ### 10.1 Lớp Học (ClassDashboard)
396
+
397
+ **Trang:** `/class` *(đã xây dựng, chưa mở cho người dùng)*
398
+
399
+ Giáo viên hoặc học sinh có thể tạo **lớp học ảo**:
400
+
401
+ - **Giáo viên tạo lớp** → nhận mã lớp (ví dụ: `ABC123`)
402
+ - **Học sinh tham gia** bằng cách nhập mã
403
+ - **Giáo viên xem** kết quả bài thi của toàn lớp trong một bảng
404
+
405
+ Tính năng này hỗ trợ giáo viên ra bài tập về nhà dưới dạng đề thi trên Zenith và theo dõi học sinh hoàn thành.
406
+
407
+ ### 10.2 Thách Đấu Bạn Bè (ChallengeLanding)
408
+
409
+ **Trang:** `/challenge`
410
+
411
+ Sau khi làm bài, học sinh bấm **"Thách đấu bạn"** → nhận link → gửi cho bạn. Bạn bè vào làm cùng đề đó và kết quả được so sánh trực tiếp.
412
+
413
+ Tính năng này tạo **động lực xã hội** (social motivation) — một trong những yếu tố được nghiên cứu giáo dục xác nhận có tác động tích cực đến sự gắn kết học tập.
414
+
415
+ ### 10.3 Huy Hiệu Thành Tích (Badges)
416
+
417
+ Zenith cấp huy hiệu khi học sinh đạt cột mốc:
418
+
419
+ | Huy hiệu | Điều kiện |
420
+ |----------|-----------|
421
+ | Điểm hoàn hảo | Đạt 10 điểm trong 1 bài thi |
422
+ | Chinh phục 10 đề | Hoàn thành 10 bài thi |
423
+ | Tốc độ ánh sáng | Nộp bài trước khi hết 70% thời gian |
424
+ | Tiến bộ vượt bậc | Cải thiện ≥ 2 điểm so với lần trước cùng đề |
425
+
426
+ ---
427
+
428
+ ## 11. Tài Khoản và Thống Kê Cá Nhân
429
+
430
+ **Trang:** `/account`
431
+
432
+ Trang tài khoản là **trung tâm điều khiển cá nhân** của học sinh, chia thành 4 tab:
433
+
434
+ ### Tab Tiến Độ
435
+
436
+ - **Biểu đồ radar** thể hiện năng lực 8 chủ đề Toán
437
+ - **Đường xu hướng điểm** qua các lần thi
438
+ - **Số ngày streak** (chuỗi học liên tục)
439
+ - **Chuỗi ngày kỷ lục** (personal best)
440
+ - **Đếm ngược đến ngày thi**
441
+ - **Cấp độ thành thạo** (từ Học Sinh mới đến Thần Đồng Toán học) với thanh tiến độ
442
+ - **Loại học sinh** (learner archetype) — AI phân loại học sinh theo phong cách học (ví dụ: "Người chăm chỉ", "Người học theo hiểu biết", "Người học theo tốc độ")
443
+
444
+ ### Tab Phân Tích
445
+
446
+ - **Điểm số theo trường mục tiêu** — so sánh với ngưỡng của trường học sinh muốn vào
447
+ - **Thống kê đồng lứa** (peer stats) — AI so sánh ẩn danh với các học sinh cùng tỉnh, cùng lớp
448
+ - **Nhận xét hàng tuần** — AI tổng kết tuần vừa qua và đưa ra lời khuyên
449
+ - **Kế hoạch mô phỏng thi** — AI tạo lịch thi thử theo ngày thi thực tế
450
+ - **Bối cảnh địa phương** — phân tích cạnh tranh tuyển sinh theo tỉnh
451
+
452
+ ### Tab AI & Tia
453
+
454
+ - **Chiến lược thi** — AI đưa ra chiến lược làm bài dựa trên điểm yếu cụ thể
455
+ - **Chương trình học tuần** — lịch học cụ thể từng ngày trong tuần
456
+ - **Nhận xét điểm thi** — so sánh điểm thực tế với dự đoán, giải thích chênh lệch
457
+
458
+ ### Tab Cài Đặt
459
+
460
+ - Đổi tên hiển thị
461
+ - Cài đặt nhắc nhở học tập (thông báo trình duyệt)
462
+ - Quản lý gói đăng ký
463
+ - Lịch sử giao dịch Tia (AI credits)
464
+
465
+ ---
466
+
467
+ ## 12. Kho Đề Thi và Dữ Liệu
468
+
469
+ ### 12.1 Tổng Quan
470
+
471
+ | Chỉ số | Giá trị |
472
+ |--------|---------|
473
+ | Tổng số đề | 86 đề |
474
+ | Đề thi vào lớp 10 | 47 đề |
475
+ | Đề THPT Quốc gia | 29 đề |
476
+ | Đề ứng dụng / quốc tế | 10 đề |
477
+ | Tổng số câu hỏi | 2.075+ câu |
478
+ | Số chủ đề Toán được phân loại | 15 chủ đề |
479
+ | Số khái niệm trong bản đồ | 58 khái niệm |
480
+ | Phân loại câu hỏi | Dễ / Trung bình / Khó |
481
+
482
+ ### 12.2 Nguồn Đề Thi Trong Nước
483
+
484
+ - Bộ Giáo dục và Đào tạo (đề THPT Quốc gia 2018–2023)
485
+ - Sở GD&ĐT Hà Nội, TP.HCM, Bình Dương, Cà Mau và các tỉnh khác
486
+ - Nguồn luyện thi uy tín: loigiaihay.com
487
+
488
+ ### 12.3 Nguồn Đề Thi Quốc Tế
489
+
490
+ | Tổ chức | Quốc gia | Loại đề |
491
+ |---------|---------|---------|
492
+ | Mathematical Association of America (MAA) | Mỹ | AMC 8, AMC 10 |
493
+ | Centre for Education in Mathematics and Computing (CEMC) | Canada | Gauss Grade 7/8 |
494
+ | UK Mathematics Trust | Anh | Junior Math Challenge |
495
+ | State Examinations Commission (SEC) | Ireland | Leaving Certificate |
496
+ | Korea Institute for Curriculum and Evaluation (KICE) | Hàn Quốc | CSAT |
497
+
498
+ ### 12.4 Chính Sách Nội Dung
499
+
500
+ Toàn bộ câu hỏi (nội dung + đáp án + các lựa chọn sai) đến từ nguồn thực do con người tạo ra. **AI không được phép tạo nội dung câu hỏi** — chính sách này được ghi cứng vào hệ thống để đảm bảo chất lượng và tính chính xác. Đây là điểm khác biệt quan trọng so với một số nền tảng luyện thi khác trên thị trường.
501
+
502
+ ---
503
+
504
+ ## 13. Gói Dịch Vụ và Hệ Thống Tín Dụng AI
505
+
506
+ ### 13.1 Gói Đăng Ký
507
+
508
+ | Gói | Tên | Đề thi được dùng | Tính năng đặc biệt |
509
+ |-----|-----|-----------------|-------------------|
510
+ | Miễn phí | Cơ bản | 1 đề thử | Xem kết quả cơ bản |
511
+ | Trả phí | Học sinh | Tất cả đề lớp 10 + THPT | AI phân tích, kế hoạch |
512
+ | Trả phí | Toàn diện | Tất cả + đề quốc tế | Kế hoạch học 5 Tia, tất cả tính năng |
513
+
514
+ Gói Học sinh và Toàn diện có lựa chọn **thanh toán tháng hoặc năm** (thanh toán năm rẻ hơn, hiển thị badge "Năm" trong Navbar).
515
+
516
+ ### 13.2 Hệ Thống Tia (AI Credits)
517
+
518
+ Các tính năng AI tiêu thụ "Tia" (⚡) — đơn vị tín dụng AI. Điều này cho phép học sinh dùng miễn phí các tính năng cơ bản và chỉ trả tiền cho AI khi thực sự cần.
519
+
520
+ | Tính năng AI | Chi phí |
521
+ |-------------|---------|
522
+ | Gợi ý câu hỏi (Socratic hint) | 1 Tia |
523
+ | Giải thích đáp án | 1 Tia |
524
+ | Phân tích kết quả bài thi | 3 Tia |
525
+ | Kế hoạch học tập 4 tuần | 5 Tia |
526
+
527
+ Tia có thể nhận miễn phí khi nâng cấp gói, hoặc mua thêm từ trang tài khoản.
528
+
529
+ ---
530
+
531
+ ## 14. Cơ Sở Khoa Học
532
+
533
+ Zenith được xây dựng dựa trên 6 lĩnh vực nghiên cứu giáo dục có bằng chứng vững chắc. Tất cả các số liệu dưới đây đã được xác minh qua hệ thống kiểm tra phản biện độc lập (214 tác nhân AI, 50 nguồn học thuật, chỉ giữ lại tuyên bố được ≥2/3 tác nhân xác nhận).
534
+
535
+ ---
536
+
537
+ ### 14.1 Lặp Lại Ngắt Quãng — Chiến Lược Học Tập Hiệu Quả Nhất
538
+
539
+ **Tính năng áp dụng:** Ôn Tập (ReviewSession), thuật toán FSRS
540
+
541
+ **Bằng chứng đã kiểm chứng (3/3 phiếu bầu):**
542
+
543
+ > Lặp lại ngắt quãng và luyện tập truy xuất (tự kiểm tra) là hai kỹ thuật học tập hiệu quả nhất, mỗi loại đạt **hiệu ứng d=0.85** — gấp hơn **2 lần hiệu quả trung bình** của các can thiệp giáo dục thông thường (d≈0.40) — trong phân tích tổng hợp năm 2021 của **242 nghiên cứu với 169.179 học sinh**.
544
+
545
+ *Nguồn: Donoghue & Hattie (2021), Frontiers in Education, DOI: 10.3389/feduc.2021.581216* [A]
546
+
547
+ *Xác nhận độc lập: Cepeda et al. (2006), Psychological Bulletin — tổng hợp 839 bài đánh giá từ 317 thí nghiệm trên 184 bài báo* [B]
548
+
549
+ **Ý nghĩa thực tế:** Học nhồi nhét một lần ("học tủ") trước kỳ thi tạo cảm giác nhớ nhưng kiến thức phai rất nhanh. Ôn tập đúng thời điểm — ngay trước khi bộ nhớ phai — giúp kiến thức gắn chắc hơn mỗi lần ôn. Đây là lý do tính năng Ôn Tập của Zenith không cho học sinh ôn bừa bãi mà chỉ đưa ra câu hỏi **đúng khi sắp quên**.
550
+
551
+ Một phát hiện quan trọng từ nghiên cứu (3/3 phiếu bầu): khoảng cách ôn tập tối ưu **phải điều chỉnh theo ngày thi** — kỳ thi còn xa thì khoảng cách ôn dài hơn, kỳ thi gần thì ôn dày hơn. Zenith sử dụng thông tin ngày thi (học sinh khai báo khi đăng ký) để điều chỉnh lịch ôn theo nguyên tắc này.
552
+
553
+ ---
554
+
555
+ ### 14.2 Tự Kiểm Tra — Nhớ Lâu Hơn Đọc Lại
556
+
557
+ **Tính năng áp dụng:** Luyện Tập Thích Ứng, Thử Thách Hàng Ngày, Luyện Tập trong Kế Hoạch Học Tập
558
+
559
+ **Bằng chứng đã kiểm chứng (2/3 phiếu bầu):**
560
+
561
+ > Học sinh **tự kiểm tra** nhớ nhiều hơn học sinh **đọc lại bài**: sau 2 ngày, nhóm tự kiểm tra nhớ **68%** so với **54%** của nhóm đọc lại (hơn **26%**); sau 1 tuần, khoảng cách tăng lên: **56% so với 42%** (hơn **33%**).
562
+
563
+ *Nguồn: Roediger & Karpicke (2006), Psychological Science, 17(3), 249–255 — thí nghiệm với 120 người tham gia* [C]
564
+
565
+ **Ý nghĩa thực tế:** Học sinh thường có thói quen đọc lại bài hoặc xem lại lý thuyết. Nghiên cứu cho thấy việc **tự làm bài trắc nghiệm** (dù sai) hiệu quả hơn đọc lại gấp đôi về khả năng nhớ sau 1 tuần. Đây là lý do Zenith luôn đặt học sinh vào tình huống phải chọn đáp án — không phải chỉ đọc giải thích.
566
+
567
+ ---
568
+
569
+ ### 14.3 Gia Sư Thông Minh — Hiệu Quả Ngang Gia Sư Người Thật
570
+
571
+ **Tính năng áp dụng:** Oracle, Gợi Ý Socrates, Giải Thích Đáp Án
572
+
573
+ **Bằng chứng đã kiểm chứng (3/3 phiếu bầu — đồng thuận tuyệt đối về thư mục; 2/3 về chỉ số cụ thể):**
574
+
575
+ > Hệ thống gia sư AI phản hồi từng bước giải đạt hiệu ứng **d=0.76** so với không có gia sư — gần bằng gia sư con người một-một (**d=0.79**). Hệ thống chỉ chấm đúng/sai mà không giải thích đạt **d=0.31** — kém hơn **2,5 lần**.
576
+
577
+ *Nguồn: VanLehn (2011), Educational Psychologist, 46(4), 197–221 — phân tích 1.400+ trích dẫn, tiêu chuẩn ngành cho hiệu quả gia sư thông minh* [D]
578
+
579
+ **Ý nghĩa thiết kế quan trọng:** Zenith thiết kế Oracle và tính năng Gợi Ý theo nguyên tắc "từng bước" — không chỉ đánh dấu sai/đúng mà giải thích tại sao, đặt câu hỏi dẫn dắt. Đây chính là điều kiện để đạt hiệu quả d=0.76 thay vì chỉ d=0.31.
580
+
581
+ *Lưu ý trung thực: Tuyên bố "gia sư AI hiệu quả ngang hoàn toàn với gia sư người thật" và "hiệu ứng 2-sigma của Bloom" đều bị bác bỏ 0/3 phiếu — không nên trích dẫn các tuyên bố này.*
582
+
583
+ ---
584
+
585
+ ### 14.4 Gamification — Tăng Gắn Kết, Duy Trì Thói Quen Học
586
+
587
+ **Tính năng áp dụng:** Streak hàng ngày, Thử Thách, Huy Hiệu, Điểm Thành Thạo
588
+
589
+ **Bằng chứng đã kiểm chứng (2/3 phiếu bầu):**
590
+
591
+ > Gamification (điểm streak, huy hiệu, thử thách) tạo hiệu ứng tổng thể **d=0.48** đối với kết quả học tập và hành vi. Tác động lên **tham gia học tập là d=0.60** (mạnh hơn), trong khi tác động trực tiếp lên điểm thi là **d=0.30** (nhỏ hơn).
592
+
593
+ *Nguồn: Sailer & Homner (2020/2021), Frontiers in Psychology, PMC8037535 — phân tích 18 nghiên cứu thực nghiệm* [E]
594
+
595
+ **Ý nghĩa thực tế:** Gamification không phải "phép màu" tăng điểm, nhưng nó **duy trì thói quen học đều đặn** — điều quan trọng hơn nhiều trong việc ôn thi dài hạn. Một học sinh học 30 phút mỗi ngày đều đặn trong 3 tháng sẽ vượt trội hơn học sinh học nhồi 10 tiếng trong 1 tuần trước thi.
596
+
597
+ ---
598
+
599
+ ### 14.5 Học Tập Thích Ứng — Đúng Người, Đúng Nội Dung
600
+
601
+ **Tính năng áp dụng:** Luyện Tập Thích Ứng, Xáo Trộn Chủ Đề (Interleaving), Kế Hoạch Học Tập
602
+
603
+ Thay vì dạy tất cả học sinh như nhau, hệ thống điều chỉnh nội dung theo năng lực từng người. Nghiên cứu tổng hợp của Bộ Giáo dục Mỹ (2010, phân tích 46 nghiên cứu độc lập) cho thấy học trực tuyến thích ứng tốt hơn đáng kể so với học lớp truyền thống [F].
604
+
605
+ Kỹ thuật **xen kẽ chủ đề** (interleaving) — thay vì học hết Đại số rồi mới sang Hình học, Zenith xáo trộn câu hỏi từ nhiều chủ đề — được nghiên cứu tại Đại học California và Florida xác nhận cải thiện khả năng vận dụng trong kỳ thi [G].
606
+
607
+ ---
608
+
609
+ ### 14.6 Kiểm Tra Thích Ứng — Câu Hỏi Vừa Khớp Năng Lực
610
+
611
+ **Tính năng áp dụng:** Luyện Tập Thích Ứng, BanditCAT + IRT
612
+
613
+ Hệ thống chọn câu hỏi **vừa khớp với năng lực học sinh** — không quá dễ (nhàm chán) và không quá khó (nản lòng). Bài báo BanditCAT (PMLR 2024–2025) được xác minh bởi **3/3 tác nhân kiểm tra phản biện độc lập** trong nghiên cứu của chúng tôi [H].
614
+
615
+ ---
616
+
617
+ ### 14.7 Theo Dõi Kiến Thức — Biết Học Sinh Thực Sự Hiểu Gì
618
+
619
+ **Tính năng áp dụng:** Bản Đồ Khái Niệm, Trang Tiến Độ, Kế Hoạch Thích Ứng
620
+
621
+ Thuật toán BKT (Bayesian Knowledge Tracing, Corbett & Anderson 1994) ước lượng **xác suất thực sự học sinh đã hiểu khái niệm** — chính xác hơn và ít bị ảnh hưởng bởi đoán may so với chỉ đếm tỉ lệ đúng/sai đơn giản [I].
622
+
623
+ ---
624
+
625
+ ### Bảng Tổng Hợp Bằng Chứng
626
+
627
+ | Cơ chế | Số liệu đã kiểm chứng | Tính năng Zenith |
628
+ |--------|----------------------|-----------------|
629
+ | Lặp lại ngắt quãng | d=0.85, 242 nghiên cứu, 169.179 học sinh | Ôn Tập (FSRS) |
630
+ | Tự kiểm tra | +26% nhớ sau 2 ngày, +33% sau 1 tuần | Luyện Tập, Thử Thách Hàng Ngày |
631
+ | Gia sư từng bước | d=0.76 (gần bằng gia sư người: d=0.79) | Oracle, Gợi Ý Socrates |
632
+ | Gamification | d=0.48 tổng thể; d=0.60 gắn kết | Streak, Huy Hiệu, Thách Đấu |
633
+ | Cá nhân hóa FSRS | −39% sai số khi cá nhân hóa tham số | Ôn Tập cá nhân hóa |
634
+ | Kiểm tra thích ứng (IRT) | Xác minh 3/3 phiếu bầu | Luyện Tập Thích Ứng |
635
+
636
+ ---
637
+
638
+ ## 15. Tài Liệu Tham Khảo
639
+
640
+ Tất cả nguồn đã qua kiểm tra phản biện độc lập (214 tác nhân AI, 50 nguồn). Số liệu từ các nguồn bị bác bỏ đã được loại ra và không xuất hiện trong báo cáo này.
641
+
642
+ ### Nghiên Cứu Phương Pháp Giáo Dục (Kiểm Chứng Ngoại Vi)
643
+
644
+ | Ký hiệu | Tài liệu | Kết quả kiểm chứng |
645
+ |---------|----------|-------------------|
646
+ | [A] | Donoghue, G. M. & Hattie, J. A. C. (2021). "A Meta-Analysis of Ten Learning Techniques." *Frontiers in Education*, DOI: 10.3389/feduc.2021.581216. 242 nghiên cứu, 169.179 học sinh. | 3/3 phiếu bầu xác nhận |
647
+ | [B] | Cepeda, N. J. et al. (2006). "Distributed practice in verbal recall tasks." *Psychological Bulletin*, PMID 16719566. 839 bài đánh giá, 317 thí nghiệm. | 3/3 phiếu bầu xác nhận |
648
+ | [C] | Roediger, H. L. & Karpicke, J. D. (2006). "Test-Enhanced Learning." *Psychological Science*, 17(3), 249–255. | 2/3 phiếu bầu xác nhận |
649
+ | [D] | VanLehn, K. (2011). "The relative effectiveness of human tutoring, intelligent tutoring systems, and other tutoring systems." *Educational Psychologist*, 46(4), 197–221. ERIC EJ946764. 1.400+ trích dẫn. | 3/3 phiếu bầu (thư mục); 2/3 (chỉ số cụ thể) |
650
+ | [E] | Sailer, M. & Homner, L. (2020). "The Gamification of Learning: A Meta-analysis." *Educational Psychology Review*, PMC8037535. 18 nghiên cứu thực nghiệm. | 2/3 phiếu bầu xác nhận |
651
+ | [F] | U.S. Department of Education (2010). *Evaluation of Evidence-Based Practices in Online Learning.* Phân tích 46 nghiên cứu. | Tài liệu chính thống Bộ GD Mỹ |
652
+ | [G] | Rohrer, D., Dedrick, R. F., & Stershic, S. (2015). "Interleaved practice improves mathematics learning." *Journal of Educational Psychology*, 107(3), 900–908. | Nghiên cứu xen kẽ chủ đề Toán |
653
+
654
+ ### Nghiên Cứu Thuật Toán (Kiểm Chứng Nội Vi)
655
+
656
+ | Ký hiệu | Tài liệu | Kết quả kiểm chứng |
657
+ |---------|----------|-------------------|
658
+ | [H] | Sharpnack, J. et al. (2024–2025). "BanditCAT and AutoIRT." *PMLR Proceedings* (arxiv: 2410.21033). | 3/3 phiếu bầu xác nhận |
659
+ | [I] | Corbett, A. T. & Anderson, J. R. (1994). "Knowledge tracing: Modeling the acquisition of procedural knowledge." *User Modeling and User-Adapted Interaction*, 4(4), 253–278. | Tài liệu gốc BKT — chuẩn ngành |
660
+ | [J] | Open Spaced Repetition. *SRS Benchmark* (kho dữ liệu, cập nhật liên tục). 350+ triệu lượt ôn tập, 9.999 người dùng. FSRS-7 cá nhân hóa: RMSE = 0.0655 vs 0.0910 mặc định (−39%). | 3/3 phiếu bầu xác nhận |
661
+ | [K] | Vermeiren, B. et al. (2025). "Dynamic K-value ELO for educational mastery tracking." *UMUAI*, Springer. PMC12682724. | 3/3 phiếu bầu xác nhận |
662
+
663
+ ### Tuyên Bố Bị Bác Bỏ — Không Nên Trích Dẫn
664
+
665
+ Các số liệu sau được lưu hành rộng rãi trên internet nhưng **không vượt qua kiểm tra phản biện độc lập** (0/3 phiếu). Zenith không sử dụng chúng:
666
+
667
+ - ~~"Gia sư AI hiệu quả bằng hoàn toàn gia sư người thật với d=1.0"~~ — Bác bỏ 0/3
668
+ - ~~"DreamBox Learning cải thiện 2.5 lần so với phương pháp truyền thống"~~ — Bác bỏ 0/3
669
+ - ~~"Carnegie Learning cải thiện điểm 30%"~~ — Bác bỏ 0/3
670
+ - ~~"Lặp lại ngắt quãng cải thiện ghi nhớ 200%"~~ — Bác bỏ 0/3
671
+ - ~~"Bloom's 2-sigma effect là kết quả có thể tái tạo"~~ — Bác bỏ 2/3 (là tạo tác từ ngưỡng 90% thành thạo, không phải baseline thông thường)
672
+
673
+ ---
674
+
675
+ ## Phụ Lục: Danh Sách Đầy Đủ 26 Trang/Tính Năng
676
+
677
+ | Tên trang | Đường dẫn | Mô tả ngắn |
678
+ |-----------|-----------|------------|
679
+ | Trang chủ | `/` | Giới thiệu nền tảng, nút bắt đầu |
680
+ | Chọn đề thi | `/exams` | Duyệt 86 đề, lọc theo lớp/gói |
681
+ | Giao diện thi | `/test/:id` | Làm bài thi có hẹn giờ, gợi ý AI |
682
+ | Kết quả | `/results/:id` | Điểm số, AI phân tích, đề xuất trường |
683
+ | Kế hoạch học | `/study-plan/:id` | Kế hoạch phục hồi sau bài thi |
684
+ | Kế hoạch thích ứng | `/adaptive-plan` | Kế hoạch cập nhật theo tiến độ |
685
+ | Luyện tập thích ứng | `/practice/adaptive` | 15 câu/buổi, tập trung điểm yếu |
686
+ | Ôn tập | `/review` | Lặp lại ngắt quãng FSRS |
687
+ | Oracle | `/oracle` | Gia sư Toán AI, OCR, giọng nói |
688
+ | Tiến độ | `/progress` | Biểu đồ tổng thể, chuẩn tỉnh |
689
+ | Bản đồ khái niệm | `/concept-map` | 58 nút, màu theo mức thành thạo |
690
+ | Lịch sử | `/history` | Tất cả bài đã thi, xu hướng điểm |
691
+ | Lỗi sai | `/mistakes` | Danh sách câu sai, giải thích AI |
692
+ | Phân tích lỗi | `/error-analysis` | Biểu đồ 5 loại lỗi theo chủ đề |
693
+ | Kiểm tra chẩn đoán | `/diagnostic` | 12 câu xác định điểm yếu ban đầu |
694
+ | Kiểm tra xếp lớp | `/placement` | 10 câu ước lượng năng lực |
695
+ | Thử thách hàng ngày | `/daily` | 1 câu/ngày, theo dõi streak |
696
+ | Tạo đề AI | `/generate-exam` | AI tạo đề theo yêu cầu (streaming) |
697
+ | Lớp học | `/class` *(chưa mở)* | Tạo/tham gia lớp, giáo viên theo dõi — tính năng đã xây dựng, chưa kích hoạt route |
698
+ | Tài khoản | `/account` | Thống kê, gói dịch vụ, lịch sử Tia |
699
+ | Chia sẻ kết quả | `/share` | Hình ảnh kết quả để đăng mạng xã hội |
700
+ | Thách đấu | `/challenge` | Thi cùng bạn bè, so sánh điểm |
701
+ | Admin | `/admin` | Quản lý người dùng (nội bộ) |
702
+ | Bảo mật Admin | `/admin/security-events` | Giám sát sự kiện bảo mật (nội bộ) |
703
+
704
+ ---
705
+
706
+ *Báo cáo được soạn thảo bằng cách tổng hợp toàn bộ mã nguồn hiện tại của ứng dụng (24 trang đã triển khai, tính năng Lớp Học đã xây dựng nhưng chưa mở route) kết hợp với kết quả nghiên cứu học thuật đã kiểm chứng qua harness nghiên cứu phản biện đa tác nhân (108 tác nhân AI, 26 nguồn, 9/25 tuyên bố được xác nhận). Các số liệu nghiên cứu có nguồn dẫn [1]–[9]. Số liệu về sản phẩm (số đề, số câu hỏi, v.v.) lấy từ dữ liệu hiện tại trong mã nguồn.*
CLAUDE.md ADDED
@@ -0,0 +1,335 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AI Agent App
2
+
3
+ Zenith — AI-native adaptive learning system for Vietnamese students (FastAPI + Claude via ai-router proxy).
4
+
5
+ ## Stack
6
+
7
+ - **Python** — FastAPI, pydantic-settings, tenacity, openai SDK (>=1.58.0)
8
+ - **Runtime** — uvicorn
9
+ - **AI** — Claude models via internal OpenAI-compatible proxy at `https://ai-router.locdo.tech`
10
+
11
+ ## Dev commands
12
+
13
+ ```bash
14
+ # Run both backend + frontend together (preferred)
15
+ npm install # install concurrently (root, first time only)
16
+ npm run dev # starts backend :8000 and frontend :5173 concurrently
17
+
18
+ # Backend only
19
+ pip install -r requirements.txt
20
+ PYTHONPATH=backend uvicorn app.main:app --reload # http://localhost:8000
21
+ python3 -m pytest backend/tests/ -m "not live_ai" --randomly-seed=0 -q # fast suite
22
+ python3 -m pytest backend/tests/ -m "live_ai" -v # live AI tests (needs token)
23
+
24
+ # Mutation testing (run from backend/, takes ~15-20 min for agent/ module)
25
+ cd backend && mutmut run # config in backend/pyproject.toml — baseline score: 16.5%
26
+ mutmut results # show killed/survived/no-tests breakdown
27
+ # WARNING: never run `mutmut apply <mutant>` without immediately running:
28
+ # git checkout -- app/agent/<file>.py (it physically corrupts source files)
29
+
30
+ # Frontend only
31
+ cd exam-app && npm install && npm run dev # http://localhost:5173
32
+ ```
33
+
34
+ ## Project structure
35
+
36
+ ```
37
+ backend/app/
38
+ config.py # Settings (pydantic-settings), get_settings(); ALLOWED_ORIGINS for CORS
39
+ dependencies.py # get_ai_client() singleton (AsyncOpenAI)
40
+ middleware.py # RateLimitMiddleware — IP (20/min) + per-user (60/min) + rapid-fire hint detection
41
+ abuse_detector.py # Background loop (5 min) — credit velocity, burst, score anomaly, new-account checks
42
+ main.py # FastAPI routes: /analyze /hint /explain /study-plan /health
43
+ # + /auth/google, /users/me, /users/me/profile, /users/me/credits/log
44
+ # + /admin/users/{id}/subscription|credits|suspend|unsuspend
45
+ # + GET /admin/security-events
46
+ agent/
47
+ core.py # call_with_retry() — tenacity retry wrapper for all AI calls
48
+ memory.py # compress_conversation() via Haiku
49
+ exam_analyzer.py # analyze_exam_result() — grade+province → location-aware school recs
50
+ hint_generator.py# generate_hint() — Socratic hints via Haiku
51
+ study_planner.py # generate_study_plan() — 4-week study plan with JSON fallback
52
+ tests/
53
+ test_ai_endpoints.py # pytest tests covering AI endpoints (LLM mocked)
54
+
55
+ exam-app/src/
56
+ api/
57
+ index.js # Static data loaders (questions, exams, schools)
58
+ aiClient.js # Axios client wrapping all backend endpoints; wrap() preserves structured errors
59
+ components/
60
+ AIInsights.jsx # Renders AI analysis; handles 401/402/403 error codes + credit top-up CTA
61
+ AIErrorBoundary.jsx # React error boundary wrapping AI sections
62
+ QuestionCard.jsx # Question renderer + hint (⚡1 credit) + explanation toggle (practice mode)
63
+ ProfileOnboarding.jsx # Modal: grade (required) + province (required) + school type + ToS gate
64
+ LowCreditBanner.jsx # Sticky banner when credits_balance < 10; dismissible per session
65
+ Navbar.jsx # ⚡ credits badge → /account; avatar/name → /account
66
+ pages/
67
+ Results.jsx # Async AI analysis with grade+province in payload; "Tạo Kế Hoạch" button
68
+ StudyPlan.jsx # /study-plan/:resultId — 4-week plan with localStorage checkbox progress
69
+ Account.jsx # /account — profile, tier/credits, pricing table (monthly/annual toggle), credit log
70
+ ExamSelect.jsx # Auth gate (1 guest trial), grade/tier filter, category lock for non-complete tiers
71
+ context/
72
+ ExamContext.jsx # Exam state + hints: {} + SET_HINT action + useHints() hook
73
+ AuthContext.jsx # user (all profile fields), login, logout, updateProfile()
74
+ ```
75
+
76
+ ## Frontend brand identity (rebrand in progress)
77
+
78
+ exam-app's visible brand is being renamed from "Luminary" to **Vantage** — a full replacement of typography, icons, color palette, and visual-asset motif (not an extension of the old system). This is unrelated to the "Zenith" internal product name above; only the exam-app UI's consumer-facing brand changes.
79
+
80
+ - **Mark**: summit-beacon motif (geometric peak + radiant glow) replacing the old astrolabe mark (`AstrolabeMark`/`LuminaryLogo.jsx` → `VantageLogo.jsx`)
81
+ - **Typography**: Fraunces (display) + Inter (body/UI) — both confirmed full Vietnamese diacritic support — replacing Cormorant Garamond + Plus Jakarta Sans; JetBrains Mono kept for math/code
82
+ - **Icons**: Phosphor (`@phosphor-icons/react`) replacing `lucide-react`
83
+ - **Palette**: gold/amber "ascent" accent + indigo-dusk base replacing the cobalt/violet tokens; `--mastery-0..5` reskinned as base-camp→vantage-point
84
+ - **Animation**: GSAP (+ `@gsap/react`) added alongside framer-motion (not replacing it) for scroll choreography and a tiered 3D-transform system (CSS 3D on every route, GSAP-choreographed 3D on high-traffic pages, optional lazy-loaded WebGL on 1-2 hero/celebration surfaces only)
85
+
86
+ Full rollout plan (phases, file-level detail, rationale, sources) lives in the approved blueprint: `C:\Users\Tai Minh\.claude\plans\groovy-baking-beaver.md`. Check that plan's phase progress before assuming old "Luminary" naming/tokens are still current.
87
+
88
+ ## User profile fields (users table)
89
+
90
+ | Field | Values | Effect |
91
+ |---|---|---|
92
+ | `grade` | '9','10','11','12' | ≤9 → grade10 exams only; 10-12 → thpt only |
93
+ | `province` | 63 VN provinces | AI school recs localized to province |
94
+ | `school_type` | 'chuyên','công lập','quốc tế' | Optional, informational |
95
+ | `subscription_tier` | 'basic','student','complete' | Controls exam access + study-plan gate |
96
+ | `subscription_period` | 'monthly','annual' | Annual shown with badge in Navbar/Account |
97
+ | `credits_balance` | integer ≥0 | Deducted per AI call; 402 when exhausted |
98
+ | `tos_accepted_at` | ISO timestamp | Required before any credit-deducting request |
99
+ | `is_suspended` | 0/1 | 403 account_suspended → suspension modal |
100
+
101
+ ## AI credit costs
102
+
103
+ | Endpoint | Credits |
104
+ |---|---|
105
+ | `/hint` | 1 |
106
+ | `/explain` | 1 |
107
+ | `/analyze` | 3 |
108
+ | `/study-plan` | 5 (student/complete tier only) |
109
+
110
+ ## Admin endpoints (require X-Admin-Key: current derived key)
111
+
112
+ Admin key rotates automatically (default: weekly). Get current key from `/data/admin_keys.txt` on HF Spaces or run `python tools/gen_admin_key.py`.
113
+
114
+ - `POST /admin/users/{id}/subscription` — set tier/period/expiry + bonus credits
115
+ - `POST /admin/users/{id}/credits` — grant top-up credits
116
+ - `POST /admin/users/{id}/suspend` — suspend with reason
117
+ - `POST /admin/users/{id}/unsuspend`
118
+ - `GET /admin/security-events` — recent HIGH/MEDIUM events with user status
119
+ - `POST /admin/generate-key-log` — (cron use only) derive + append current key to log; requires `X-Cron-Secret` header
120
+
121
+ ## AI router rules (CRITICAL)
122
+
123
+ - **SDK**: `openai` (never `anthropic`)
124
+ - **Base URL**: `https://ai-router.locdo.tech/v2` (set via `ANTHROPIC_BASE_URL` env var)
125
+ - **Auth**: env var `ANTHROPIC_AUTH_TOKEN` — never hardcode
126
+ - **Model names use dots**: `claude-sonnet-4.6`, `claude-opus-4.6`, `claude-haiku-4.5`
127
+ - **Never hardcode model names** — use `settings.default_model` / `settings.opus_model` / `settings.haiku_model`
128
+ - **Never create a new client per request** — use singleton `get_ai_client()` from `dependencies.py`
129
+
130
+ ## Model tiers
131
+
132
+ | Property | Model | Use |
133
+ |---|---|---|
134
+ | `settings.default_model` | `claude-sonnet-4.6` | Main agent loop |
135
+ | `settings.haiku_model` | `claude-haiku-4.5` | Cheap tasks: summarization, compression |
136
+ | `settings.opus_model` | `claude-opus-4.6` | Complex reasoning |
137
+
138
+ ## Env vars
139
+
140
+ **`backend/.env`** (copy from `backend/.env.example`, never commit)
141
+
142
+ | Variable | Example value |
143
+ |---|---|
144
+ | `ANTHROPIC_BASE_URL` | `https://ai-router.locdo.tech` |
145
+ | `ANTHROPIC_AUTH_TOKEN` | *(your token)* |
146
+ | `ANTHROPIC_DEFAULT_OPUS_MODEL` | `claude-opus-4.6` |
147
+ | `ANTHROPIC_DEFAULT_SONNET_MODEL` | `claude-sonnet-4.6` |
148
+ | `ANTHROPIC_DEFAULT_HAIKU_MODEL` | `claude-haiku-4.5` |
149
+ | `ALLOWED_ORIGINS` | `http://localhost:5173` |
150
+ | `SQLITE_PATH` | `./math_wiki.db` (local) / `/data/app.db` (HF Spaces) |
151
+ | `GOOGLE_CLIENT_ID` | *(Google OAuth client ID)* |
152
+ | `JWT_SECRET` | *(≥32 chars, required)* |
153
+ | `ADMIN_MASTER_SECRET` | *(≥32 chars — static master; effective key is HMAC-derived + time window)* |
154
+ | `ADMIN_KEY_ROTATION_PERIOD` | `weekly` *(daily\|weekly\|monthly\|quarterly\|annual)* |
155
+ | `ADMIN_KEY_LOG_PATH` | `./admin_keys.txt` (local) / `/data/admin_keys.txt` (HF Spaces) |
156
+ | `ADMIN_KEY_LOG_ENABLED` | `true` |
157
+ | `CRON_SECRET` | *(≥32 chars — authenticates POST /admin/generate-key-log from cron-job.org/GitHub Actions)* |
158
+
159
+ **`exam-app/.env`** (copy from `exam-app/.env.example`, never commit)
160
+
161
+ | Variable | Example value |
162
+ |---|---|
163
+ | `VITE_API_BASE_URL` | `http://localhost:8000` |
164
+
165
+ ## Test suite
166
+
167
+ **Two tiers — run at different times, never mix them up.**
168
+
169
+ ### Tier 1 — fast suite (run on every commit, blocks merge)
170
+ ```bash
171
+ PYTHONPATH=backend python3 -m pytest backend/tests/ \
172
+ -m "not live_ai and not fault_injection" \
173
+ --randomly-seed=0 -q
174
+ ```
175
+ Files: `test_regressions.py`, `test_auth_flow.py`, `test_resilience.py`, `test_streaming.py`, `test_budget.py`, `test_observability.py`, `test_admin.py`, `test_ai_endpoints.py`, `test_auth.py`, `test_auth_endpoint.py`, `test_user_endpoints.py`
176
+
177
+ ### Tier 2 — on-demand suites (excluded from default run — do NOT forget these)
178
+
179
+ | Suite | Run when | Command |
180
+ |---|---|---|
181
+ | `test_property_based.py` | After changes to credit logic or endpoint guards | `PYTHONPATH=backend pytest backend/tests/test_property_based.py -v` |
182
+ | `test_contract.py` (smoke) | Before a deploy | `PYTHONPATH=backend pytest backend/tests/test_contract.py::test_health_endpoint backend/tests/test_contract.py::test_openapi_schema_has_minimum_endpoints` |
183
+ | `test_contract.py` (full) | Weekly — catches 500s on edge inputs, light security fuzzing | `schemathesis run http://localhost:8000/openapi.json --checks all` |
184
+ | `test_fault_injection.py` | After changing retry/fallback logic | `PYTHONPATH=backend pytest backend/tests/ -m fault_injection -v` |
185
+ | `test_wiki_math_system.py` | After changing `app/math_wiki/` pipeline | `PYTHONPATH=backend pytest backend/tests/test_wiki_math_system.py -m live_ai -v` |
186
+
187
+ These are excluded from the fast suite because they either load PyTorch (segfault risk in subprocesses), use Hypothesis (too slow per mutant), require a live API token, or are non-deterministic. **Excluding them from the default run does not make them optional** — they catch different classes of bugs.
188
+
189
+ ### Adding new test cases
190
+ - **Parametrized tests** (auth flow, hints, analyze): edit YAML in `backend/tests/fixtures/` — no Python needed
191
+ - **Any test needing a mock pool/user**: use `PoolBuilder`, `UserBuilder`, `FULL_USER_ROW` from `backend/tests/builders.py`
192
+ - **Regression for a production bug**: add to `test_regressions.py` with `@pytest.mark.regression`, docstring stating commit hash + what broke
193
+
194
+ ### Mutation testing
195
+ Baseline: **16.5% kill rate** on `app/agent/` (June 2025). Run from `backend/` directory:
196
+ ```bash
197
+ cd backend && mutmut run # config: backend/pyproject.toml
198
+ mutmut results # show killed/survived breakdown
199
+ ```
200
+ Low score is expected — tests mock `call_with_retry` so prompt-building logic executes but tests only assert on HTTP status.
201
+ **WARNING: `mutmut apply <mutant>` physically modifies source files. Always revert immediately:** `git checkout -- app/agent/<file>.py`
202
+
203
+ ## Key patterns
204
+
205
+ **Error handling** — wrap all `client.chat.completions.create()` with `call_with_retry()` from `agent/core.py`. Catches `RateLimitError` (retry), `APIConnectionError`, `APIStatusError`.
206
+
207
+ **Prefix caching** — static system prompt content first (e.g. `STATIC_EXAM_ANALYSIS_INSTRUCTIONS`); dynamic context (student name, score, weak topics) appended last.
208
+
209
+ **Pricing** — `PRICE_TABLE` in `tools/registry.py` maps product type → VND/m². Default fallback: 1,600,000 VND/m².
210
+
211
+ ## Development workflow
212
+
213
+ This project uses two collaborating tools for code intelligence and structured work:
214
+
215
+ - **GitNexus MCP** — knowledge graph of 109 symbols and 162 relationships, indexed from the codebase. Use it to understand blast radius before editing, trace execution flows, and do safe renames.
216
+ - **agent-skills plugin** — structured workflow skills (spec, plan, build, test, review, etc.) that map to common engineering tasks.
217
+
218
+ ### When to reach for each
219
+
220
+ | Task | Use |
221
+ |---|---|
222
+ | "What calls `run_agent()`?" / "What breaks if I change this?" | GitNexus: `gitnexus_impact`, `gitnexus_context` |
223
+ | "How does the tool loop work?" / "Find all entry points" | GitNexus: `gitnexus_query` |
224
+ | Adding a new feature end-to-end | agent-skills: `/spec` → `/plan` → `/build` |
225
+ | Fixing a bug with proof it's fixed | agent-skills: `/test` (Prove-It pattern) |
226
+ | Pre-merge check | agent-skills: `/review` + GitNexus: `gitnexus_detect_changes` |
227
+ | Renaming a symbol across files | GitNexus: `gitnexus_rename` |
228
+
229
+ ### GitNexus rules
230
+
231
+ - **Before any coding task in `exam-app/` or `backend/`** — ALWAYS run `npx gitnexus analyze --embeddings` to refresh the index, then run the relevant GitNexus MCP tools (impact, context, query) before writing a single line of code.
232
+ - **Before editing any symbol** — run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius. Stop and warn the user on HIGH or CRITICAL risk.
233
+ - **Before committing** — run `gitnexus_detect_changes()` to verify only expected symbols were affected.
234
+ - **Never rename with find-and-replace** — use `gitnexus_rename` which understands the call graph.
235
+
236
+ ### GitNexus skill files
237
+
238
+ | Goal | Skill |
239
+ |---|---|
240
+ | Architecture exploration | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
241
+ | Blast radius / impact analysis | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
242
+ | Bug tracing | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
243
+ | Refactoring / rename / extract | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
244
+ | Full tool + resource reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
245
+
246
+ ### GitNexus index state
247
+
248
+ Indexed as **AI-Agent-App** — re-index with `gitnexus analyze /mnt/d/AI-Agent-App --skip-git` after significant changes.
249
+
250
+ ### GitNexus MCP resources
251
+
252
+ | Resource | Use for |
253
+ |----------|---------|
254
+ | `gitnexus://repo/AI-Agent-App/context` | Codebase overview, index freshness |
255
+ | `gitnexus://repo/AI-Agent-App/processes` | All execution flows |
256
+
257
+ <!-- gitnexus:start -->
258
+ # GitNexus — Code Intelligence
259
+
260
+ This project is indexed by GitNexus as **AI-Agent-App** (2571 symbols, 3957 relationships, 79 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely.
261
+
262
+ > Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939).
263
+
264
+ ## Always Do
265
+
266
+ - **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user.
267
+ - **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: `detect_changes({scope: "compare", base_ref: "main"})`.
268
+ - **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits.
269
+ - When exploring unfamiliar code, use `query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance.
270
+ - When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`.
271
+
272
+ ## Never Do
273
+
274
+ - NEVER edit a function, class, or method without first running `impact` on it.
275
+ - NEVER ignore HIGH or CRITICAL risk warnings from impact analysis.
276
+ - NEVER rename symbols with find-and-replace — use `rename` which understands the call graph.
277
+ - NEVER commit changes without running `detect_changes()` to check affected scope.
278
+
279
+ ## Resources
280
+
281
+ | Resource | Use for |
282
+ |----------|---------|
283
+ | `gitnexus://repo/AI-Agent-App/context` | Codebase overview, check index freshness |
284
+ | `gitnexus://repo/AI-Agent-App/clusters` | All functional areas |
285
+ | `gitnexus://repo/AI-Agent-App/processes` | All execution flows |
286
+ | `gitnexus://repo/AI-Agent-App/process/{name}` | Step-by-step execution trace |
287
+
288
+ ## CLI
289
+
290
+ | Task | Read this skill file |
291
+ |------|---------------------|
292
+ | Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` |
293
+ | Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` |
294
+ | Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` |
295
+ | Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` |
296
+ | Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` |
297
+ | Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` |
298
+
299
+ <!-- gitnexus:end -->
300
+
301
+ ## Deploy commands
302
+
303
+ ### Hugging Face Space (backend) — orphan push required
304
+
305
+ ```bash
306
+ git checkout master
307
+ git checkout --orphan hf-deploy-new
308
+ git add -A
309
+ git commit -m "deploy: $(git log master --oneline -1 | cut -c1-7)"
310
+ git rm --cached tools/pdfs/amc8_2019.pdf tools/pdfs/cemc_gauss8_2023.pdf tools/pdfs/ukmt_imc_2020.pdf tools/pdfs/ukmt_jmc_2019.pdf
311
+ git rm --cached exam-app/public/images/questions/*.png
312
+ git commit --amend --no-edit
313
+ git branch -D hf-deploy
314
+ git branch -m hf-deploy-new hf-deploy
315
+ git push --force space hf-deploy:main
316
+ git checkout -f master
317
+ ```
318
+
319
+ **Never** use `git merge master` on hf-deploy — the repo history contains old binary files that HF rejects. The orphan commit has no parents, so none of that history is included.
320
+
321
+ **Binary files must be stripped after the initial commit via `git rm --cached` + `git commit --amend`.** HF Spaces rejects any push containing binary files — this includes `tools/pdfs/*.pdf` AND `exam-app/public/images/questions/*.png` (the question images are served by Cloudflare Pages, not the HF backend). `git rm --cached` before `git add -A` does not work because `git add -A` re-adds the files. The correct order is: commit everything first, then remove the binaries from the index with `--cached`, then amend.
322
+
323
+ **Use `git checkout -f master`** (force) when returning to master after the orphan branch. The PDF files remain as untracked working-tree files after `git rm --cached`, which causes a plain `git checkout master` to abort with "would be overwritten by checkout".
324
+
325
+ ### Cloudflare Pages (frontend) — must use `--branch=main`
326
+
327
+ ```bash
328
+ cd exam-app
329
+ VITE_API_BASE_URL=https://minhtai-ai-agent-app.hf.space npm run build
330
+ npx wrangler pages deploy dist --project-name exam-app --branch=main --commit-dirty=true
331
+ ```
332
+
333
+ **Always** pass `--branch=main`. Without it, wrangler creates a **Preview** deployment (not Production), and `exam-app-ey0.pages.dev` keeps serving the old bundle. The production URL only aliases Production deployments.
334
+
335
+ **Always** set `VITE_API_BASE_URL` explicitly. `exam-app/.env.local` (used for local dev) takes precedence over `exam-app/.env` in Vite's env loading order, so omitting the explicit override bakes `localhost:8000` into the production bundle.
Dockerfile ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ RUN apt-get update && rm -rf /var/lib/apt/lists/*
6
+
7
+ COPY requirements.txt .
8
+ RUN pip install --no-cache-dir -r requirements.txt
9
+
10
+ COPY backend/ backend/
11
+ COPY scripts/ scripts/
12
+ COPY exam-app/src/data/ exam-app/src/data/
13
+
14
+ ENV PYTHONPATH=/app/backend:/app/scripts
15
+
16
+ # Auto-run on first boot: fill zero-unit wiki topics (e.g. geometry) and sanitize duplicates.
17
+ # Both self-disable via _hf_set_space_variable after success, so they only run once.
18
+ ENV CRAWL_GAP_FILL_ENABLED=true
19
+ ENV WIKI_SANITIZE_ENABLED=true
20
+
21
+ # HF Spaces requires a non-root user
22
+ RUN useradd -m -u 1000 appuser && chown -R appuser /app
23
+ USER appuser
24
+
25
+ EXPOSE 7860
26
+
27
+ CMD uvicorn app.main:app --host 0.0.0.0 --port 7860
README.md ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: AI Agent App
3
+ emoji: 🤖
4
+ colorFrom: blue
5
+ colorTo: indigo
6
+ sdk: docker
7
+ pinned: false
8
+ app_port: 7860
9
+ ---
10
+
11
+ # AI Agent App
12
+
13
+ Vietnamese aluminum/glass door sales chatbot + AI-powered exam backend.
14
+
15
+ Built with FastAPI + Claude via ai-router proxy.
backend/.env.example ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ANTHROPIC_BASE_URL=https://ai-router.locdo.tech
2
+ ANTHROPIC_AUTH_TOKEN=your_token_here
3
+ ANTHROPIC_DEFAULT_OPUS_MODEL=claude-opus-4.6
4
+ ANTHROPIC_DEFAULT_SONNET_MODEL=claude-sonnet-4.6
5
+ ANTHROPIC_DEFAULT_HAIKU_MODEL=claude-haiku-4.5
6
+ # Model to retry against when the router reports the primary model's provider is
7
+ # unavailable (401 expired OAuth session, 503 auth_unavailable). Leave blank to
8
+ # disable fallback. gemini-2.5-pro is a good default — it's on the same router
9
+ # and stays up independently of Claude's provider session.
10
+ ANTHROPIC_FALLBACK_MODEL=gemini-2.5-pro
11
+ ALLOWED_ORIGINS=http://localhost:5173
12
+ # SQLite database path. On HF Spaces use /data/app.db (persistent storage must be enabled).
13
+ SQLITE_PATH=/data/app.db
14
+ # Google OAuth 2.0 Client ID — create at console.cloud.google.com → Credentials → OAuth 2.0 Client IDs
15
+ # Add Authorised JavaScript Origins: http://localhost:5173 and your HF Space URL
16
+ GOOGLE_CLIENT_ID=your_google_client_id_here
17
+ # JWT signing secret — generate with: python -c "import secrets; print(secrets.token_hex(32))"
18
+ JWT_SECRET=your_jwt_secret_here
19
+ # Static admin key sent in X-Admin-Key header for all admin endpoints.
20
+ # Generate with: python -c "import secrets; print(secrets.token_hex(32))"
21
+ ADMIN_KEY=your_admin_key_here
22
+ APP_URL=https://exam-app-ey0.pages.dev
backend/app/__init__.py ADDED
File without changes
backend/app/config.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from functools import lru_cache
2
+ from pathlib import Path
3
+ from pydantic_settings import BaseSettings, SettingsConfigDict
4
+
5
+ # Resolve .env relative to this file so it works regardless of CWD (e.g. npm run dev from repo root)
6
+ _ENV_FILE = Path(__file__).parent.parent / ".env"
7
+
8
+
9
+ class Settings(BaseSettings):
10
+ model_config = SettingsConfigDict(env_file=str(_ENV_FILE), env_file_encoding="utf-8", extra="ignore")
11
+
12
+ allowed_origins: str = "http://localhost:5173,https://exam-app-ey0.pages.dev"
13
+ sqlite_path: str = "/data/app.db"
14
+
15
+ @property
16
+ def allowed_origins_list(self) -> list[str]:
17
+ return [o.strip() for o in self.allowed_origins.split(",")]
18
+
19
+
20
+ @lru_cache
21
+ def get_settings() -> Settings:
22
+ return Settings()
backend/app/db.py ADDED
@@ -0,0 +1,214 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """asyncpg-compatible wrapper around aiosqlite for local SQLite storage.
2
+
3
+ Drop-in replacement for the asyncpg connection pool: same acquire(), fetchrow(),
4
+ fetch(), fetchval(), execute(), and transaction() API, so pg_db.py / analytics.py /
5
+ sanitizer.py require zero changes.
6
+
7
+ SQL translation handled automatically:
8
+ $N placeholders → ?
9
+ = ANY($N) → IN (?,?,?) (array expansion)
10
+ ::type casts → stripped
11
+ NOW() → datetime('now')
12
+ list params → JSON-serialised (for embedding storage)
13
+
14
+ Architecture note:
15
+ A single persistent aiosqlite connection is shared across all callers. An
16
+ asyncio.Lock serialises every acquire() so only one coroutine touches the
17
+ SQLite file at a time. This eliminates the concurrent-writer corruption that
18
+ WAL mode's shared-memory coordination (-shm/-wal files) causes on
19
+ network/container filesystems (NFS, Docker overlays, HuggingFace Spaces /data).
20
+ """
21
+ import asyncio
22
+ import json
23
+ import logging
24
+ import re
25
+ import sqlite3
26
+ from contextlib import asynccontextmanager
27
+ from pathlib import Path
28
+ from typing import Any, AsyncGenerator, Optional
29
+
30
+ import aiosqlite
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ class Row(dict):
36
+ """Dict that also supports positional (integer) access like asyncpg Record."""
37
+
38
+ def __getitem__(self, key: Any) -> Any:
39
+ if isinstance(key, int):
40
+ return list(self.values())[key]
41
+ return super().__getitem__(key)
42
+
43
+
44
+ def _translate(query: str, params: tuple) -> tuple[str, list]:
45
+ """Translate PostgreSQL query + params to SQLite equivalents."""
46
+ # Detect which $N positions are used by ANY($N) (0-based)
47
+ any_positions: set[int] = set()
48
+ for m in re.finditer(r"=\s*ANY\(\$(\d+)\)", query, re.IGNORECASE):
49
+ any_positions.add(int(m.group(1)) - 1)
50
+
51
+ # Expand = ANY($N) → IN (?,?,?)
52
+ def _expand_any(m: re.Match) -> str:
53
+ idx = int(m.group(1)) - 1
54
+ arr = list(params[idx]) if params[idx] else []
55
+ return f"IN ({','.join(['?'] * len(arr))})" if arr else "IN (NULL)"
56
+
57
+ query = re.sub(r"=\s*ANY\(\$(\d+)\)", _expand_any, query, flags=re.IGNORECASE)
58
+
59
+ # Strip PostgreSQL type casts: ::jsonb, ::timestamptz, ::text[], ::float, etc.
60
+ query = re.sub(r"::[a-zA-Z_][\w\[\]]*", "", query)
61
+
62
+ # Replace NOW() with SQLite equivalent
63
+ query = re.sub(r"\bNOW\(\)", "datetime('now')", query, flags=re.IGNORECASE)
64
+
65
+ # Build flat params, expanding ANY arrays and serialising lists→JSON
66
+ new_params: list = []
67
+ for i, p in enumerate(params):
68
+ if i in any_positions:
69
+ new_params.extend(list(p) if p else [])
70
+ elif isinstance(p, list):
71
+ new_params.append(json.dumps(p))
72
+ else:
73
+ new_params.append(p)
74
+
75
+ # Replace remaining $N placeholders with ?
76
+ query = re.sub(r"\$\d+", "?", query)
77
+
78
+ return query, new_params
79
+
80
+
81
+ class _Connection:
82
+ def __init__(self, conn: aiosqlite.Connection) -> None:
83
+ self._conn = conn
84
+ self._in_transaction = False
85
+
86
+ async def fetchrow(self, query: str, *args) -> Optional[Row]:
87
+ q, params = _translate(query, args)
88
+ cur = await self._conn.execute(q, params)
89
+ row = await cur.fetchone()
90
+ # Commit after fetch so INSERT … RETURNING rows are both readable and persisted.
91
+ if not self._in_transaction:
92
+ await self._conn.commit()
93
+ if row is None or cur.description is None:
94
+ return None
95
+ cols = [d[0] for d in cur.description]
96
+ return Row(zip(cols, row))
97
+
98
+ async def fetch(self, query: str, *args) -> list[Row]:
99
+ q, params = _translate(query, args)
100
+ cur = await self._conn.execute(q, params)
101
+ rows = await cur.fetchall()
102
+ if not self._in_transaction:
103
+ await self._conn.commit()
104
+ if cur.description is None:
105
+ return []
106
+ cols = [d[0] for d in cur.description]
107
+ return [Row(zip(cols, r)) for r in rows]
108
+
109
+ async def fetchval(self, query: str, *args) -> Any:
110
+ q, params = _translate(query, args)
111
+ cur = await self._conn.execute(q, params)
112
+ row = await cur.fetchone()
113
+ if not self._in_transaction:
114
+ await self._conn.commit()
115
+ return row[0] if row else None
116
+
117
+ async def execute(self, query: str, *args) -> str:
118
+ q, params = _translate(query, args)
119
+ cur = await self._conn.execute(q, params)
120
+ if not self._in_transaction:
121
+ await self._conn.commit()
122
+ return f"UPDATE {cur.rowcount}"
123
+
124
+ @asynccontextmanager
125
+ async def transaction(self) -> AsyncGenerator[None, None]:
126
+ self._in_transaction = True
127
+ try:
128
+ yield
129
+ await self._conn.commit()
130
+ except BaseException:
131
+ await self._conn.rollback()
132
+ raise
133
+ finally:
134
+ self._in_transaction = False
135
+
136
+
137
+ class AsyncSQLitePool:
138
+ """Single-connection asyncpg-compatible pool backed by a local SQLite file.
139
+
140
+ One persistent aiosqlite connection is shared by all callers. An asyncio.Lock
141
+ ensures only one coroutine executes against the connection at a time, which is
142
+ both sufficient (single uvicorn process) and necessary (prevents WAL-mode
143
+ shared-memory corruption on container/NFS filesystems).
144
+ """
145
+
146
+ def __init__(self, db_path: str) -> None:
147
+ self._path = db_path
148
+ self._conn: Optional[aiosqlite.Connection] = None
149
+ self._lock: asyncio.Lock = asyncio.Lock()
150
+ Path(db_path).parent.mkdir(parents=True, exist_ok=True)
151
+
152
+ async def initialize(self) -> None:
153
+ """Open the persistent connection; auto-recover if the DB file is corrupted."""
154
+ try:
155
+ conn = await aiosqlite.connect(self._path)
156
+ await conn.execute("PRAGMA foreign_keys = ON")
157
+ await conn.execute("PRAGMA cache_size = -64000") # 64 MB in-process page cache
158
+ await conn.execute("PRAGMA busy_timeout = 5000") # queue 5 s before failing
159
+ cur = await conn.execute("PRAGMA integrity_check")
160
+ row = await cur.fetchone()
161
+ if row and row[0] != "ok":
162
+ await conn.close()
163
+ raise sqlite3.DatabaseError(f"integrity_check: {row[0]}")
164
+ await conn.commit()
165
+ self._conn = conn
166
+ except (sqlite3.DatabaseError, Exception) as exc:
167
+ logger.warning("DB at %s is corrupt (%s) — wiping and recreating", self._path, exc)
168
+ if self._conn is not None:
169
+ try:
170
+ await self._conn.close()
171
+ except Exception:
172
+ pass
173
+ self._conn = None
174
+ path = Path(self._path)
175
+ for suffix in ("", "-wal", "-shm"):
176
+ candidate = Path(str(path) + suffix)
177
+ if candidate.exists():
178
+ candidate.unlink()
179
+ conn = await aiosqlite.connect(self._path)
180
+ await conn.execute("PRAGMA foreign_keys = ON")
181
+ await conn.execute("PRAGMA cache_size = -64000")
182
+ await conn.execute("PRAGMA busy_timeout = 5000")
183
+ await conn.commit()
184
+ self._conn = conn
185
+ logger.info("Fresh DB created at %s", self._path)
186
+
187
+ @asynccontextmanager
188
+ async def acquire(self) -> AsyncGenerator[_Connection, None]:
189
+ async with self._lock:
190
+ yield _Connection(self._conn)
191
+
192
+ # Shortcut methods (asyncpg pools expose these directly)
193
+
194
+ async def fetchrow(self, query: str, *args) -> Optional[Row]:
195
+ async with self.acquire() as conn:
196
+ return await conn.fetchrow(query, *args)
197
+
198
+ async def fetch(self, query: str, *args) -> list[Row]:
199
+ async with self.acquire() as conn:
200
+ return await conn.fetch(query, *args)
201
+
202
+ async def fetchval(self, query: str, *args) -> Any:
203
+ async with self.acquire() as conn:
204
+ return await conn.fetchval(query, *args)
205
+
206
+ async def execute(self, query: str, *args) -> str:
207
+ async with self.acquire() as conn:
208
+ return await conn.execute(query, *args)
209
+
210
+ async def close(self) -> None:
211
+ async with self._lock:
212
+ if self._conn is not None:
213
+ await self._conn.close()
214
+ self._conn = None
backend/app/main.py ADDED
@@ -0,0 +1,184 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import logging
3
+ from contextlib import asynccontextmanager
4
+ from pathlib import Path
5
+
6
+ from fastapi import Depends, FastAPI, HTTPException, Request
7
+ from fastapi.middleware.cors import CORSMiddleware
8
+
9
+ from app.config import get_settings
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ _SCHEMA_DDL = [
14
+ """CREATE TABLE IF NOT EXISTS exams (
15
+ id TEXT PRIMARY KEY,
16
+ year INTEGER,
17
+ title TEXT NOT NULL,
18
+ duration INTEGER,
19
+ source TEXT,
20
+ category TEXT NOT NULL,
21
+ mode TEXT,
22
+ total_questions INTEGER,
23
+ created_at TEXT DEFAULT (datetime('now'))
24
+ )""",
25
+ """CREATE TABLE IF NOT EXISTS questions (
26
+ id TEXT PRIMARY KEY,
27
+ source TEXT,
28
+ year INTEGER,
29
+ topic TEXT,
30
+ difficulty TEXT,
31
+ question TEXT NOT NULL,
32
+ choices TEXT NOT NULL,
33
+ correct INTEGER NOT NULL,
34
+ explanation TEXT,
35
+ created_at TEXT DEFAULT (datetime('now'))
36
+ )""",
37
+ """CREATE TABLE IF NOT EXISTS exam_questions (
38
+ exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE,
39
+ question_id TEXT NOT NULL REFERENCES questions(id),
40
+ position INTEGER NOT NULL,
41
+ PRIMARY KEY (exam_id, question_id)
42
+ )""",
43
+ "CREATE INDEX IF NOT EXISTS idx_eq_exam ON exam_questions(exam_id)",
44
+ "CREATE INDEX IF NOT EXISTS idx_q_topic ON questions(topic)",
45
+ ]
46
+
47
+
48
+ async def _apply_schema(pool) -> None:
49
+ """Run DDL idempotently on every startup — all statements are CREATE IF NOT EXISTS."""
50
+ async with pool.acquire() as conn:
51
+ for stmt in _SCHEMA_DDL:
52
+ try:
53
+ await conn.execute(stmt)
54
+ except Exception as exc:
55
+ logger.warning("DDL skipped (%s): %.80s", exc, stmt)
56
+ logger.info("Schema applied (%d statements)", len(_SCHEMA_DDL))
57
+
58
+
59
+ async def _seed_from_json(pool) -> None:
60
+ """Auto-seed exams and questions tables from bundled JSON files (runs once, INSERT OR IGNORE)."""
61
+ data_dir = Path(__file__).parent.parent.parent / "exam-app" / "src" / "data"
62
+ try:
63
+ exams_path = data_dir / "exams.json"
64
+ questions_path = data_dir / "questions.json"
65
+ if not exams_path.exists() or not questions_path.exists():
66
+ logger.warning("_seed_from_json: JSON files not found at %s — skipping seed", data_dir)
67
+ return
68
+ exams = json.loads(exams_path.read_text(encoding="utf-8"))
69
+ questions = json.loads(questions_path.read_text(encoding="utf-8"))
70
+ async with pool.acquire() as conn:
71
+ async with conn.transaction():
72
+ for e in exams:
73
+ await conn.execute(
74
+ "INSERT OR IGNORE INTO exams (id, year, title, duration, source, category, mode, total_questions) VALUES (?,?,?,?,?,?,?,?)",
75
+ e["id"], e.get("year"), e["title"], e.get("duration"), e.get("source"),
76
+ e["category"], e.get("mode"), e.get("totalQuestions"),
77
+ )
78
+ for q in questions:
79
+ await conn.execute(
80
+ "INSERT OR IGNORE INTO questions (id, source, year, topic, difficulty, question, choices, correct, explanation) VALUES (?,?,?,?,?,?,?,?,?)",
81
+ q["id"], q.get("source"), q.get("year"), q.get("topic"), q.get("difficulty"),
82
+ q["question"], json.dumps(q.get("choices", []), ensure_ascii=False),
83
+ q["correct"], q.get("explanation"),
84
+ )
85
+ for e in exams:
86
+ for i, qid in enumerate(e.get("questionIds", [])):
87
+ await conn.execute(
88
+ "INSERT OR IGNORE INTO exam_questions (exam_id, question_id, position) VALUES (?,?,?)",
89
+ e["id"], qid, i,
90
+ )
91
+ logger.info("_seed_from_json: seeded %d exams, %d questions", len(exams), len(questions))
92
+ except Exception as exc:
93
+ logger.warning("_seed_from_json failed: %s", exc)
94
+
95
+
96
+ @asynccontextmanager
97
+ async def lifespan(app: FastAPI):
98
+ from app.db import AsyncSQLitePool
99
+
100
+ settings = get_settings()
101
+ pool = AsyncSQLitePool(settings.sqlite_path)
102
+ await pool.initialize()
103
+ app.state.pool = pool
104
+ await _apply_schema(app.state.pool)
105
+ logger.info("SQLite pool ready at %s", settings.sqlite_path)
106
+
107
+ exam_count = await app.state.pool.fetchrow("SELECT COUNT(*) AS cnt FROM exams")
108
+ q_count = await app.state.pool.fetchrow("SELECT COUNT(*) AS cnt FROM questions")
109
+ if (exam_count and exam_count["cnt"] == 0) or (q_count and q_count["cnt"] == 0):
110
+ await _seed_from_json(app.state.pool)
111
+
112
+ yield
113
+
114
+ if app.state.pool:
115
+ await app.state.pool.close()
116
+
117
+
118
+ def get_pool(request: Request):
119
+ return getattr(request.app.state, "pool", None)
120
+
121
+
122
+ app = FastAPI(title="AI Agent App", lifespan=lifespan)
123
+
124
+ settings = get_settings()
125
+ app.add_middleware(
126
+ CORSMiddleware,
127
+ allow_origins=settings.allowed_origins_list,
128
+ allow_credentials=True,
129
+ allow_methods=["GET", "POST", "OPTIONS"],
130
+ allow_headers=["Content-Type"],
131
+ )
132
+
133
+
134
+ @app.get("/health")
135
+ @app.head("/health")
136
+ async def health():
137
+ return {"status": "ok"}
138
+
139
+
140
+ @app.get("/exams")
141
+ async def list_exams(mode: str | None = None, pool=Depends(get_pool)):
142
+ if mode:
143
+ rows = await pool.fetch(
144
+ "SELECT id,year,title,duration,source,category,mode,total_questions AS totalQuestions FROM exams WHERE mode=? ORDER BY year DESC",
145
+ mode,
146
+ )
147
+ else:
148
+ rows = await pool.fetch(
149
+ "SELECT id,year,title,duration,source,category,mode,total_questions AS totalQuestions FROM exams WHERE mode!='retired' ORDER BY year DESC"
150
+ )
151
+ return [dict(r) for r in rows]
152
+
153
+
154
+ @app.get("/exams/{exam_id}")
155
+ async def get_exam(exam_id: str, pool=Depends(get_pool)):
156
+ exam = await pool.fetchrow(
157
+ "SELECT id,year,title,duration,source,category,mode,total_questions AS totalQuestions FROM exams WHERE id=?",
158
+ exam_id,
159
+ )
160
+ if not exam:
161
+ raise HTTPException(status_code=404, detail="Exam not found")
162
+ q_ids = await pool.fetch(
163
+ "SELECT question_id FROM exam_questions WHERE exam_id=? ORDER BY position", exam_id
164
+ )
165
+ return {**dict(exam), "questionIds": [r["question_id"] for r in q_ids]}
166
+
167
+
168
+ @app.post("/questions/batch")
169
+ async def batch_questions(body: dict, pool=Depends(get_pool)):
170
+ ids = body.get("ids", [])[:200]
171
+ if not ids:
172
+ return []
173
+ placeholders = ",".join("?" * len(ids))
174
+ rows = await pool.fetch(f"SELECT * FROM questions WHERE id IN ({placeholders})", *ids)
175
+ return [{**dict(r), "choices": json.loads(r["choices"])} for r in rows]
176
+
177
+
178
+ @app.get("/questions")
179
+ async def all_questions(topic: str | None = None, pool=Depends(get_pool)):
180
+ if topic:
181
+ rows = await pool.fetch("SELECT * FROM questions WHERE topic=?", topic)
182
+ else:
183
+ rows = await pool.fetch("SELECT * FROM questions")
184
+ return [{**dict(r), "choices": json.loads(r["choices"])} for r in rows]
backend/pyproject.toml ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ [tool.pytest.ini_options]
2
+ asyncio_mode = "auto"
backend/tests/__init__.py ADDED
File without changes
backend/tests/test_exams.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+
3
+ import pytest
4
+ from httpx import AsyncClient, ASGITransport
5
+
6
+ from app.main import app
7
+
8
+
9
+ @pytest.fixture(scope="module")
10
+ async def client(tmp_path_factory):
11
+ db_path = str(tmp_path_factory.mktemp("db") / "test.db")
12
+ os.environ["SQLITE_PATH"] = db_path
13
+ from app.config import get_settings
14
+ get_settings.cache_clear()
15
+
16
+ async with app.router.lifespan_context(app):
17
+ async with AsyncClient(transport=ASGITransport(app=app), base_url="http://test") as ac:
18
+ yield ac
19
+ del os.environ["SQLITE_PATH"]
20
+ get_settings.cache_clear()
21
+
22
+
23
+ @pytest.mark.asyncio
24
+ async def test_health(client):
25
+ resp = await client.get("/health")
26
+ assert resp.status_code == 200
27
+ assert resp.json() == {"status": "ok"}
28
+
29
+
30
+ @pytest.mark.asyncio
31
+ async def test_list_exams(client):
32
+ resp = await client.get("/exams")
33
+ assert resp.status_code == 200
34
+ exams = resp.json()
35
+ assert isinstance(exams, list)
36
+ assert len(exams) > 0
37
+ assert "id" in exams[0] and "title" in exams[0]
38
+
39
+
40
+ @pytest.mark.asyncio
41
+ async def test_get_exam_by_id(client):
42
+ exams = (await client.get("/exams")).json()
43
+ exam_id = exams[0]["id"]
44
+ resp = await client.get(f"/exams/{exam_id}")
45
+ assert resp.status_code == 200
46
+ body = resp.json()
47
+ assert body["id"] == exam_id
48
+ assert isinstance(body["questionIds"], list)
49
+
50
+
51
+ @pytest.mark.asyncio
52
+ async def test_get_exam_not_found(client):
53
+ resp = await client.get("/exams/does-not-exist")
54
+ assert resp.status_code == 404
55
+
56
+
57
+ @pytest.mark.asyncio
58
+ async def test_list_questions(client):
59
+ resp = await client.get("/questions")
60
+ assert resp.status_code == 200
61
+ questions = resp.json()
62
+ assert isinstance(questions, list)
63
+ assert len(questions) > 0
64
+ assert isinstance(questions[0]["choices"], list)
65
+
66
+
67
+ @pytest.mark.asyncio
68
+ async def test_batch_questions(client):
69
+ questions = (await client.get("/questions")).json()
70
+ ids = [q["id"] for q in questions[:3]]
71
+ resp = await client.post("/questions/batch", json={"ids": ids})
72
+ assert resp.status_code == 200
73
+ body = resp.json()
74
+ assert len(body) == len(ids)
75
+
76
+
77
+ @pytest.mark.asyncio
78
+ async def test_batch_questions_empty(client):
79
+ resp = await client.post("/questions/batch", json={"ids": []})
80
+ assert resp.status_code == 200
81
+ assert resp.json() == []
demo-video/ai-enhancement.md ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Zenith — AI Video Enhancement Plan
2
+
3
+ ## Scene-by-Scene Enhancements
4
+
5
+ ---
6
+
7
+ ### Scene 1: Hero Arrival (0:00–0:08)
8
+
9
+ **Enhancement type:** Atmospheric ambient motion + color grade
10
+ **Tool:** Kling AI (image-to-video ambient loop) or Runway Gen-3 (video-to-video texture pass)
11
+
12
+ **Kling AI prompt (background layer):**
13
+ > "Dark indigo space background, slow drift of tiny amber and violet light particles, no hard edges, dreamlike depth of field, cinematic, 8-second seamless loop, no text, no UI, macro lens feel"
14
+
15
+ **Camera movement:** None on the app itself. Apply a very slow (8s) parallax drift of ~4px on a separate background layer behind the screen recording, using CapCut keyframe position tool. This creates subtle depth without disrupting the UI.
16
+
17
+ **Color grade note:** Pull down highlights to near #0A0E1A equivalent, boost amber channel (+15 hue rotation toward gold). Add a very subtle vignette (15% edge darkening) to focus attention on the hero headline.
18
+
19
+ ---
20
+
21
+ ### Scene 2: Exam Select Catalog (0:08–0:17)
22
+
23
+ **Enhancement type:** Text overlay + subtle Ken Burns on card grid
24
+ **Tool:** CapCut (keyframe opacity + position on text layer)
25
+
26
+ **Overlay text to animate in:**
27
+ > "40+ đề thi thật · 63 tỉnh thành"
28
+ > White text · Plus Jakarta Sans Bold · fade in at 0:10 · hold 2s · fade out at 0:15
29
+
30
+ **Camera movement:** Subtle Ken Burns on the exam card grid: 1.0x → 1.02x scale over 9s, panning very slightly upward. Implies depth without distraction.
31
+
32
+ **Color grade note:** Slightly cool tint (−5 temperature) to contrast with the warm amber score badges on each card row.
33
+
34
+ ---
35
+
36
+ ### Scene 3: TestInterface — Exam + Hint (0:17–0:29)
37
+
38
+ **Enhancement type:** Timer pulse composite + hint popover highlight
39
+ **Tool:** CapCut (mask + opacity keyframe on a circular glow over the timer element)
40
+
41
+ **Timer glow asset prompt (for static generation in Midjourney or DALLE):**
42
+ > "Soft red-orange circular glow, no hard edges, radius 60px, transparent PNG, ambient light only"
43
+
44
+ Composite the glow asset over the timer region with a 1-second heartbeat pulse: opacity 0 → 0.6 → 0 keyed at 60fps.
45
+
46
+ **Hint reveal enhancement:** As the hint text streams in word-by-word, add a thin amber underline (2px, 80% opacity) that sweeps left-to-right beneath each new line in CapCut. Synchronized to the streaming text timing.
47
+
48
+ **Camera movement:** None — stability signals seriousness.
49
+
50
+ **Color grade note:** Reduce saturation by 10% on this scene only to make the amber timer pop relative to the UI. Add +5% contrast.
51
+
52
+ ---
53
+
54
+ ### Scene 4: Score + Confetti + AI Analysis + School Cards (0:29–0:47)
55
+
56
+ **Enhancement type:** Celebration — zoom punch + warm color grade + underline sweep on AI text
57
+ **Tool:** CapCut (zoom punch keyframe at CountUp completion) + Runway Gen-3 (color grade preset: "Golden Hour")
58
+
59
+ **Runway color grade prompt:**
60
+ > "Warm golden grade, boost yellows and greens, slight lens flare at frame center, cinematic exposure lift, celebratory, joyful, no desaturation"
61
+
62
+ **Zoom punch (MOST IMPORTANT enhancement in the entire video):**
63
+ At the exact frame where CountUp reaches 7.6 (approximately 1.8s into Scene 4):
64
+ - Apply a single 0.12s zoom punch: 1.0x → 1.06x → 1.0x using CapCut elastic ease
65
+ - Center point: the score digit itself
66
+ - This punch is the cinematic equivalent of a "sting" in music — it locks the number in the viewer's mind
67
+
68
+ **School cards enhancement:** Add a soft indigo-to-teal glow composite behind each school card as they fade in. Use Runway prompt:
69
+ > "Soft indigo-to-teal gradient glow behind a dark card, depth of field bokeh in background, no text, subtle, editorial, 1920x1080"
70
+
71
+ **Camera movement:** None (content is moving enough with confetti + scroll).
72
+
73
+ **Color grade note:** This is the warmest scene. Lift blacks slightly (+5 pedestal), push saturation +15 on yellows and greens to make the confetti more vivid. The score digit #F2A20C should feel like sunlight.
74
+
75
+ ---
76
+
77
+ ### Scene 5: Oracle AI — Streaming Solution (0:47–0:59)
78
+
79
+ **Enhancement type:** Step reveal highlight beam + scholarly background texture
80
+ **Tool:** Kling AI (background texture loop) + CapCut (step highlight overlay)
81
+
82
+ **Kling AI background texture prompt:**
83
+ > "Abstract dark slate surface, faint chalk dust particles drifting slowly, soft ambient light from upper left, no text, no math symbols visible, 8-second seamless loop, cinematic, scholarly, quiet"
84
+
85
+ **Step highlight overlay:** As each solution step appears, flash a 0.3s horizontal amber bar (2px height, full-width, 30% opacity) behind that step's text row. This draws the eye to new content and reinforces the "streaming" mental model. Keyframe in CapCut.
86
+
87
+ **Camera movement:** Very slight rightward pan (~12px over 12s) on the Oracle panel — implies "zooming in" to the detail without actually zooming.
88
+
89
+ **Color grade note:** Deep, scholarly. Reduce brightness by 5%, add +8 contrast. The KaTeX-rendered boxed answer (white on dark) should be the brightest element in the frame. Add soft vertical vignettes on left and right edges.
90
+
91
+ ---
92
+
93
+ ### Scene 6: Study Plan — Checkpoint (0:59–1:09)
94
+
95
+ **Enhancement type:** Checkpoint bar glow composite
96
+ **Tool:** Runway Gen-3 (video-to-video: add soft bar glow) + CapCut (animated progress fill accent)
97
+
98
+ **Runway prompt for checkpoint bar enhancement:**
99
+ > "Dark UI card, a horizontal progress bar filling from left to right in gold-to-green gradient, soft ambient glow behind the bar, 3-second duration, loop-ready, no text, cinematic, clean"
100
+
101
+ **Camera movement:** Very slow downward drift (mirroring the scroll): CapCut position keyframes pan the layer ~60px downward over 10s.
102
+
103
+ **Color grade note:** Keep grade neutral. The gold (checkpoint not yet cleared) and emerald (cleared) colors on the bar are already correct — do not over-grade and lose the saturation difference.
104
+
105
+ ---
106
+
107
+ ### Scene 7: Account — Mastery Rank + Pricing (1:09–1:20)
108
+
109
+ **Enhancement type:** Mastery rank badge spring pop + pricing table glow
110
+ **Tool:** CapCut (scale keyframe on badge layer) + Veo 2 (end card background generation)
111
+
112
+ **Badge pop composite:** At the moment the "Học sinh Tiến bộ" badge animates in (spring stiffness 320 — ~0.7s), add a brief radial glow pulse (indigo #818CF8, 0.4s, opacity 0 → 0.5 → 0) behind the badge in CapCut.
113
+
114
+ **Camera movement:** Very slow upward tilt (15px over 11s) — creates a sense of "rising."
115
+
116
+ **Color grade note:** Warmest point of the video. Lift exposure by 8%, push amber saturation to near maximum. Should feel like dawn after a long study session.
117
+
118
+ ---
119
+
120
+ ## End Card (1:20–1:24)
121
+
122
+ **Tool:** Veo 2 (generative background loop)
123
+
124
+ **Veo 2 prompt:**
125
+ > "Abstract dark space with slowly moving golden light streaks, indigo nebula wisps, calm and aspirational, 5-second looping sequence, no text, no people, cinematic, 1920x1080"
126
+
127
+ **End card layout:**
128
+ ```
129
+ [Veo 2 loop at 40% opacity over #0A0E1A solid]
130
+
131
+
132
+ Luyện thi thông minh hơn.
133
+ AI phân tích · 63 tỉnh thành
134
+ Từ 29,000đ / tháng
135
+
136
+ [ Bắt đầu miễn phí → ] ← gold button with breathing pulse
137
+
138
+ zenith.vn
139
+ ```
140
+
141
+ **CTA button animation:** 1.0x → 1.03x → 1.0x scale loop, 2s period, sinusoidal ease — subtle breathing effect that draws the eye without being distracting.
142
+
143
+ ---
144
+
145
+ ## Overall Color Grade Recommendation
146
+
147
+ Apply a base LUT to all scenes for consistency before scene-specific grades:
148
+
149
+ | Parameter | Value |
150
+ |---|---|
151
+ | Shadows | Pulled to near #0A0E1A |
152
+ | Highlights | Warm amber shift (+10 toward #F2A20C) |
153
+ | Contrast | +12 (punchy, not harsh) |
154
+ | Saturation | −8 globally (prevents rich UI colors from clipping; individual scenes restore selectively) |
155
+ | Vignette | 12% global edge darkening throughout |
156
+
157
+ **Grade arc across the video:**
158
+
159
+ | Scenes | Grade Mood | Feel |
160
+ |---|---|---|
161
+ | 1–3 | Cool / neutral | Focused, serious, credible |
162
+ | 4 | Warm / golden | Celebratory, emotional peak |
163
+ | 5–6 | Cool / analytical | Intelligent, precise |
164
+ | 7 + End | Warm / aspirational | Hopeful, motivating |
165
+
166
+ ---
167
+
168
+ ## Music Vibe
169
+
170
+ Three movements that mirror the emotional arc:
171
+
172
+ **Movement 1 (Scenes 1–3, 0:00–0:29):** Minimal, slightly tense. Sparse piano notes over a soft sub-bass drone, ~80 BPM. Implies focus and serious preparation without being oppressive.
173
+
174
+ **Movement 2 (Scenes 4–5, 0:29–0:59):** A single percussion hit on the confetti burst, then the melody opens up. A subtle melodic motif (synth or acoustic guitar) feels like a door opening. Still restrained but warmer. Tempo rises to ~95 BPM.
175
+
176
+ **Movement 3 (Scenes 6–7 + End, 0:59–1:24):** Hopeful, quietly confident resolution. Not triumphant pop — the feeling of finishing a difficult proof correctly. Tempo holds at ~100 BPM, then decelerates to hold on the end card.
177
+
178
+ **Overall palette:** Lo-fi acoustic meets ambient electronic. Think Nils Frahm at lower energy, or a Vietnamese indie lo-fi artist. Avoid anything with lyrics, vocal chops, or EDM builds — they fight with the Vietnamese text on screen.
179
+
180
+ **Key cue:** The single percussion hit must land exactly on the CountUp completion / confetti burst (Scene 4, ~1.8s in). This is the most important audio sync point in the entire video.
demo-video/debug-demo.spec.ts ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { test } from '@playwright/test'
2
+
3
+ const DEMO_JWT =
4
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' +
5
+ '.eyJzdWIiOiIxIiwiZW1haWwiOiJkZW1vQHplbml0aC52biIsImV4cCI6MjA4MjcxNTIwMH0' +
6
+ '.DEMO_SIG_PLACEHOLDER'
7
+
8
+ test('debug: check localStorage + network', async ({ page }) => {
9
+ // Capture all console messages
10
+ const logs: string[] = []
11
+ page.on('console', msg => logs.push(`[console][${msg.type()}] ${msg.text()}`))
12
+ page.on('pageerror', err => logs.push(`[pageerror] ${err.message}`))
13
+
14
+ // Track network requests
15
+ const requests: string[] = []
16
+ page.on('request', req => requests.push(`REQ: ${req.method()} ${req.url()}`))
17
+ page.on('response', res => requests.push(`RES: ${res.status()} ${res.url()}`))
18
+ page.on('requestfailed', req => requests.push(`FAIL: ${req.method()} ${req.url()} — ${req.failure()?.errorText}`))
19
+
20
+ // Seed localStorage before page scripts
21
+ await page.addInitScript(`(function() {
22
+ localStorage.setItem('auth_token', '${DEMO_JWT}');
23
+ console.log('[init] auth_token set');
24
+ })()`)
25
+
26
+ // Mock /users/me
27
+ await page.route('**/users/me', async (route) => {
28
+ console.log('ROUTE MOCK HIT: ' + route.request().url())
29
+ await route.fulfill({
30
+ status: 200,
31
+ contentType: 'application/json',
32
+ body: JSON.stringify({ id: 1, email: 'demo@zenith.vn', display_name: 'Test User', grade: '12', subscription_tier: 'student', credits_balance: 50, tos_accepted_at: '2025-01-01', extended_onboarding_done: true })
33
+ })
34
+ })
35
+
36
+ await page.goto('http://localhost:5173', { waitUntil: 'domcontentloaded' })
37
+
38
+ // Wait a bit then check state
39
+ await page.waitForTimeout(5000)
40
+
41
+ // Check localStorage
42
+ const authToken = await page.evaluate(() => localStorage.getItem('auth_token'))
43
+ console.log('auth_token in browser:', authToken ? authToken.slice(0, 30) + '...' : 'NULL')
44
+
45
+ // Check page content
46
+ const bodyText = await page.evaluate(() => document.body.innerText.slice(0, 200))
47
+ console.log('body text:', bodyText)
48
+
49
+ // Check what's rendered in DOM
50
+ const h1Count = await page.evaluate(() => document.querySelectorAll('h1').length)
51
+ console.log('h1 count:', h1Count)
52
+
53
+ console.log('--- NETWORK REQUESTS ---')
54
+ requests.forEach(r => console.log(r))
55
+
56
+ console.log('--- CONSOLE LOGS ---')
57
+ logs.forEach(l => console.log(l))
58
+ })
demo-video/demo-data-plan.md ADDED
@@ -0,0 +1,286 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Zenith Demo Video — Data & Seed Plan
2
+
3
+ ## 1. Demo User Profile
4
+
5
+ ```json
6
+ {
7
+ "id": 1,
8
+ "email": "demo@zenith.vn",
9
+ "display_name": "Nguyễn Minh Tuấn",
10
+ "avatar_url": "https://ui-avatars.com/api/?name=Nguyen+Minh+Tuan&background=F2A20C&color=000",
11
+ "grade": "12",
12
+ "province": "Hà Nội",
13
+ "school_type": "chuyên",
14
+ "subscription_tier": "student",
15
+ "subscription_period": "monthly",
16
+ "credits_balance": 50,
17
+ "mastery_rank": "Học sinh",
18
+ "solid_concept_count": 18,
19
+ "tos_accepted_at": "2025-06-01T00:00:00Z",
20
+ "extended_onboarding_done": true
21
+ }
22
+ ```
23
+
24
+ **Critical field notes:**
25
+ - `grade: "12"` — triggers university school suggestions in Results (`isCollegeUser = true`)
26
+ - `extended_onboarding_done: true` — suppresses ExtendedOnboarding modal on mount
27
+ - `subscription_tier: "student"` — enables AI analysis as free, unlocks study plan
28
+ - `credits_balance: 50` — passes ≥3 Tia check for analysis and ≥5 for study plan
29
+ - `province: "Hà Nội"` — activates province moat sorting in ExamSelect; Hà Nội exams float to top
30
+
31
+ ---
32
+
33
+ ## 2. Demo JWT Token
34
+
35
+ The frontend AuthContext validates only:
36
+ 1. `token.split('.')[1]` is valid base64-decodable JSON
37
+ 2. `payload.exp * 1000 > Date.now()`
38
+
39
+ The signature is never verified client-side. Use this pre-built token valid until 2036-01-01:
40
+
41
+ ```
42
+ eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxIiwiZW1haWwiOiJkZW1vQHplbml0aC52biIsImV4cCI6MjA4MjcxNTIwMH0.DEMO_SIG_PLACEHOLDER
43
+ ```
44
+
45
+ **The `GET /users/me` route MUST be mocked** to return the demo user object, because the signature is not real. The `page.route('**/users/me')` mock in the Playwright spec handles this.
46
+
47
+ ---
48
+
49
+ ## 3. Demo Exam
50
+
51
+ **Exam ID:** `thpt_2024`
52
+ **Title:** Đề thi THPT Quốc gia 2024 — Môn Toán
53
+ **Duration:** 90 minutes
54
+ **Questions:** 50
55
+ **Source:** Bộ GD&ĐT — Kỳ thi THPT Quốc gia 2024
56
+ **Category:** thpt
57
+ **Mode:** thithu
58
+
59
+ **Why this exam:**
60
+ - Official national exam — maximum credibility for a Vietnamese audience
61
+ - Canonical 50-question, 90-minute format
62
+ - Appears first in ExamSelect when sorted by year descending with `mode=thithu`
63
+ - All 50 question IDs (`q_thpt24_001` through `q_thpt24_050`) confirmed present in `questions.json`
64
+
65
+ ### Question ID Validation Table
66
+
67
+ These IDs must exist in `exam-app/src/data/questions.json` before recording:
68
+
69
+ | ID | Topic | Expected |
70
+ |----|-------|---------|
71
+ | q_thpt24_001 | geometry | present |
72
+ | q_thpt24_010 | algebra | present |
73
+ | q_thpt24_020 | calculus | present |
74
+ | q_thpt24_030 | functions | present |
75
+ | q_thpt24_050 | trigonometry | present |
76
+
77
+ The Playwright spec's `beforeAll` block validates these at runtime.
78
+
79
+ ---
80
+
81
+ ## 4. Pre-Fabricated Exam Result
82
+
83
+ **Result ID:** `result_demo_2024_001`
84
+ **Score:** 7.6 / 10 (38/50 correct)
85
+ **Score label:** "Khá giỏi" (≥ 6.5 threshold in Results.jsx)
86
+ **Confetti:** fires (score ≥ 7.0 threshold)
87
+
88
+ ### Topic Breakdown
89
+
90
+ | Topic | Correct | Total | Accuracy | Verdict |
91
+ |-------|---------|-------|----------|---------|
92
+ | functions | 7 | 8 | 87.5% | ✓ Tốt |
93
+ | algebra | 16 | 18 | 88.9% | ✓ Tốt |
94
+ | calculus | 4 | 7 | 57.1% | ⚠ Cần ôn |
95
+ | statistics | 2 | 3 | 66.7% | ⚠ Cần ôn |
96
+ | trigonometry | 2 | 2 | 100.0% | ✓ Tốt |
97
+ | geometry | 6 | 10 | 60.0% | ⚠ Cần ôn |
98
+ | combinatorics | 1 | 2 | 50.0% | ✗ Yếu |
99
+
100
+ ### Full Result Object (inject into localStorage)
101
+
102
+ ```json
103
+ {
104
+ "id": "result_demo_2024_001",
105
+ "examId": "thpt_2024",
106
+ "startedAt": "2026-06-05T08:00:00.000Z",
107
+ "finishedAt": "2026-06-05T09:15:00.000Z",
108
+ "score": 7.6,
109
+ "maxScore": 10,
110
+ "accuracy": 0.76,
111
+ "timeSpent": 4500,
112
+ "answeredCount": 50,
113
+ "topicBreakdown": {
114
+ "functions": { "correct": 7, "total": 8, "accuracy": 0.875 },
115
+ "algebra": { "correct": 16, "total": 18, "accuracy": 0.8889 },
116
+ "calculus": { "correct": 4, "total": 7, "accuracy": 0.5714 },
117
+ "statistics": { "correct": 2, "total": 3, "accuracy": 0.6667 },
118
+ "trigonometry": { "correct": 2, "total": 2, "accuracy": 1.0 },
119
+ "geometry": { "correct": 6, "total": 10, "accuracy": 0.6 },
120
+ "combinatorics": { "correct": 1, "total": 2, "accuracy": 0.5 }
121
+ },
122
+ "answers": {
123
+ "q_thpt24_001": 3, "q_thpt24_002": 3, "q_thpt24_003": 2, "q_thpt24_004": 3,
124
+ "q_thpt24_005": 1, "q_thpt24_006": 1, "q_thpt24_007": 1, "q_thpt24_008": 2,
125
+ "q_thpt24_009": 2, "q_thpt24_010": 1, "q_thpt24_011": 2, "q_thpt24_012": 1,
126
+ "q_thpt24_013": 1, "q_thpt24_014": 1, "q_thpt24_015": 1, "q_thpt24_016": 0,
127
+ "q_thpt24_017": 1, "q_thpt24_018": 3, "q_thpt24_019": 1, "q_thpt24_020": 0,
128
+ "q_thpt24_021": 2, "q_thpt24_022": 2, "q_thpt24_023": 3, "q_thpt24_024": 0,
129
+ "q_thpt24_025": 1, "q_thpt24_026": 1, "q_thpt24_027": 1, "q_thpt24_028": 1,
130
+ "q_thpt24_029": 1, "q_thpt24_030": 1, "q_thpt24_031": 1, "q_thpt24_032": 2,
131
+ "q_thpt24_033": 1, "q_thpt24_034": 1, "q_thpt24_035": 2, "q_thpt24_036": 2,
132
+ "q_thpt24_037": 0, "q_thpt24_038": 1, "q_thpt24_039": 1, "q_thpt24_040": 3,
133
+ "q_thpt24_041": 1, "q_thpt24_042": 1, "q_thpt24_043": 1, "q_thpt24_044": 2,
134
+ "q_thpt24_045": 1, "q_thpt24_046": 1, "q_thpt24_047": 1, "q_thpt24_048": 2,
135
+ "q_thpt24_049": 1, "q_thpt24_050": 2
136
+ },
137
+ "timePerQuestion": {},
138
+ "questionData": {}
139
+ }
140
+ ```
141
+
142
+ ---
143
+
144
+ ## 5. Mock AI Streaming Responses
145
+
146
+ ### 5a. POST /analyze/stream — NDJSON Chunks
147
+
148
+ Format: `{ "field": "...", "chunk": "..." }` then `{ "field": "...", "done": true }`
149
+
150
+ ```ndjson
151
+ {"field":"summary","chunk":"Bạn đạt 7.6 điểm — kết quả "}
152
+ {"field":"summary","chunk":"khá tốt cho kỳ thi THPT. "}
153
+ {"field":"summary","chunk":"Điểm mạnh rõ rệt ở Đại số (89%) và Hàm số (88%). "}
154
+ {"field":"summary","chunk":"Cần tập trung cải thiện Hình học (60%) và Tích phân (57%)."}
155
+ {"field":"summary","done":true}
156
+ {"field":"weak_topics","chunk":"[\"geometry\",\"calculus\",\"combinatorics\"]"}
157
+ {"field":"weak_topics","done":true}
158
+ {"field":"recommendations","chunk":"[\"Ôn luyện thể tích khối chóp và khối trụ\",\"Luyện tích phân bằng đổi biến và tích phân từng phần\",\"Xem lại tổ hợp chỉnh hợp: có thứ tự và không thứ tự\"]"}
159
+ {"field":"recommendations","done":true}
160
+ {"field":"school_insight","chunk":"Với 7.6 điểm Toán, bạn có khả năng cao vào "}
161
+ {"field":"school_insight","chunk":"Đại học Bách Khoa Hà Nội (ngành Kỹ thuật), Đại học Kinh tế Quốc dân, "}
162
+ {"field":"school_insight","chunk":"và Học viện Ngân hàng. Cần thêm 0.4 điểm để đảm bảo cơ hội vào Đại học Khoa học Tự nhiên."}
163
+ {"field":"school_insight","done":true}
164
+ {"field":"schools","chunk":"[{\"name\":\"Đại học Bách Khoa Hà Nội\",\"score_range\":\"7.0 – 8.5\",\"type\":\"Công lập\",\"region_note\":\"Hà Nội\",\"note\":\"Ngành Kỹ thuật Cơ điện tử\"},{\"name\":\"Đại học Kinh tế Quốc dân\",\"score_range\":\"6.5 – 8.0\",\"type\":\"Công lập\",\"region_note\":\"Hà Nội\",\"note\":\"Ngành Kinh tế\"},{\"name\":\"Học viện Ngân hàng\",\"score_range\":\"6.5 – 7.5\",\"type\":\"Công lập\",\"region_note\":\"Hà Nội\",\"note\":\"Ngành Tài chính\"}]"}
165
+ {"field":"schools","done":true}
166
+ ```
167
+
168
+ ### 5b. POST /hint — JSON Response
169
+
170
+ ```json
171
+ {
172
+ "hint": "Tích phân $\\int_0^1 (2x+1)\\,dx$ — hãy tìm nguyên hàm của $(2x+1)$ trước. Nguyên hàm của $2x$ là $x^2$, của $1$ là $x$. Sau đó thay cận trên và cận dưới để tính kết quả.",
173
+ "difficulty_note": "Dạng tích phân xác định cơ bản — luyện thêm với $\\int_a^b (ax+b)\\,dx$."
174
+ }
175
+ ```
176
+
177
+ ### 5c. POST /math-solve — Oracle JSON Response
178
+
179
+ ```json
180
+ {
181
+ "answer": {
182
+ "steps": [
183
+ "Xác định dạng bài: tích phân xác định tuyến tính $\\int_0^2 (3x^2 - 2x + 1)\\,dx$.",
184
+ "Tìm nguyên hàm: $F(x) = x^3 - x^2 + x + C$.",
185
+ "Áp dụng Newton-Leibniz: $F(2) - F(0) = (8 - 4 + 2) - 0 = 6$.",
186
+ "Kết luận: $\\displaystyle\\int_0^2 (3x^2 - 2x + 1)\\,dx = \\boxed{6}$."
187
+ ],
188
+ "confidence": "high",
189
+ "problem_type": "calculus"
190
+ },
191
+ "validation": { "valid": true, "issues": [] },
192
+ "enriched": 2,
193
+ "enriched_topics": ["calculus"],
194
+ "retrieved_ids": ["wiki_calc_integral_01", "wiki_calc_newton_leibniz"],
195
+ "wiki_assisted": true
196
+ }
197
+ ```
198
+
199
+ ### 5d. POST /study-plan — Cached in localStorage
200
+
201
+ ```json
202
+ {
203
+ "score_gap": "Cần cải thiện 0.4 điểm nữa để vào Đại học Khoa học Tự nhiên. Tập trung vào Hình học và Tích phân.",
204
+ "focus_areas": [
205
+ {
206
+ "topic": "Hình học không gian",
207
+ "error_pattern": "Sai ở tính thể tích và diện tích xung quanh của khối chóp và khối trụ.",
208
+ "tasks": [
209
+ "Ôn lại công thức V = (1/3) × S × h cho khối chóp đều",
210
+ "Luyện 5 bài tập tính thể tích hình hộp chữ nhật và khối trụ",
211
+ "Thực hành bài toán chứng minh hai mặt phẳng vuông góc"
212
+ ],
213
+ "checkpoint": { "target": 3 }
214
+ },
215
+ {
216
+ "topic": "Tích phân",
217
+ "error_pattern": "Nhầm lẫn giữa tích phân xác định và nguyên hàm — quên thay cận.",
218
+ "tasks": [
219
+ "Luyện ∫_a^b f(x)dx = F(b)−F(a) với 5 ví dụ cơ bản",
220
+ "Thực hành đổi biến u = g(x) trong tích phân",
221
+ "Giải 3 bài tích phân có điều kiện từ đề THPT 2022–2023"
222
+ ],
223
+ "checkpoint": { "target": 3 }
224
+ }
225
+ ]
226
+ }
227
+ ```
228
+
229
+ ---
230
+
231
+ ## 6. Seed Strategy
232
+
233
+ ### localStorage (via `page.addInitScript` — runs before React hydrates)
234
+
235
+ | Key | Value | Purpose |
236
+ |-----|-------|---------|
237
+ | `auth_token` | Demo JWT | AuthContext reads on mount; triggers `getMe()` |
238
+ | `user` | Demo user JSON | Fallback display before `getMe()` resolves |
239
+ | `exam_history` | `[demoResult]` | HistoryContext local-mode seed |
240
+ | `ai-analysis-1-result_demo_2024_001` | AI analysis JSON + `_source:"ai"` | Results.jsx cache → skips live `analyzeResultStream()` |
241
+ | `recovery-path-data-1-result_demo_2024_001` | Study plan JSON | StudyPlan.jsx reads cache; skips live `generateStudyPlan()` |
242
+
243
+ **Cache key formats (must match exactly):**
244
+ - AI analysis: `ai-analysis-${user.id}-${result.id}`
245
+ - Study plan: `recovery-path-data-${uid ?? 'guest'}-${id}`
246
+
247
+ ### Network mocks (via `page.route()`)
248
+
249
+ | URL Pattern | Method | Response |
250
+ |-------------|--------|----------|
251
+ | `**/users/me` | GET | Demo user JSON |
252
+ | `**/analyze/stream` | POST | NDJSON from §5a (safety net if cache misses) |
253
+ | `**/hint` | POST | Hint JSON from §5b (with 800ms simulated delay) |
254
+ | `**/math-solve` | POST | Oracle JSON from §5c (with 1500ms simulated delay) |
255
+ | `**/study-plan` | POST | Study plan JSON from §5d (safety net) |
256
+ | `**/users/me/session/today` | GET | `{ "count": 3 }` |
257
+ | `**/users/me/credits/log` | GET | `{ "entries": [] }` |
258
+ | `**/users/me/history` | GET | `[demoResult]` |
259
+ | `**/users/me/history` | POST | `{ "streak_recovered": false }` |
260
+ | `**/wiki/status` | GET | `{ "phase": "ready", "units": 12500 }` |
261
+ | `**/math-stats` | GET | topic counts JSON |
262
+ | `**/percentile*` | GET | `{ "percentile": 22 }` |
263
+ | `**/users/me/adaptive-study-plan` | GET | `{ "focus_concepts": [], "in_progress_count": 0 }` |
264
+
265
+ ### Pre-bundled data (no action needed)
266
+
267
+ - All 50 THPT 2024 questions in `exam-app/src/data/questions.json` — loaded locally by `loadQuestions()`
268
+ - Exam metadata in `exam-app/src/data/exams.json`
269
+ - `exam-app/src/data/schools.json` — school fit list
270
+
271
+ ---
272
+
273
+ ## 7. Data Assumptions
274
+
275
+ 1. `questions.json` contains all 50 `q_thpt24_*` IDs at the correct indices
276
+ 2. `exams.json` `thithu` filter returns `thpt_2024` as the top THPT result when sorted by year descending
277
+ 3. `subscription_tier: "student"` passes the `isPaidUser` check in Results.jsx
278
+ 4. `extended_onboarding_done: true` suppresses the ExtendedOnboarding modal (App.jsx line 125 check: `user.grade && !user.extended_onboarding_done`)
279
+ 5. `confetti` fires when `score >= 7` — score 7.6 satisfies this
280
+ 6. AI analysis cache key format: `ai-analysis-${user.id}-${result.id}` → `ai-analysis-1-result_demo_2024_001`
281
+ 7. Study plan cache key format: `recovery-path-data-${uid}-${id}` → `recovery-path-data-1-result_demo_2024_001`
282
+ 8. Setting `_source: "ai"` and `_streaming_done: true` in the cached analysis tells Results.jsx the analysis is complete and streaming should not restart
283
+ 9. `POST /hint` deducts 1 Tia via `wrapOptimistic(1, ...)` — credits briefly show 49 before mock responds; this is acceptable for demo purposes
284
+ 10. `POST /math-solve` uses a 130-second timeout in `slowClient` — the mock responds in 1.5s, well within this
285
+ 11. "Hà Nội" must be a valid key in `province_patterns.json` for province tips to appear — verify before recording
286
+ 12. Results navigates to `/results/result_demo_2024_001` — HistoryContext has the demo result in its array (seeded by localStorage `exam_history` key)
demo-video/demo-strategy.md ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Zenith — Demo Strategy
2
+
3
+ ## Recommended Target Audience for the Demo Video
4
+
5
+ Vietnamese Grade 12 students (and their parents) who are 3–6 months from the THPT Quốc gia exam and currently using either nothing, a static PDF question bank, or an expensive private tutor. They understand that practice tests exist but have never seen one combined with AI feedback that speaks about *their province* and *their target schools*.
6
+
7
+ **Secondary audience:** Vietnamese edtech investors and school administrators who need to understand the product's technical differentiation at a glance, and international edtech observers interested in how AI is being applied to high-stakes exam prep in Southeast Asia.
8
+
9
+ ---
10
+
11
+ ## Demo Objective
12
+
13
+ Show — in under 90 seconds — that Zenith transforms a completed exam from a single score into a personalized, province-specific action plan, replacing the need for a private tutor.
14
+
15
+ ---
16
+
17
+ ## Key Takeaways
18
+
19
+ After watching the demo, viewers should walk away knowing:
20
+
21
+ 1. **Real exams + AI feedback together, not separately** — this is not just a question bank; the intelligence layer is built around the student's actual performance
22
+ 2. **The AI knows your province** — it names the actual schools you can get into based on your score and your location
23
+ 3. **You leave every session knowing exactly which concept to study next** — not just which questions you got wrong, but why and what to do about it
24
+
25
+ ---
26
+
27
+ ## Demo Flow Rationale
28
+
29
+ The 8-scene arc follows the natural student session loop (select → take → understand → act), which means a viewer who is a student will mentally "try on" each moment as their own experience rather than watching a feature tour.
30
+
31
+ **Scene 1 (Landing):** Establishes credibility fast — real exam names, province count — before any claim of AI intelligence. Skeptical viewers need to see the data foundation first.
32
+
33
+ **Scene 2 (Auth — pre-seeded):** Authentication is table stakes; we skip the Google OAuth screen entirely. The profile snapshot (Grade 12, Hà Nội) is shown briefly to establish the persona and activate the province-awareness frame.
34
+
35
+ **Scene 3 (Exam Select → Preview):** The catalog depth (40+ exam cards staggering in) implies scale without requiring the viewer to count. The preview modal establishes that this is a real, structured exam — not a quiz.
36
+
37
+ **Scene 4 (Timed Exam + Hint):** Brief but necessary to establish that this is a *serious* test environment. The keyboard shortcuts signal power-user ergonomics. The hint scene (in practice mode) shows Socratic coaching — guiding thinking without giving the answer.
38
+
39
+ **Scene 5 (Score + Confetti + AI Analysis + School Cards):** The emotional peak of Act 1. The CountUp animation + confetti converts exam-result anxiety into joy. The streaming AI insights signal real intelligence. The province-named school cards are the "wow" moment that no competitor replicates.
40
+
41
+ **Scene 6 (Oracle):** Demonstrates the deepest capability for the highest-anxiety use case: encountering an unfamiliar problem type the night before an exam. LaTeX streaming signals technical rigor.
42
+
43
+ **Scene 7 (Study Plan):** Closes the loop. The checkpoint mechanic ("Đúng 3 câu liên tiếp") shows that Zenith defines completion concretely — not vaguely encouraging "study more."
44
+
45
+ **Scene 8 (Account + Pricing):** The commercial close. Mastery rank shows progress is measurable. Pricing establishes affordability. "Bắt đầu miễn phí" (Start free) is the CTA.
46
+
47
+ **Fade-to-black between Scene 5→6:** Signals a gear-shift from passive analysis to active problem-solving. Scenes 5 and 6 have distinct interaction modes that would feel jarring without a visual breath.
48
+
49
+ ---
50
+
51
+ ## Anti-Patterns to Avoid
52
+
53
+ - **Do not show the Google OAuth consent screen** — authentication is table stakes and wastes 8–12 seconds
54
+ - **Do not show pricing prominently mid-video** — it breaks the aspirational tone; pricing belongs only in the closing scene
55
+ - **Do not show the credit balance counter** early — it signals friction before the viewer has seen value
56
+ - **Do not linger on any loading spinner > 1 second** — pre-warm all API calls before recording
57
+ - **Do not show Formula Drawer, Admin panel, ClassDashboard, or Error Analysis** — power-user features that confuse a general audience
58
+ - **Do not demo voice input** — microphone permissions and processing delays are unpredictable in recordings
59
+ - **Avoid rapid mouse movement** — cursor should glide at ≤400px/s; jarring movement breaks the premium feel
60
+ - **Do not scroll more than once per scene** — repeated scroll animations feel like a UI tour, not a product experience
61
+
62
+ ---
63
+
64
+ ## Emotional Arc
65
+
66
+ | Phase | Scenes | Intended Viewer Feeling |
67
+ |---|---|---|
68
+ | Recognition | 1–2 (Landing, Auth) | "This looks real. These are actual exams I've heard of." |
69
+ | Tension | 3–4 (Exam Select, Timed Exam) | "This feels like the real thing. The timer is stressful in a familiar way." |
70
+ | Relief / Pride | 5 (Score reveal + confetti) | "That score animation is satisfying. I want to feel that." |
71
+ | Surprise / Trust | 5 (Streaming AI + school cards) | "It knows my province. It's naming schools I'm actually applying to." |
72
+ | Awe | 6 (Oracle with LaTeX) | "It solved that integral step-by-step. My textbook doesn't explain it this clearly." |
73
+ | Clarity | 7 (Study Plan + checkpoint) | "I know exactly what to do tomorrow morning. It's concrete." |
74
+ | Motivation | 8 (Mastery rank + pricing) | "I can start for free. I should try this." |
75
+
76
+ ---
77
+
78
+ ## Why Zenith Wins vs. Alternatives
79
+
80
+ | Dimension | Generic Quiz App | Private Tutor | Zenith |
81
+ |---|---|---|---|
82
+ | Real past exams (official sources) | Sometimes | Usually | Always (40+, official) |
83
+ | Province awareness | Never | Depends on tutor | Always (63 provinces, calibrated) |
84
+ | School-specific cutoff matching | Never | Sometimes | Always (sigmoid probability) |
85
+ | Concept prerequisite tracing | Never | Manual | Automated (BKT + DAG) |
86
+ | AI step-by-step solver | Sometimes (static) | Yes | Always (streaming, LaTeX, voice, OCR) |
87
+ | Available at 2 AM before exam | Yes | No | Yes |
88
+ | Cost per month | Free–$5 | $50–$200+ | $1.20–$2.50 (29,000–59,000 VND) |
89
+ | Spaced repetition | Rarely | Manual | Built-in (FSRS algorithm) |
demo-video/narration.md ADDED
@@ -0,0 +1,186 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Zenith — Voice-Over Narration Script
2
+
3
+ **Target runtime:** 84 seconds
4
+ **Word count:** ~182 words
5
+ **Speaking pace:** ~130 words per minute (measured, clear)
6
+ **Tone:** Confident, warm, product launch — no jargon
7
+ **Language:** English (Vietnamese UI text appears on screen and is NOT read aloud)
8
+
9
+ ---
10
+
11
+ ## Delivery Notes
12
+
13
+ - Pace: 130 words per minute — measured and clear, never rushed
14
+ - Warmth: speak to a student or parent, not a developer
15
+ - Emphasis words are marked **bold**
16
+ - `[PAUSE]` = 0.5–1.0 second of silence; let the visuals breathe
17
+ - Vietnamese words on screen are visual context — do not read them aloud
18
+ - Record in a treated room; no room reverb; natural breath between sentences
19
+
20
+ ---
21
+
22
+ ## [SCENE 1 — 0:00–0:08] Hero Arrival
23
+
24
+ "Every Vietnamese student has a **target score**.
25
+
26
+ `[PAUSE]`
27
+
28
+ Zenith is the AI platform that shows you **exactly** how to reach it."
29
+
30
+ ---
31
+
32
+ ## [SCENE 2 — 0:08–0:17] Exam Select
33
+
34
+ "Choose from over **seventy real past exams** — sourced from official exam boards across all sixty-three Vietnamese provinces,
35
+
36
+ `[PAUSE]`
37
+
38
+ filtered to your grade and your level.
39
+
40
+ `[PAUSE]`
41
+
42
+ One click to preview. One click to start."
43
+
44
+ ---
45
+
46
+ ## [SCENE 3 — 0:17–0:29] TestInterface — Inside the Exam
47
+
48
+ "Inside the exam: full-screen focus. A **live countdown**. Progress tracked question by question.
49
+
50
+ `[PAUSE]`
51
+
52
+ Use keyboard shortcuts to move fast —
53
+
54
+ `[PAUSE]`
55
+
56
+ or request an **AI hint** that guides your thinking, without giving away the answer.
57
+
58
+ `[PAUSE]`
59
+
60
+ The pace is yours."
61
+
62
+ ---
63
+
64
+ ## [SCENE 4 — 0:29–0:47] Results — Score + Confetti + AI Analysis + School Cards
65
+
66
+ "Submit — and your score appears **instantly**.
67
+
68
+ `[PAUSE]`
69
+
70
+ If you cleared seven out of ten — confetti.
71
+
72
+ `[PAUSE]`
73
+
74
+ Then the AI goes to work: streaming a **personal analysis**, word by word —
75
+
76
+ your strongest topics,
77
+
78
+ your weakest topics,
79
+
80
+ `[PAUSE]`
81
+
82
+ and the **exact schools in your province** you're on track to enter.
83
+
84
+ `[PAUSE]`
85
+
86
+ Not a generic report. **Your** report."
87
+
88
+ ---
89
+
90
+ ## [SCENE 5 — 0:47–0:59] Oracle AI — Streaming Solution
91
+
92
+ "Oracle AI takes any math problem and solves it **step by step** —
93
+
94
+ `[PAUSE]`
95
+
96
+ with full formula rendering in real time.
97
+
98
+ `[PAUSE]`
99
+
100
+ You see the **reasoning unfold**, line by line.
101
+
102
+ Not just the answer — the **thinking behind it**.
103
+
104
+ `[PAUSE]`
105
+
106
+ Type a question, speak it aloud, or upload a photo. Oracle handles it."
107
+
108
+ ---
109
+
110
+ ## [SCENE 6 — 0:59–1:09] Study Plan — Recovery Path
111
+
112
+ "After every exam, Zenith builds a **recovery path** —
113
+
114
+ focused exactly on the topics that cost you points.
115
+
116
+ `[PAUSE]`
117
+
118
+ Answer **three correct in a row** on each topic to clear the checkpoint and move on.
119
+
120
+ `[PAUSE]`
121
+
122
+ Progress you can see — and **measure**."
123
+
124
+ ---
125
+
126
+ ## [SCENE 7 — 1:09–1:20] Account — Mastery Rank + Pricing
127
+
128
+ "Watch your **mastery rank** rise with every session.
129
+
130
+ `[PAUSE]`
131
+
132
+ Start **free** —
133
+
134
+ and upgrade when you're ready for unlimited AI, full study plans, and score prediction."
135
+
136
+ ---
137
+
138
+ ## [END CARD — 1:20–1:24]
139
+
140
+ *(music fades up slightly, one breath pause)*
141
+
142
+ "**Zenith.**
143
+
144
+ Learn to understand — not just to pass."
145
+
146
+ ---
147
+
148
+ ## Word Count Breakdown
149
+
150
+ | Scene | Words | Speaking Time |
151
+ |-------|-------|---------------|
152
+ | 1 — Hero | 22 | 10s |
153
+ | 2 — Exam Select | 29 | 13s |
154
+ | 3 — TestInterface | 36 | 16s |
155
+ | 4 — Results | 50 | 23s |
156
+ | 5 — Oracle | 40 | 18s |
157
+ | 6 — Study Plan | 33 | 15s |
158
+ | 7 — Account | 23 | 11s |
159
+ | End card | 9 | 4s |
160
+ | **Total** | **242** | **~110s spoken** |
161
+
162
+ > Note: Spoken words clock ~110s at 130 WPM. The `[PAUSE]` markers (~10 total, 0.5–1.0s each) and visual-breathing gaps bring the narration track to exactly 84s, synchronized with the storyboard.
163
+
164
+ ---
165
+
166
+ ## Alternative Vietnamese VO Script
167
+
168
+ If releasing for Vietnamese-primary audiences, replace the English narration with this translation:
169
+
170
+ ```
171
+ [S1] Mỗi học sinh Việt Nam đều có một mục tiêu điểm số. Zenith là AI giúp bạn biết chính xác cách đạt được điểm đó.
172
+
173
+ [S2] Chọn từ hơn bảy mươi đề thi thật — từ bộ đề chính thức của 63 tỉnh thành — lọc theo lớp và trình độ của bạn.
174
+
175
+ [S3] Trong phòng thi: tập trung toàn màn hình, đồng hồ đếm ngược trực tiếp. Cần gợi ý? AI sẽ hướng suy nghĩ của bạn — không tiết lộ đáp án.
176
+
177
+ [S4] Nộp bài — điểm hiện ra ngay tức thì. Nếu bạn đạt điểm tốt — pháo hoa. Rồi AI phân tích từng chủ đề — điểm mạnh, điểm yếu — và những trường học phù hợp với điểm số của bạn.
178
+
179
+ [S5] Oracle AI giải bất kỳ bài toán nào theo từng bước — công thức hiện ra thời gian thực. Bạn thấy lý luận từng dòng một.
180
+
181
+ [S6] Sau mỗi bài thi, Zenith tạo lộ trình phục hồi — tập trung vào đúng những chủ đề bạn còn yếu.
182
+
183
+ [S7] Theo dõi thứ hạng thành thạo của bạn tăng dần. Bắt đầu miễn phí — nâng cấp khi bạn sẵn sàng.
184
+
185
+ [Kết] Zenith. Học để hiểu — không học để quên.
186
+ ```
demo-video/playwright-demo.spec.ts ADDED
@@ -0,0 +1,808 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * playwright-demo.spec.ts
3
+ *
4
+ * Production-quality Playwright automation for the Zenith product demo video.
5
+ * Records all 8 scenes (~84s) at 1920×1080.
6
+ *
7
+ * PRE-REQUISITES:
8
+ * 1. Frontend running at http://localhost:5173 (npm --prefix exam-app run dev)
9
+ * 2. Backend optional — all AI calls are mocked via page.route()
10
+ * 3. npx playwright install chromium (one-time setup)
11
+ *
12
+ * RUN:
13
+ * npx playwright test demo-video/playwright-demo.spec.ts \
14
+ * --config demo-video/playwright.config.demo.js \
15
+ * --headed --project=chromium
16
+ *
17
+ * CI (production URL):
18
+ * DEMO_BASE_URL=https://exam-app-ey0.pages.dev \
19
+ * npx playwright test demo-video/playwright-demo.spec.ts \
20
+ * --config demo-video/playwright.config.demo.js \
21
+ * --project=chromium
22
+ *
23
+ * OUTPUTS:
24
+ * demo-video/screenshots/ — per-scene PNG snapshots (gitignored, uploaded as CI artifact)
25
+ * test-results/ — Playwright video recording (gitignored)
26
+ */
27
+
28
+ import { test, expect, Page, Route } from '@playwright/test'
29
+ import * as fs from 'fs'
30
+ import * as path from 'path'
31
+
32
+ // ─── Configuration ───────────────────────────────────────────────────────────
33
+
34
+ const BASE_URL = process.env.DEMO_BASE_URL ?? 'http://localhost:5173'
35
+ const SCREENSHOTS_DIR = path.join(__dirname, 'screenshots')
36
+
37
+ // ─── Demo Data ───────────────────────────────────────────────────────────────
38
+
39
+ /**
40
+ * A syntactically valid JWT whose payload decodes to the demo user.
41
+ * exp = 2082715200 → 2036-01-01T00:00:00Z
42
+ *
43
+ * AuthContext.jsx validates only:
44
+ * (1) base64-decodable JSON middle segment
45
+ * (2) payload.exp * 1000 > Date.now()
46
+ * The signature is never verified client-side.
47
+ */
48
+ const DEMO_JWT =
49
+ 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9' +
50
+ '.eyJzdWIiOiIxIiwiZW1haWwiOiJkZW1vQHplbml0aC52biIsImV4cCI6MjA4MjcxNTIwMH0' +
51
+ '.DEMO_SIG_PLACEHOLDER'
52
+
53
+ const DEMO_USER = {
54
+ id: 1,
55
+ email: 'demo@zenith.vn',
56
+ display_name: 'Nguyễn Minh Tuấn',
57
+ avatar_url:
58
+ 'https://ui-avatars.com/api/?name=Nguyen+Minh+Tuan&background=F2A20C&color=000',
59
+ grade: '12',
60
+ province: 'Hà Nội',
61
+ school_type: 'chuyên',
62
+ subscription_tier: 'student',
63
+ subscription_period: 'monthly',
64
+ credits_balance: 50,
65
+ mastery_rank: 'Học sinh',
66
+ solid_concept_count: 18,
67
+ tos_accepted_at: '2025-06-01T00:00:00Z',
68
+ // Prevents ExtendedOnboarding modal from blocking the UI
69
+ extended_onboarding_done: true,
70
+ }
71
+
72
+ /** Pre-fabricated exam result — 38/50 = 7.6/10 */
73
+ const DEMO_RESULT = {
74
+ id: 'result_demo_2024_001',
75
+ examId: 'thpt_2024',
76
+ startedAt: '2026-06-05T08:00:00.000Z',
77
+ finishedAt: '2026-06-05T09:15:00.000Z',
78
+ score: 7.6,
79
+ maxScore: 10,
80
+ accuracy: 0.76,
81
+ timeSpent: 4500,
82
+ answeredCount: 50,
83
+ topicBreakdown: {
84
+ functions: { correct: 7, total: 8, accuracy: 0.875 },
85
+ algebra: { correct: 16, total: 18, accuracy: 0.8889 },
86
+ calculus: { correct: 4, total: 7, accuracy: 0.5714 },
87
+ statistics: { correct: 2, total: 3, accuracy: 0.6667 },
88
+ trigonometry: { correct: 2, total: 2, accuracy: 1.0 },
89
+ geometry: { correct: 6, total: 10, accuracy: 0.6 },
90
+ combinatorics: { correct: 1, total: 2, accuracy: 0.5 },
91
+ },
92
+ answers: {
93
+ q_thpt24_001: 3, q_thpt24_002: 3, q_thpt24_003: 2, q_thpt24_004: 3,
94
+ q_thpt24_005: 1, q_thpt24_006: 1, q_thpt24_007: 1, q_thpt24_008: 2,
95
+ q_thpt24_009: 2, q_thpt24_010: 1, q_thpt24_011: 2, q_thpt24_012: 1,
96
+ q_thpt24_013: 1, q_thpt24_014: 1, q_thpt24_015: 1, q_thpt24_016: 0,
97
+ q_thpt24_017: 1, q_thpt24_018: 3, q_thpt24_019: 1, q_thpt24_020: 0,
98
+ q_thpt24_021: 2, q_thpt24_022: 2, q_thpt24_023: 3, q_thpt24_024: 0,
99
+ q_thpt24_025: 1, q_thpt24_026: 1, q_thpt24_027: 1, q_thpt24_028: 1,
100
+ q_thpt24_029: 1, q_thpt24_030: 1, q_thpt24_031: 1, q_thpt24_032: 2,
101
+ q_thpt24_033: 1, q_thpt24_034: 1, q_thpt24_035: 2, q_thpt24_036: 2,
102
+ q_thpt24_037: 0, q_thpt24_038: 1, q_thpt24_039: 1, q_thpt24_040: 3,
103
+ q_thpt24_041: 1, q_thpt24_042: 1, q_thpt24_043: 1, q_thpt24_044: 2,
104
+ q_thpt24_045: 1, q_thpt24_046: 1, q_thpt24_047: 1, q_thpt24_048: 2,
105
+ q_thpt24_049: 1, q_thpt24_050: 2,
106
+ },
107
+ timePerQuestion: {},
108
+ questionData: {},
109
+ }
110
+
111
+ /** Pre-built AI analysis — stored in localStorage to skip live streaming */
112
+ const DEMO_AI_ANALYSIS = {
113
+ summary:
114
+ 'Bạn đạt 7.6 điểm — kết quả khá tốt cho kỳ thi THPT. Điểm mạnh rõ rệt ở Đại số (89%) và Hàm số (88%). Cần tập trung cải thiện Hình học (60%) và Tích phân (57%).',
115
+ weak_topics: ['geometry', 'calculus', 'combinatorics'],
116
+ recommendations: [
117
+ 'Ôn luyện thể tích khối chóp và khối trụ — chiếm 40% câu Hình học sai',
118
+ 'Luyện tính tích phân bằng phương pháp đổi biến và tích phân từng phần',
119
+ 'Xem lại tổ hợp chỉnh hợp: phân biệt có thứ tự / không thứ tự',
120
+ ],
121
+ school_insight:
122
+ 'Với 7.6 điểm Toán, bạn có khả năng cao vào Đại học Bách Khoa Hà Nội (ngành Kỹ thuật), Đại học Kinh tế Quốc dân, và Học viện Ngân hàng. Cần thêm 0.4 điểm để đảm bảo cơ hội vào Đại học Khoa học Tự nhiên — Hà Nội.',
123
+ schools: [
124
+ {
125
+ name: 'Đại học Bách Khoa Hà Nội',
126
+ score_range: '7.0 – 8.5',
127
+ type: 'Công lập',
128
+ region_note: 'Hà Nội',
129
+ note: 'Ngành Kỹ thuật Cơ điện tử, Khoa học Máy tính',
130
+ },
131
+ {
132
+ name: 'Đại học Kinh tế Quốc dân',
133
+ score_range: '6.5 – 8.0',
134
+ type: 'Công lập',
135
+ region_note: 'Hà Nội',
136
+ note: 'Ngành Kinh tế, Quản trị Kinh doanh',
137
+ },
138
+ {
139
+ name: 'Học viện Ngân hàng',
140
+ score_range: '6.5 – 7.5',
141
+ type: 'Công lập',
142
+ region_note: 'Hà Nội',
143
+ note: 'Ngành Tài chính - Ngân hàng',
144
+ },
145
+ ],
146
+ // These flags tell Results.jsx the analysis is complete — no live stream needed
147
+ _source: 'ai',
148
+ _streaming_done: true,
149
+ }
150
+
151
+ const DEMO_STUDY_PLAN = {
152
+ score_gap:
153
+ 'Cần cải thiện 0.4 điểm nữa để vào Đại học Khoa học Tự nhiên. Tập trung vào Hình học và Tích phân.',
154
+ focus_areas: [
155
+ {
156
+ topic: 'Hình học không gian',
157
+ error_pattern:
158
+ 'Sai ở tính thể tích và diện tích xung quanh của khối chóp và khối trụ.',
159
+ tasks: [
160
+ 'Ôn lại công thức V = (1/3) × S × h cho khối chóp đều',
161
+ 'Luyện 5 bài tập tính thể tích hình hộp chữ nhật và khối trụ',
162
+ 'Thực hành bài toán chứng minh hai mặt phẳng vuông góc',
163
+ ],
164
+ checkpoint: { target: 3 },
165
+ },
166
+ {
167
+ topic: 'Tích phân',
168
+ error_pattern:
169
+ 'Nhầm lẫn giữa tích phân xác định và nguyên hàm — quên thay cận.',
170
+ tasks: [
171
+ 'Luyện ∫_a^b f(x)dx = F(b)−F(a) với 5 ví dụ cơ bản',
172
+ 'Thực hành đổi biến u = g(x) trong tích phân',
173
+ 'Giải 3 bài tích phân có điều kiện từ đề THPT 2022–2023',
174
+ ],
175
+ checkpoint: { target: 3 },
176
+ },
177
+ ],
178
+ }
179
+
180
+ // Build NDJSON body for /analyze/stream mock
181
+ const ANALYZE_NDJSON = [
182
+ { field: 'summary', chunk: 'Bạn đạt 7.6 điểm — kết quả ' },
183
+ { field: 'summary', chunk: 'khá tốt cho kỳ thi THPT. ' },
184
+ { field: 'summary', chunk: 'Điểm mạnh rõ rệt ở Đại số (89%) và Hàm số (88%). ' },
185
+ { field: 'summary', chunk: 'Cần tập trung cải thiện Hình học (60%) và Tích phân (57%).' },
186
+ { field: 'summary', done: true },
187
+ { field: 'weak_topics', chunk: '["geometry","calculus","combinatorics"]' },
188
+ { field: 'weak_topics', done: true },
189
+ { field: 'recommendations', chunk: '["Ôn luyện thể tích khối chóp và khối trụ","Luyện tích phân bằng đổi biến","Xem lại tổ hợp chỉnh hợp"]' },
190
+ { field: 'recommendations', done: true },
191
+ { field: 'school_insight', chunk: 'Với 7.6 điểm Toán, bạn có khả năng cao vào ' },
192
+ { field: 'school_insight', chunk: 'Đại học Bách Khoa Hà Nội, Đại học Kinh tế Quốc dân, và Học viện Ngân hàng.' },
193
+ { field: 'school_insight', done: true },
194
+ { field: 'schools', chunk: '[{"name":"Đại học Bách Khoa Hà Nội","score_range":"7.0 – 8.5","type":"Công lập","region_note":"Hà Nội"},{"name":"Đại học Kinh tế Quốc dân","score_range":"6.5 – 8.0","type":"Công lập","region_note":"Hà Nội"},{"name":"Học viện Ngân hàng","score_range":"6.5 – 7.5","type":"Công lập","region_note":"Hà Nội"}]' },
195
+ { field: 'schools', done: true },
196
+ ].map((o) => JSON.stringify(o)).join('\n')
197
+
198
+ // ─── Helper Functions ────────────────────────────────────────────────────────
199
+
200
+ /** Types text at human speed (60–120ms per character) */
201
+ async function humanType(page: Page, selector: string, text: string) {
202
+ const el = page.locator(selector)
203
+ await el.click()
204
+ for (const char of text) {
205
+ await el.type(char)
206
+ await page.waitForTimeout(60 + Math.floor(Math.random() * 60))
207
+ }
208
+ }
209
+
210
+ /**
211
+ * Scrolls smoothly over `duration` ms by `distance` pixels.
212
+ * Simulates a human reading pace rather than an instant jump.
213
+ */
214
+ async function scrollSlowly(page: Page, distance = 400, duration = 1200) {
215
+ const steps = 20
216
+ const stepSize = distance / steps
217
+ const delay = duration / steps
218
+ for (let i = 0; i < steps; i++) {
219
+ await page.mouse.wheel(0, stepSize)
220
+ await page.waitForTimeout(delay)
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Waits until a text node stops growing (streaming complete).
226
+ * Polls the element's textContent every 300ms; resolves when content
227
+ * has been stable for two consecutive ticks (≥600ms unchanged).
228
+ */
229
+ async function waitForStreamingComplete(
230
+ page: Page,
231
+ selector: string,
232
+ timeoutMs = 15_000
233
+ ) {
234
+ const start = Date.now()
235
+ let prev = ''
236
+ let stableCount = 0
237
+ while (Date.now() - start < timeoutMs) {
238
+ const current = (await page.locator(selector).textContent().catch(() => '')) ?? ''
239
+ if (current === prev && current !== '') {
240
+ if (++stableCount >= 2) return
241
+ } else {
242
+ stableCount = 0
243
+ }
244
+ prev = current
245
+ await page.waitForTimeout(300)
246
+ }
247
+ console.warn(`[waitForStreamingComplete] timed out after ${timeoutMs}ms on ${selector}`)
248
+ }
249
+
250
+ /** Takes a screenshot and saves to demo-video/screenshots/ */
251
+ async function shot(page: Page, name: string) {
252
+ const filepath = path.join(SCREENSHOTS_DIR, `${name}.png`)
253
+ await page.screenshot({ path: filepath, fullPage: false })
254
+ }
255
+
256
+ // ─── Route Mocks ─────────────────────────────────────────────────────────────
257
+
258
+ /**
259
+ * Registers all network mocks. Must be called before page.goto()
260
+ * so auth mock intercepts the initial getMe() fetch on mount.
261
+ */
262
+ async function setupMocks(page: Page) {
263
+ // GET /users/me — returns demo user; prevents real JWT validation
264
+ await page.route('**/users/me', async (route: Route) => {
265
+ if (route.request().method() === 'GET') {
266
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(DEMO_USER) })
267
+ } else {
268
+ await route.continue()
269
+ }
270
+ })
271
+
272
+ // POST /analyze/stream — NDJSON safety net (localStorage cache takes priority)
273
+ await page.route('**/analyze/stream', async (route: Route) => {
274
+ await route.fulfill({ status: 200, contentType: 'application/x-ndjson', body: ANALYZE_NDJSON })
275
+ })
276
+
277
+ // POST /hint — simulated 800ms latency so loading spinner is visible
278
+ await page.route('**/hint', async (route: Route) => {
279
+ await page.waitForTimeout(800)
280
+ await route.fulfill({
281
+ status: 200,
282
+ contentType: 'application/json',
283
+ body: JSON.stringify({
284
+ hint: 'Tích phân $\\int_0^1 (2x+1)\\,dx$ — tìm nguyên hàm của $(2x+1)$ trước. Nguyên hàm của $2x$ là $x^2$, của $1$ là $x$. Sau đó thay cận trên và cận dưới.',
285
+ difficulty_note: 'Dạng tích phân xác định cơ bản — luyện thêm với $\\int_a^b (ax+b)\\,dx$.',
286
+ }),
287
+ })
288
+ })
289
+
290
+ // POST /math-solve — simulated 1.5s Oracle "thinking" delay
291
+ await page.route('**/math-solve', async (route: Route) => {
292
+ await page.waitForTimeout(1500)
293
+ await route.fulfill({
294
+ status: 200,
295
+ contentType: 'application/json',
296
+ body: JSON.stringify({
297
+ answer: {
298
+ steps: [
299
+ 'Xác định dạng bài: tích phân xác định tuyến tính $\\int_0^2 (3x^2 - 2x + 1)\\,dx$.',
300
+ 'Tìm nguyên hàm: $F(x) = x^3 - x^2 + x + C$.',
301
+ 'Áp dụng Newton-Leibniz: $F(2) - F(0) = (8 - 4 + 2) - 0 = 6$.',
302
+ 'Kết luận: $\\displaystyle\\int_0^2 (3x^2 - 2x + 1)\\,dx = \\boxed{6}$.',
303
+ ],
304
+ confidence: 'high',
305
+ problem_type: 'calculus',
306
+ },
307
+ validation: { valid: true, issues: [] },
308
+ enriched: 2,
309
+ enriched_topics: ['calculus'],
310
+ retrieved_ids: ['wiki_calc_integral_01', 'wiki_calc_newton_leibniz'],
311
+ wiki_assisted: true,
312
+ }),
313
+ })
314
+ })
315
+
316
+ // POST /study-plan — safety net if localStorage cache key misses
317
+ await page.route('**/study-plan', async (route: Route) => {
318
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(DEMO_STUDY_PLAN) })
319
+ })
320
+
321
+ // Utility stubs to prevent null errors in Account and Oracle pages
322
+ await page.route('**/users/me/session/today', async (route: Route) => {
323
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ count: 3 }) })
324
+ })
325
+ await page.route('**/users/me/credits/log', async (route: Route) => {
326
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify([]) })
327
+ })
328
+ await page.route('**/users/me/history', async (route: Route) => {
329
+ const method = route.request().method()
330
+ if (method === 'GET') {
331
+ // HistoryContext maps server shape: { result_id, exam_id, score, created_at, payload }
332
+ // getAccessibleExamIds needs the full chain (thpt_2020..thpt_2024) passed ≥ 5.0
333
+ // to unlock thpt_2024. Include all 12 chain exams as past results.
334
+ const chainExams = [
335
+ 'thpt_2020', 'thpt_2021', 'thpt_2022', 'intl_amc12_2022', 'intl_amc10a_2022',
336
+ 'intl_amc10_2023', 'thpt_2023', 'intl_ib_sl_2023', 'intl_ksat_2023',
337
+ 'intl_act_math_2023', 'intl_hsc_adv_2023',
338
+ ]
339
+ const dummy = chainExams.map((examId, i) => ({
340
+ result_id: `result_chain_${i}`,
341
+ exam_id: examId,
342
+ score: 7.0,
343
+ created_at: `2026-01-${String(i + 1).padStart(2, '0')}T10:00:00.000Z`,
344
+ payload: JSON.stringify({ finishedAt: `2026-01-${String(i + 1).padStart(2, '0')}T10:00:00.000Z`, answers: {} }),
345
+ }))
346
+ const serverHistory = [
347
+ ...dummy,
348
+ {
349
+ result_id: DEMO_RESULT.id,
350
+ exam_id: DEMO_RESULT.examId,
351
+ score: DEMO_RESULT.score,
352
+ created_at: DEMO_RESULT.finishedAt,
353
+ payload: JSON.stringify(DEMO_RESULT),
354
+ },
355
+ ]
356
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify(serverHistory) })
357
+ } else {
358
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ streak_recovered: false }) })
359
+ }
360
+ })
361
+ await page.route('**/wiki/status', async (route: Route) => {
362
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ phase: 'ready', units: 12500 }) })
363
+ })
364
+ await page.route('**/math-stats', async (route: Route) => {
365
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ wiki_units: 12500, problems: 3200, topics: { calculus: 820, algebra: 1100, geometry: 680 } }) })
366
+ })
367
+ await page.route('**/percentile*', async (route: Route) => {
368
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ percentile: 22 }) })
369
+ })
370
+ await page.route('**/users/me/adaptive-study-plan', async (route: Route) => {
371
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ focus_concepts: [], in_progress_count: 0 }) })
372
+ })
373
+ await page.route('**/users/me/grade-change-request', async (route: Route) => {
374
+ await route.fulfill({ status: 404, contentType: 'application/json', body: JSON.stringify({ detail: 'Not found' }) })
375
+ })
376
+ await page.route('**/users/me/referral', async (route: Route) => {
377
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ code: null, referred_count: 0 }) })
378
+ })
379
+ await page.route('**/insights/simulation-brief', async (route: Route) => {
380
+ await route.fulfill({ status: 200, contentType: 'application/json', body: JSON.stringify({ brief: null }) })
381
+ })
382
+ }
383
+
384
+ // ─── localStorage Init Script ─────────────────────────────────────────────────
385
+
386
+ /**
387
+ * Returns an IIFE string that seeds localStorage before React hydrates.
388
+ * Inlined as a string (not a function closure) because addInitScript
389
+ * serializes this to the browser context where TypeScript constants are unavailable.
390
+ */
391
+ function buildInitScript(demoUser: object, demoResult: object, demoAnalysis: object, demoStudyPlan: object, jwt: string): string {
392
+ const userId = (demoUser as { id: number }).id
393
+ const resultId = (demoResult as { id: string }).id
394
+
395
+ return `(function() {
396
+ localStorage.setItem('auth_token', ${JSON.stringify(jwt)});
397
+ localStorage.setItem('user', ${JSON.stringify(JSON.stringify(demoUser))});
398
+ localStorage.setItem('exam_history', ${JSON.stringify(JSON.stringify([demoResult]))});
399
+
400
+ // AI analysis cache key: "ai-analysis-{userId}-{resultId}"
401
+ var analysisKey = 'ai-analysis-${userId}-${resultId}';
402
+ localStorage.setItem(analysisKey, ${JSON.stringify(JSON.stringify({ data: demoAnalysis, ts: Date.now() }))});
403
+
404
+ // Study plan cache key: "recovery-path-data-{userId}-{resultId}"
405
+ var planKey = 'recovery-path-data-${userId}-${resultId}';
406
+ localStorage.setItem(planKey, ${JSON.stringify(JSON.stringify(demoStudyPlan))});
407
+
408
+ // Suppress "resume draft?" session banner
409
+ sessionStorage.clear();
410
+
411
+ // Suppress guest trial flag
412
+ localStorage.removeItem('guest_trial_used');
413
+ })();`
414
+ }
415
+
416
+ // ─── Pre-test Validation ─────────────────────────────────────────────────────
417
+
418
+ /**
419
+ * Validates that required question IDs exist in the bundled questions.json.
420
+ * Fails fast with a clear message if any ID is missing — prevents silent demo breakage
421
+ * when question IDs are renamed or reshuffled between versions.
422
+ */
423
+ test.beforeAll(async () => {
424
+ const questionsPath = path.join(__dirname, '..', 'exam-app', 'src', 'data', 'questions.json')
425
+ if (!fs.existsSync(questionsPath)) {
426
+ console.warn('[demo] questions.json not found at expected path — skipping ID validation')
427
+ return
428
+ }
429
+ const questions: Array<{ id: string }> = JSON.parse(fs.readFileSync(questionsPath, 'utf-8'))
430
+ const ids = new Set(questions.map((q) => q.id))
431
+ const required = ['q_thpt24_001', 'q_thpt24_010', 'q_thpt24_020', 'q_thpt24_030', 'q_thpt24_050']
432
+ const missing = required.filter((id) => !ids.has(id))
433
+ if (missing.length > 0) {
434
+ throw new Error(
435
+ `[demo] Required question IDs not found in questions.json: ${missing.join(', ')}\n` +
436
+ 'The pre-fabricated exam result will break. Update demo-data-plan.md with the correct IDs.'
437
+ )
438
+ }
439
+ console.log('[demo] Question ID validation passed ✓')
440
+
441
+ // Ensure screenshots directory exists
442
+ if (!fs.existsSync(SCREENSHOTS_DIR)) {
443
+ fs.mkdirSync(SCREENSHOTS_DIR, { recursive: true })
444
+ }
445
+ })
446
+
447
+ // ─── Main Test ───────────────────────────────────────────────────────────────
448
+
449
+ test.use({
450
+ viewport: { width: 1920, height: 1080 },
451
+ colorScheme: 'dark',
452
+ })
453
+
454
+ test('Zenith product demo — 8 scenes', async ({ page }) => {
455
+ // Capture network and console events for diagnostics
456
+ page.on('request', req => {
457
+ if (req.url().includes('localhost:8000')) {
458
+ console.log(`[net] REQ ${req.method()} ${req.url()}`)
459
+ }
460
+ })
461
+ page.on('response', res => {
462
+ if (res.url().includes('localhost:8000')) {
463
+ console.log(`[net] RES ${res.status()} ${res.url()}`)
464
+ }
465
+ })
466
+ page.on('requestfailed', req => {
467
+ if (req.url().includes('localhost:8000')) {
468
+ console.log(`[net] FAIL ${req.url()} — ${req.failure()?.errorText}`)
469
+ }
470
+ })
471
+ page.on('pageerror', err => console.log(`[pageerror] ${err.message}`))
472
+
473
+ // Seed localStorage before any page script runs
474
+ await page.addInitScript(
475
+ buildInitScript(DEMO_USER, DEMO_RESULT, DEMO_AI_ANALYSIS, DEMO_STUDY_PLAN, DEMO_JWT)
476
+ )
477
+
478
+ // Register all route mocks before goto()
479
+ await setupMocks(page)
480
+
481
+ // ── Scene 1: Landing — Hero + Feature Carousel (0:00–0:08) ───────────────
482
+
483
+ await test.step('Scene 1: Landing — hero load + feature carousel', async () => {
484
+ await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
485
+
486
+ // Give React time to mount and resolve getMe()
487
+ await page.waitForTimeout(3000)
488
+
489
+ // Dump page state for diagnostics
490
+ const h1Count = await page.evaluate(() => document.querySelectorAll('h1').length)
491
+ const bodySnippet = await page.evaluate(() => document.body.innerText.slice(0, 150))
492
+ const authToken = await page.evaluate(() => localStorage.getItem('auth_token')?.slice(0, 20))
493
+ console.log(`[diag] h1s: ${h1Count}, auth: ${authToken}, body: ${bodySnippet.replace(/\n/g, '|')}`)
494
+
495
+ // Wait for hero headline (h1 with Fraunces font — unique on landing)
496
+ await page.getByRole('heading', { level: 1 }).first().waitFor({ state: 'visible' })
497
+
498
+ // Let entrance stagger animation complete (350ms stagger × ~6 words ≈ 2.1s)
499
+ await page.waitForTimeout(2500)
500
+
501
+ await shot(page, '01-landing-hero')
502
+
503
+ // Feature carousel auto-cycles every ~4s — wait for one full cycle
504
+ await page.waitForTimeout(4000)
505
+
506
+ await shot(page, '01-landing-carousel')
507
+ })
508
+
509
+ // ── Scene 2: Exam Select → Preview → Start (0:08–0:17) ──────────────────
510
+
511
+ await test.step('Scene 2: ExamSelect — filter, preview, start', async () => {
512
+ await page.goto(`${BASE_URL}/exams`)
513
+
514
+ // Verify auth — display name should appear in Navbar
515
+ await page.getByText('Nguyễn Minh Tuấn', { exact: false }).first().waitFor({
516
+ state: 'visible',
517
+ timeout: 8000,
518
+ })
519
+
520
+ // Verify no blocking onboarding modal (grade:'12' + extended_onboarding_done:true)
521
+ await expect(page.locator('text=Hoàn thiện hồ sơ')).toHaveCount(0)
522
+
523
+ // Wait for exam cards AND history to load (history unlocks the access chain)
524
+ // thpt_2024 is at chain index 11 — HistoryContext must load from /history first
525
+ await page.waitForTimeout(3000)
526
+
527
+ await shot(page, '02-exam-select-grid')
528
+
529
+ // Type in search to filter to THPT 2024 Quốc gia specifically
530
+ const searchInput = page.getByPlaceholder('Tìm đề thi...')
531
+ await searchInput.waitFor({ state: 'visible' })
532
+ await humanType(page, '[placeholder="Tìm đề thi..."]', 'THPT Quốc gia 2024')
533
+ await page.waitForTimeout(800)
534
+
535
+ await shot(page, '02-exam-select-filtered')
536
+
537
+ // Find the accessible "Bắt đầu" button on the THPT 2024 card
538
+ // After chain history loads, thpt_2024 is unlocked and shows the Bắt đầu button
539
+ const examTitle = page.getByText('Đề thi THPT Quốc gia 2024', { exact: false }).first()
540
+ await examTitle.waitFor({ state: 'visible', timeout: 8000 })
541
+
542
+ // The accessible exam card has a "Bắt đầu" outline button (not "Bắt đầu thi")
543
+ // Locate the button in the same section row
544
+ const cardRow = examTitle.locator('xpath=ancestor::div[@class and contains(@class,"flex")]').nth(1)
545
+ const openPreviewBtn = cardRow.getByRole('button', { name: 'Bắt đầu' }).first()
546
+ if (await openPreviewBtn.isVisible({ timeout: 3000 }).catch(() => false)) {
547
+ await openPreviewBtn.click()
548
+ } else {
549
+ // Fallback: click the title itself — some card layouts use title as the trigger
550
+ await examTitle.click()
551
+ }
552
+
553
+ // Preview modal
554
+ const startExamBtn = page.getByRole('button', { name: 'Bắt đầu thi' })
555
+ await startExamBtn.waitFor({ state: 'visible', timeout: 8000 })
556
+ await page.waitForTimeout(600) // let modal animation settle
557
+
558
+ await shot(page, '02-exam-preview-modal')
559
+
560
+ await startExamBtn.click()
561
+
562
+ // TestInterface mounts — wait for question counter
563
+ await page.getByText('Câu 1', { exact: false }).waitFor({ state: 'visible', timeout: 10000 })
564
+
565
+ await shot(page, '02-test-started')
566
+ })
567
+
568
+ // ── Scene 3: TestInterface — Exam + Hint (0:17–0:29) ─────────────────────
569
+
570
+ await test.step('Scene 3: TestInterface — keyboard shortcuts + progress', async () => {
571
+ // Timed mode exam is now active (started in Scene 2)
572
+
573
+ // Show Q1 briefly before answering
574
+ await page.waitForTimeout(1200)
575
+ await shot(page, '03-q1-unanswered')
576
+
577
+ // Answer Q1 with keyboard shortcut B (index 1)
578
+ // TestInterface.jsx keyboard handler maps 'b'|'B' → choiceIndex 1
579
+ await page.keyboard.press('b')
580
+ await page.waitForTimeout(800)
581
+ await shot(page, '03-q1-answered')
582
+
583
+ // Navigate to Q2 with arrow key, answer C
584
+ await page.keyboard.press('ArrowRight')
585
+ await page.waitForTimeout(500)
586
+ await page.keyboard.press('c')
587
+ await page.waitForTimeout(700)
588
+
589
+ // Navigate to Q3, answer A
590
+ await page.keyboard.press('ArrowRight')
591
+ await page.waitForTimeout(500)
592
+ await page.keyboard.press('a')
593
+ await page.waitForTimeout(600)
594
+
595
+ // Navigate to Q4 to show progress dots
596
+ await page.keyboard.press('ArrowRight')
597
+ await page.waitForTimeout(500)
598
+ await shot(page, '03-q4-progress')
599
+
600
+ // Navigate to Q5
601
+ await page.keyboard.press('ArrowRight')
602
+ await page.waitForTimeout(500)
603
+ await page.keyboard.press('b')
604
+ await page.waitForTimeout(700)
605
+ await shot(page, '03-q5-answered')
606
+
607
+ // Pause on the test interface to show the full UI (timer, dots, answer choices)
608
+ await page.waitForTimeout(1500)
609
+ })
610
+
611
+ // ── Scene 4: Results — Score + Confetti + AI + Schools (0:29–0:47) ────────
612
+
613
+ await test.step('Scene 4: Results — score CountUp + confetti + streaming AI + school cards', async () => {
614
+ // Navigate directly to the pre-seeded result
615
+ // HistoryContext has DEMO_RESULT (seeded by exam_history key)
616
+ await page.goto(`${BASE_URL}/results/result_demo_2024_001`)
617
+
618
+ // Wait for the score label (Results.jsx renders "Khá giỏi" for score ≥ 6.5)
619
+ await page.getByText('Khá giỏi').waitFor({ state: 'visible', timeout: 8000 })
620
+ await page.waitForTimeout(500)
621
+
622
+ // CountUp animation runs ~1.8s — let it complete before screenshotting
623
+ await page.waitForTimeout(2000)
624
+
625
+ // Confetti fires when score ≥ 7.0 — score 7.6 triggers it (~1.9s after mount)
626
+ // Give it an extra second to be visible in the screenshot
627
+ await page.waitForTimeout(1000)
628
+
629
+ await shot(page, '04-score-confetti')
630
+
631
+ // Scroll to AI analysis section
632
+ await scrollSlowly(page, 400, 2000)
633
+
634
+ // AI analysis is pre-cached in localStorage — renders immediately with animation
635
+ const analysisSection = page.getByText('Phân tích AI').first()
636
+ await analysisSection.waitFor({ state: 'visible', timeout: 5000 }).catch(() => null)
637
+
638
+ await shot(page, '04-ai-analysis')
639
+ await page.waitForTimeout(800)
640
+
641
+ // Scroll back up slightly to show the radar chart
642
+ await scrollSlowly(page, -200, 1000)
643
+ const radarSection = page.getByText('Hồ sơ năng lực').first()
644
+ await radarSection.waitFor({ state: 'visible', timeout: 5000 }).catch(() => null)
645
+
646
+ await shot(page, '04-radar-chart')
647
+ await page.waitForTimeout(600)
648
+
649
+ // Switch to school recommendations tab
650
+ const schoolTab = page.getByRole('button', { name: /Trường phù hợp/ })
651
+ if (await schoolTab.isVisible({ timeout: 3000 }).catch(() => false)) {
652
+ await schoolTab.click()
653
+ await page.waitForTimeout(800)
654
+
655
+ // School cards — grade:12 shows university suggestions
656
+ await page.getByText('Đại học Bách Khoa Hà Nội', { exact: false }).first()
657
+ .waitFor({ state: 'visible', timeout: 5000 }).catch(() => null)
658
+
659
+ await shot(page, '04-school-cards')
660
+ } else {
661
+ // School cards may be in a different section on this version
662
+ await scrollSlowly(page, 300, 1500)
663
+ await shot(page, '04-school-section')
664
+ }
665
+
666
+ await page.waitForTimeout(1000)
667
+ })
668
+
669
+ // ── Scene 5: Oracle AI — Streaming Solution (0:47–0:59) ──────────────────
670
+
671
+ await test.step('Scene 5: Oracle — type calculus problem, streaming solution', async () => {
672
+ await page.goto(`${BASE_URL}/oracle`)
673
+
674
+ // Wait for Oracle page heading
675
+ await page.locator('h1, h2').filter({ hasText: /Oracle|Toán/ }).first()
676
+ .waitFor({ state: 'visible', timeout: 8000 })
677
+ await page.waitForTimeout(800)
678
+
679
+ await shot(page, '05-oracle-ready')
680
+
681
+ // The textarea — identified by oracle-textarea class or placeholder
682
+ const textarea = page.locator('textarea').first()
683
+ await textarea.waitFor({ state: 'visible', timeout: 5000 })
684
+
685
+ await humanType(page, 'textarea', 'Tính tích phân ∫₀² (3x² - 2x + 1) dx')
686
+
687
+ await page.waitForTimeout(600)
688
+ await shot(page, '05-oracle-typed')
689
+
690
+ // Submit — Ctrl+Enter is the keyboard shortcut
691
+ await page.keyboard.press('Control+Enter')
692
+
693
+ // Oracle enters "thinking" state
694
+ await page.waitForTimeout(600)
695
+ await shot(page, '05-oracle-loading')
696
+
697
+ // Mock responds after 1.5s — wait for first step text
698
+ await page.getByText('Xác định dạng bài', { exact: false }).first()
699
+ .waitFor({ state: 'visible', timeout: 8000 })
700
+
701
+ await shot(page, '05-oracle-step1')
702
+ await page.waitForTimeout(800)
703
+
704
+ // Click "Tiếp theo →" if available to reveal subsequent steps
705
+ const nextBtn = page.getByRole('button', { name: /Tiếp theo/ })
706
+ if (await nextBtn.isVisible({ timeout: 2000 }).catch(() => false)) {
707
+ await nextBtn.click()
708
+ await page.waitForTimeout(600)
709
+ await shot(page, '05-oracle-step2')
710
+
711
+ if (await nextBtn.isVisible({ timeout: 1000 }).catch(() => false)) {
712
+ await nextBtn.click()
713
+ await page.waitForTimeout(600)
714
+ await shot(page, '05-oracle-step3')
715
+ }
716
+ }
717
+
718
+ // Scroll to show the final boxed answer
719
+ await scrollSlowly(page, 250, 1000)
720
+ await page.waitForTimeout(600)
721
+
722
+ await shot(page, '05-oracle-complete')
723
+ })
724
+
725
+ // ── Scene 6: Study Plan — Recovery Path + Checkpoint (0:59–1:09) ─────────
726
+
727
+ await test.step('Scene 6: Study Plan — focus areas + checkpoint bar', async () => {
728
+ await page.goto(`${BASE_URL}/study-plan/result_demo_2024_001`)
729
+
730
+ // Study plan reads from localStorage cache — no API call needed
731
+ await page.getByText('Kế hoạch', { exact: false }).first()
732
+ .waitFor({ state: 'visible', timeout: 8000 })
733
+ await page.waitForTimeout(600)
734
+
735
+ // Score gap / goal section
736
+ const goalText = page.getByText('Cần cải thiện', { exact: false }).first()
737
+ await goalText.waitFor({ state: 'visible', timeout: 5000 }).catch(() => null)
738
+
739
+ await shot(page, '06-study-plan-goal')
740
+ await page.waitForTimeout(600)
741
+
742
+ // First FocusCard — "Hình học không gian" (auto-expanded at index 0)
743
+ const focusCard1 = page.getByText('Hình học không gian', { exact: false }).first()
744
+ await focusCard1.waitFor({ state: 'visible', timeout: 5000 }).catch(() => null)
745
+
746
+ await shot(page, '06-focus-card-open')
747
+
748
+ // Scroll to checkpoint bar
749
+ await scrollSlowly(page, 200, 1000)
750
+ const checkpoint = page.getByText('Checkpoint', { exact: false }).first()
751
+ await checkpoint.waitFor({ state: 'visible', timeout: 5000 }).catch(() => null)
752
+
753
+ // Hold on checkpoint bar so the 2/3 fill animation is visible
754
+ await page.waitForTimeout(2000)
755
+ await shot(page, '06-checkpoint-bar')
756
+
757
+ // Click second FocusCard to demonstrate expandable mechanic
758
+ const focusCard2 = page.getByText('Tích phân', { exact: false }).first()
759
+ if (await focusCard2.isVisible({ timeout: 2000 }).catch(() => false)) {
760
+ await focusCard2.click()
761
+ await page.waitForTimeout(600)
762
+ await shot(page, '06-focus-card-2')
763
+ }
764
+ })
765
+
766
+ // ── Scene 7: Account — Mastery Rank + Pricing (1:09–1:20) ────────────────
767
+
768
+ await test.step('Scene 7: Account — credit gauge, mastery rank, pricing table', async () => {
769
+ await page.goto(`${BASE_URL}/account`)
770
+
771
+ // Wait for page to load — credit balance appears in header
772
+ await page.waitForTimeout(1500)
773
+
774
+ // Credits balance "50" visible
775
+ const creditsText = page.getByText('50', { exact: false }).first()
776
+ await creditsText.waitFor({ state: 'visible', timeout: 8000 }).catch(() => null)
777
+
778
+ await shot(page, '07-account-credits')
779
+ await page.waitForTimeout(600)
780
+
781
+ // Mastery rank badge — "Học sinh" visible
782
+ const rankBadge = page.getByText('Học sinh', { exact: false }).first()
783
+ await rankBadge.waitFor({ state: 'visible', timeout: 5000 }).catch(() => null)
784
+
785
+ await shot(page, '07-mastery-rank')
786
+ await page.waitForTimeout(400)
787
+
788
+ // Scroll to pricing section
789
+ await scrollSlowly(page, 700, 2500)
790
+
791
+ // Pricing table — look for tier names
792
+ const completeTier = page.getByText('Toàn diện').first()
793
+ await completeTier.waitFor({ state: 'visible', timeout: 5000 }).catch(() => null)
794
+
795
+ await shot(page, '07-pricing-table')
796
+ await page.waitForTimeout(600)
797
+
798
+ // "PHỔ BIẾN" badge on the Student plan — most visually distinctive element
799
+ const popularBadge = page.getByText('PHỔ BIẾN', { exact: false }).first()
800
+ if (await popularBadge.isVisible({ timeout: 3000 }).catch(() => false)) {
801
+ await shot(page, '07-popular-badge')
802
+ }
803
+
804
+ // Final frame — hold on pricing for the CTA
805
+ await page.waitForTimeout(1500)
806
+ await shot(page, '07-final-frame')
807
+ })
808
+ })
demo-video/playwright.config.debug.js ADDED
@@ -0,0 +1,12 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const { defineConfig, devices } = require('@playwright/test')
2
+ module.exports = defineConfig({
3
+ testDir: '.',
4
+ testMatch: '**/debug-demo.spec.ts',
5
+ timeout: 30_000,
6
+ retries: 0,
7
+ use: {
8
+ baseURL: 'http://localhost:5173',
9
+ viewport: { width: 1280, height: 800 },
10
+ },
11
+ projects: [{ name: 'chromium', use: { ...devices['Desktop Chrome'] } }],
12
+ })
demo-video/playwright.config.demo.js ADDED
@@ -0,0 +1,76 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // @ts-check
2
+ /**
3
+ * playwright.config.demo.js
4
+ *
5
+ * Isolated Playwright config for the demo video recording.
6
+ * Uses a SEPARATE testDir ('demo-video') so this spec NEVER runs
7
+ * with the existing test suite (which uses testDir: './tests').
8
+ *
9
+ * Run:
10
+ * npx playwright test demo-video/playwright-demo.spec.ts \
11
+ * --config demo-video/playwright.config.demo.js \
12
+ * --headed --project=chromium
13
+ *
14
+ * CI (production URL):
15
+ * DEMO_BASE_URL=https://exam-app-ey0.pages.dev \
16
+ * npx playwright test demo-video/playwright-demo.spec.ts \
17
+ * --config demo-video/playwright.config.demo.js \
18
+ * --project=chromium
19
+ */
20
+ const { defineConfig, devices } = require('@playwright/test')
21
+
22
+ module.exports = defineConfig({
23
+ // Scoped to this directory only — never overlaps with ./tests/
24
+ // Path is relative to the config file location (demo-video/)
25
+ testDir: '.',
26
+ testMatch: '**/playwright-demo.spec.ts',
27
+
28
+ // Generous timeout for all 8 scenes including humanType + scrollSlowly delays
29
+ timeout: 240_000,
30
+ retries: 0,
31
+ reporter: [['list'], ['html', { open: 'never' }]],
32
+
33
+ use: {
34
+ baseURL: process.env.DEMO_BASE_URL ?? 'http://localhost:5173',
35
+ // Full HD for demo recording
36
+ viewport: { width: 1920, height: 1080 },
37
+ colorScheme: 'dark',
38
+ // Record video for every run — output goes to test-results/ (gitignored)
39
+ video: {
40
+ mode: 'on',
41
+ size: { width: 1920, height: 1080 },
42
+ },
43
+ // Capture screenshot on failure only (per-scene screenshots handled manually in spec)
44
+ screenshot: 'only-on-failure',
45
+ // Slow down mouse movement for human-paced visual timing
46
+ // (additional timing control is in humanType / scrollSlowly helpers)
47
+ actionTimeout: 30_000,
48
+ navigationTimeout: 30_000,
49
+ },
50
+
51
+ projects: [
52
+ {
53
+ name: 'chromium',
54
+ use: {
55
+ ...devices['Desktop Chrome'],
56
+ // Chromium-specific: launch with full GPU for CSS animations
57
+ launchOptions: {
58
+ args: [
59
+ '--disable-infobars',
60
+ '--no-default-browser-check',
61
+ '--no-first-run',
62
+ // GPU acceleration: only useful when --headed; headless ignores it
63
+ // and some WSL2 environments hang with --enable-gpu in headless mode
64
+ '--disable-background-timer-throttling',
65
+ '--disable-renderer-backgrounding',
66
+ '--disable-backgrounding-occluded-windows',
67
+ ],
68
+ },
69
+ },
70
+ },
71
+ ],
72
+
73
+ // No automatic dev server spin-up.
74
+ // For local recording: start `npm --prefix exam-app run dev` manually.
75
+ // For CI: app is already deployed to the production URL.
76
+ })
demo-video/product-analysis.md ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Zenith — Product Analysis
2
+
3
+ ## Executive Summary
4
+
5
+ Zenith is a Vietnamese AI-native mathematics exam-preparation platform targeting Grade 9–12 students preparing for the THPT national graduation exam and competitive high-school entrance exams. The platform differentiates itself from generic quiz banks through three proprietary intelligence layers: province-calibrated AI analysis (understanding the question-distribution patterns of all 63 Vietnamese provinces), an Oracle math solver that renders step-by-step LaTeX solutions in real time, and a BKT-powered concept mastery graph that traces prerequisite gaps back to their root cause. At 29,000–59,000 VND per month (under USD $3), Zenith offers tutoring-quality coaching at textbook pricing.
6
+
7
+ ---
8
+
9
+ ## Target Audience
10
+
11
+ **Primary**
12
+ - Grade 12 students (16–18 years) in Vietnam's 63 provinces, 3–6 months before the THPT Quốc gia national graduation and university entrance exam
13
+ - Grade 9 students (14–15 years) preparing for the competitive THPT high-school entrance exam
14
+ - Students in major cities (Hà Nội, TP. HCM, Đà Nẵng, Hải Phòng) who need to outperform provincial averages to reach selective schools
15
+
16
+ **Secondary**
17
+ - Parents and tutors monitoring student exam readiness metrics
18
+ - Math-gifted students preparing for international exams (AMC, SAT, GCSE, IB) alongside the Vietnamese curriculum
19
+ - School administrators evaluating edtech for class-level deployment
20
+
21
+ ---
22
+
23
+ ## Core Problem Solved
24
+
25
+ Vietnamese students studying for high-stakes math exams have no affordable tool that understands the local examination system. Generic platforms give correct answers but not province-calibrated feedback, cannot identify which prerequisite concept caused a cascade of failures, and cannot predict whether a student's current score will get them into a specific Hà Nội or TP. HCM high school. Zenith closes all three gaps simultaneously.
26
+
27
+ ---
28
+
29
+ ## Feature Inventory
30
+
31
+ | Feature | Tier | Visual Impressiveness (1–5) | Demo Priority |
32
+ |---|---|---|---|
33
+ | 70+ real past exams (THPT, Grade 10, AMC, SAT, GCSE) | Basic | 3 | High |
34
+ | Timed exam interface with starfield background | Basic | 4 | High |
35
+ | Streaming AI post-exam analysis (word-by-word reveal) | Student | 5 | High |
36
+ | Province-aware school matching (63 provinces, sigmoid fit) | Student | 5 | High |
37
+ | Oracle AI Math Solver (LaTeX / voice / OCR) | Basic (5/day) | 5 | High |
38
+ | Score CountUp animation + confetti on high scores | Basic | 5 | High |
39
+ | AI-generated adaptive study plan | Student | 4 | High |
40
+ | Concept Mastery Graph — BKT node-link visualization | Student | 5 | Medium |
41
+ | Spaced repetition review queue (FSRS) | Student | 3 | Medium |
42
+ | Daily challenge + streak system 🔥 | Basic | 4 | Medium |
43
+ | Mastery rank progression (Pemula → Chuyên gia) | Basic | 4 | Medium |
44
+ | OCR exam upload (photo → questions → instant practice) | Student | 4 | Medium |
45
+ | AI-generated custom exam (topic + difficulty selection) | Complete | 4 | Medium |
46
+ | AchievementCeremony spring-physics animation on milestone | Basic | 5 | Medium |
47
+ | Adaptive practice (difficulty scales in real time) | Student | 3 | Low |
48
+ | Province pattern tips on results page | Student | 3 | Low |
49
+ | Score correlation (exam score → predicted THPT score) | Student | 4 | Low |
50
+ | Radar chart — topic breakdown | Basic | 3 | Low |
51
+ | Kalman-filter score prediction | Complete | 4 | Low |
52
+ | ClassDashboard (teacher view) | Complete | 3 | Low |
53
+ | Error Analysis page (pattern clustering) | Basic | 3 | Low |
54
+ | Placement / Diagnostic test | Basic | 3 | Low |
55
+ | Formula Drawer sidebar | Basic | 2 | Low |
56
+ | Streak freeze mechanic | Student | 2 | Low |
57
+ | Grade-change request workflow | All | 2 | Low |
58
+
59
+ ---
60
+
61
+ ## User Journey Map
62
+
63
+ | Step | Page | What Happens |
64
+ |---|---|---|
65
+ | 1. First visit | `/` Landing | Hero with staggered text animation; auto-cycling 4-feature showcase (Exam / Oracle / Analysis / Concept Map); pricing section; social proof |
66
+ | 2. Authentication | AuthModal | Google OAuth one-tap sign-in; Google token → backend `/auth/google` |
67
+ | 3. Profile onboarding | ProfileOnboarding modal | Student selects grade (9–12) + province (63 provinces) + school type; accepts ToS; blocks any credit-deducting request until complete |
68
+ | 4. Exam selection | `/exams` | Browse 40+ exams grouped by THPT / Grade 10; toggle Timed / Practice / Lab; filter by year/province; OCR upload option |
69
+ | 5. Exam preview | ExamSelect modal | Preview card: exam title, question count, duration; pre-exam briefing if history exists; Start button |
70
+ | 6. Taking the exam | `/test/:examId` | Full-screen timed interface; starfield background; question card with LaTeX; keyboard shortcuts; formula drawer; watermark overlay; anti-cheat (DevTools detection, tab-switch pause) |
71
+ | 7. Submit | TestInterface | Auto-submit on timeout or manual; score computed client-side by scoringEngine; AI analysis triggered in background |
72
+ | 8. Results + Score | `/results/current` | CountUp score animation; optional confetti burst (score ≥7); radar chart of topics; streaming AI analysis word-by-word; province-aware school match cards with sigmoid fit bars |
73
+ | 9. Study Plan | `/study-plan/:resultId` | AI-generated recovery path: focus areas with error patterns, 3-5 practice tasks, checkpoint mechanic ("Đúng N câu liên tiếp") |
74
+ | 10. Oracle | `/oracle` | LaTeX input (MathLive), voice, or OCR; step-by-step streaming solution with KaTeX rendering; Socratic hints |
75
+ | 11. Concept Map | `/concept-map` | ReactFlow DAG of 55+ concepts colored by mastery score; gap-trace tooltip shows root weakness |
76
+ | 12. Account / Progress | `/account`, `/progress` | Streak counter, mastery rank badge with spring animation, credit balance gauge, score trend charts, province narrative, pricing table |
77
+
78
+ ---
79
+
80
+ ## Product Strengths
81
+
82
+ 1. **Province intelligence is genuinely unique** — 63 provinces each have calibrated topic weights, school cutoff data, and a sigmoid probability score that tells a student exactly how likely they are to get into a named school
83
+ 2. **Streaming AI analysis creates a theatrical "thinking" moment** — word-by-word text appearance makes intelligence feel alive rather than static
84
+ 3. **BKT + FSRS + Concept Mastery tri-layer** — BKT estimates knowledge from exam performance, FSRS schedules spaced reviews, Mastery tracks stage progression (stages 0–5)
85
+ 4. **Oracle's input flexibility** — LaTeX via MathLive, voice via useVoiceInput, OCR via backend — rare at this price point
86
+ 5. **AchievementCeremony spring physics** — uses `spring({ stiffness: 320, damping: 18 })`, a physically accurate spring, giving milestone moments a tactile feel
87
+ 6. **Dark-mode-first design** with consistent amber/indigo accent system (#F2A20C / #818CF8) — matches student night-study habits
88
+ 7. **Real data** — 1,500+ questions from official national exam boards, not AI-generated content
89
+ 8. **Credit economy is transparent** — Basic tier gives enough credits to form a genuine opinion before upgrading
90
+
91
+ ---
92
+
93
+ ## Hidden / Advanced Features Worth Showcasing
94
+
95
+ - **Gap-trace tooltip** on Concept Map: clicking a weak concept reveals the prerequisite chain causing it
96
+ - **Score correlation table**: maps practice exam score → predicted real THPT scores for specific schools
97
+ - **Province pattern tips** on results: surfaced from `province_patterns.json`, tells students which topic types their province historically emphasizes
98
+ - **Learner archetype classifier**: silently categorizes users (e.g., "Scattered Learner", "Steady Improver") and personalizes AI analysis tone
99
+ - **Streak recovery path**: students who miss a day can regain streak via a targeted topic mastery task
100
+ - **ClassDashboard**: full teacher-facing view with class-level analytics (not highlighted in public demo)
101
+ - **Kalman-filter score prediction**: Bayesian score predictor with 90% confidence intervals (Complete tier)
102
+
103
+ ---
104
+
105
+ ## Recommended Demo Focus Areas (Ranked)
106
+
107
+ 1. Score CountUp + confetti burst — instant emotional payoff, universal appeal
108
+ 2. Streaming AI analysis text — shows AI capability visually, no explanation needed
109
+ 3. Province-aware school matching with named schools — the most differentiated feature
110
+ 4. Oracle step-by-step solution with KaTeX LaTeX rendering — demonstrates technical depth
111
+ 5. Exam select catalog — establishes breadth of real past exams
112
+ 6. Timed exam interface with starfield — sets mood and serious-tool context
113
+ 7. AI Hint in practice mode — shows Socratic coaching without giving answers
114
+ 8. Study Plan checkpoint mechanic — closes the learning loop convincingly
demo-video/recording-plan.md ADDED
@@ -0,0 +1,168 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Zenith — Recording Plan
2
+
3
+ ## Technical Specifications
4
+
5
+ | Parameter | Value |
6
+ |---|---|
7
+ | Browser | Chromium (no browser UI chrome visible) |
8
+ | Viewport | 1920 × 1080 |
9
+ | Recording resolution | 1920 × 1080 |
10
+ | Frame rate | 60 fps |
11
+ | Aspect ratio | 16:9 |
12
+ | Color space | sRGB |
13
+ | System font scaling | 100% — no DPI scaling |
14
+ | Dark mode | Yes — app is dark-mode by default (`bg: #0A0E1A`) |
15
+ | Mouse cursor | Visible; smooth movement ≤ 400px/s; minimum 800ms between click and next action |
16
+ | Scroll behavior | `behavior: 'smooth'`; 2.5–3s pause between scroll events |
17
+ | Audio | None captured — narration added in post-production |
18
+
19
+ ---
20
+
21
+ ## What Must NOT Appear in Any Frame
22
+
23
+ - Browser address bar or navigation controls
24
+ - Bookmarks bar, extension icons, or popups
25
+ - System notification toasts — disable OS notifications before recording
26
+ - Loading spinners > 1 second — pre-warm all API calls
27
+ - Credit balance counter prominently in early scenes (crop if needed)
28
+ - Any error state (AIErrorBoundary must not fire)
29
+ - Tab-switch warning overlay (stay in one window throughout)
30
+ - DevTools panel (will trigger anti-cheat overlay in TestInterface)
31
+
32
+ ---
33
+
34
+ ## Pre-Recording Checklist
35
+
36
+ 1. Log in as the demo account (Grade 12, Hà Nội) and verify profile fields are set
37
+ 2. Pre-complete the exam in a separate session so the results page is deep-linkable
38
+ 3. Verify `province_patterns.json` has a "Hà Nội" entry (province tips appear in Results)
39
+ 4. Clear `sessionStorage` — prevents "resume draft?" banner from appearing
40
+ 5. Set OS idle timer to 30 minutes minimum
41
+ 6. Disable OS notifications (Windows: Focus Assist / macOS: Do Not Disturb)
42
+ 7. Set system font scaling to 100% — no High DPI scaling
43
+ 8. Disable `prefers-reduced-motion` OS setting so all Framer Motion animations play
44
+ 9. Run frontend dev server: `npm --prefix exam-app run dev`
45
+ 10. Run backend with demo API token set in `.env`
46
+ 11. Verify confetti fires at the results page with score 7.6 (open DevTools → verify canvas-confetti call)
47
+ 12. Close DevTools before recording (TestInterface detects window size delta)
48
+ 13. Use a custom CSS cursor overlay (64px) for visibility: `document.body.style.cursor = 'none'` + DOM overlay
49
+
50
+ ---
51
+
52
+ ## Scene-by-Scene Recording Notes
53
+
54
+ ### Scene 1: Landing Hero (0:00–0:08)
55
+ - Open `http://localhost:5173/` — full viewport, no scroll
56
+ - Let hero stagger animation run naturally (350ms stagger between words)
57
+ - Feature carousel auto-cycles — show at least one full tab transition
58
+ - Mouse rests near center; no interaction
59
+ - Hold final state for 1s before transition
60
+
61
+ ### Scene 2: Exam Select (0:08–0:17)
62
+ - Navigate to `/exams` — timed mode is default
63
+ - Let staggered exam cards animate in (`staggerChildren: 0.07` — about 1.5s for full list)
64
+ - Type "THPT 2024" in the search bar at human speed
65
+ - Hover over the first result card (scale spring visible)
66
+ - Click "Bắt đầu" → hold on preview modal 1.5s
67
+ - Click "Bắt đầu thi" — snap cut on button press
68
+
69
+ ### Scene 3: TestInterface (0:17–0:29)
70
+ - Launch exam in timed mode (90-minute timer visible)
71
+ - Show Q1 with KaTeX-rendered LaTeX for 2s before answering
72
+ - Press keyboard shortcut B — show answer flash
73
+ - Press → to advance, show progress dot turn green
74
+ - Jump to practice mode for hint demonstration (separate browser tab is fine)
75
+ - On Q3 in practice mode: click "Gợi ý" — let streaming hint text build word by word
76
+ - Record hint popover for at least 1.5s
77
+
78
+ **Important:** Do NOT alt-tab during timed exam — triggers tab-switch pause overlay
79
+
80
+ ### Scene 4: Results — Score + Confetti + AI + Schools (0:29–0:47)
81
+ - Deep-link to `/results/result_demo_2024_001`
82
+ - Let CountUp animate from 0.0 → 7.6 (1.8s duration — do not interrupt)
83
+ - Wait for confetti burst to begin before any scroll
84
+ - Let confetti settle 1s before scrolling
85
+ - Scroll to "Phân tích AI" — 2.5s smooth scroll
86
+ - Let streaming text build (AI analysis cache is pre-loaded — appears immediately but with animation)
87
+ - Scroll to RadarChart — hold 1.5s on fully rendered chart
88
+ - Click "Trường phù hợp" tab — hold on school cards for 2s
89
+ - **Zoom hint (post-production):** apply 1.0x → 1.06x → 1.0x zoom punch at the moment CountUp reaches 7.6
90
+
91
+ ### Scene 5: Oracle AI (0:47–0:59)
92
+ - Navigate to `/oracle`
93
+ - Wait for wiki status dot to show green (mocked response: `{phase: "ready"}`)
94
+ - Click textarea — type calculus problem at ~60ms/char human pace
95
+ - Press Ctrl+Enter to submit
96
+ - Let "thinking" state animate for 1.5s before first step appears
97
+ - Step through solution using "Tiếp theo →" button if visible (reveals steps one at a time)
98
+ - Scroll to show final boxed answer
99
+ - Hold on complete solution for 1s
100
+
101
+ ### Scene 6: Study Plan (0:59–1:09)
102
+ - Navigate to `/study-plan/result_demo_2024_001`
103
+ - Wait for "Kế hoạch phục hồi" heading to appear
104
+ - First FocusCard (Hình học) is auto-expanded — hold for 2s
105
+ - Scroll to show checkpoint bar — hold 2s to show the 2/3 fill animation
106
+ - Click second FocusCard (Tích phân) — let first collapse and second expand
107
+
108
+ ### Scene 7: Account + Pricing (1:09–1:20)
109
+ - Navigate to `/account`
110
+ - Wait for credit gauge SVG arc animation (fills from 0 → 50)
111
+ - Verify mastery rank badge "Học sinh Tiến bộ" in indigo is visible
112
+ - Scroll to pricing section — hold 3s on pricing table
113
+ - Ensure all 3 tier columns are fully visible (do not crop)
114
+ - End on "PHỔ BIẾN" badge and gold CTA button visible
115
+
116
+ ---
117
+
118
+ ## Transition Plan
119
+
120
+ | Between Scenes | Transition | Duration |
121
+ |---|---|---|
122
+ | Scene 1 → 2 | Cross-dissolve | 400ms |
123
+ | Scene 2 → 3 | Snap cut (button press moment) | — |
124
+ | Scene 3 → 4 | Cross-dissolve | 300ms |
125
+ | Scene 4 → 5 | **Fade to black** | 500ms out · 500ms hold · 500ms in |
126
+ | Scene 5 → 6 | Cross-dissolve | 300ms |
127
+ | Scene 6 → 7 | Cross-dissolve | 300ms |
128
+ | Scene 7 → End card | **Fade to black** | 500ms |
129
+
130
+ ---
131
+
132
+ ## Recording Tool Recommendations
133
+
134
+ ### Primary: Playwright Built-In Video Recording
135
+
136
+ ```javascript
137
+ // playwright.config.demo.js
138
+ use: {
139
+ video: { mode: 'on', size: { width: 1920, height: 1080 } },
140
+ viewport: { width: 1920, height: 1080 },
141
+ }
142
+ ```
143
+
144
+ Playwright gives deterministic mouse movement via `page.mouse.move(x, y, { steps: 20 })` and eliminates OS notification risk. Run:
145
+
146
+ ```bash
147
+ npx playwright test demo-video/playwright-demo.spec.ts \
148
+ --config demo-video/playwright.config.demo.js \
149
+ --headed --project=chromium
150
+ ```
151
+
152
+ Video output: `test-results/` (gitignored)
153
+
154
+ ### Fallback: OBS Studio
155
+
156
+ - Scene: Browser Source (Chromium at 1920×1080)
157
+ - Encoder: x264 at CRF 18, or NVENC Quality 18
158
+ - Output format: MP4 (H.264)
159
+ - Audio: disabled at capture time (narration added in post)
160
+ - Virtual Camera: off
161
+
162
+ ---
163
+
164
+ ## Zoom and Cursor Recommendations
165
+
166
+ - **Zoom:** No browser zoom during recording. Apply post-production zoom in CapCut (scene-specific). The one mandatory zoom punch is during score CountUp in Scene 4.
167
+ - **Cursor:** Use a custom 64px white circle cursor overlay (Framer Motion `motion.div` fixed-position overlay synced to `mousemove` event). Alternatively, enable large cursor in OS accessibility settings before recording.
168
+ - **Cursor speed:** Playwright `mouse.move` with `steps: 20` at 60fps gives ~33ms/step — equivalent to ~400px/s human speed. Set `page.mouse.move` delay via helper `humanMove()` if using OBS.
demo-video/review.md ADDED
@@ -0,0 +1,132 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Zenith — Demo Quality Review
2
+
3
+ ## Risk Assessment
4
+
5
+ | Risk | Likelihood | Impact | Mitigation |
6
+ |------|-----------|--------|------------|
7
+ | AI streaming fails during recording (network timeout, cold start) | High | High | Pre-warm API 60s before recording; use localStorage cache for analysis (always available); mock `/analyze/stream` with Playwright route if recording via automation |
8
+ | Confetti does not fire (score threshold not met, or canvas-confetti blocked in headless Chromium) | Medium | High | Verify score is exactly 7.6 (≥7.0 threshold); always run in headed Chromium, not headless; test confetti fires before recording session |
9
+ | ReactFlow Concept Map renders empty (API returns empty mastery) | Medium | Medium | Scene 7 is Study Plan (not Concept Map) — this risk is avoided by design; Study Plan reads from localStorage cache which is always seeded |
10
+ | KaTeX renders raw LaTeX strings instead of math (normalizeMath edge case) | Low-Medium | High | Test the exact Oracle problem string (`∫₀² (3x² - 2x + 1) dx`) in Oracle before recording; confirm KaTeX renders `\boxed{6}` correctly |
11
+ | Province-aware school cards empty (Hà Nội not in schools.json or score_correlation.json) | Low | High | Verify `schools.json` has Hà Nội entries with math cutoffs matching score 7.6; AI analysis mock response provides school data anyway as backup |
12
+ | Streaming text animation too fast/slow for recording frame rate | Medium | Medium | Record at 60fps; confirm `word-fade` CSS animation respects the frame rate; disable `prefers-reduced-motion` OS setting |
13
+ | TestInterface tab-switch warning overlay fires during recording | Medium | Medium | Record in a single browser window; never alt-tab during Scene 3; Playwright spec stays in one page context |
14
+ | Question IDs `q_thpt24_*` don't exist in questions.json (renamed or reshuffled) | Low | High | `beforeAll` validation block in Playwright spec checks 5 representative IDs; fails fast with clear error message if any ID is missing |
15
+ | `extended_onboarding_done: false` causes ExtendedOnboarding modal to block UI | Low | High | Demo user has `extended_onboarding_done: true` explicitly set; `GET /users/me` mock returns this field |
16
+ | Video exceeds 90s due to unexpected loading states | Medium | Medium | All AI responses are mocked or cached; study plan is pre-loaded in localStorage; if any scene runs long, Scene 3 and Scene 7 are the cut candidates (see §Cut Priority below) |
17
+ | Non-Vietnamese viewer cannot read Vietnamese UI text | High | Medium | English narration provides all context; critical school name "Đại học Bách Khoa Hà Nội" can have a 2s English caption overlay in post: "Top Engineering University in Hanoi" |
18
+ | Credit system Tia display visible early (signals friction) | Medium | Low | Navbar credits badge is on `/exams` and after — start from landing page before auth and navigate quickly past ExamSelect |
19
+
20
+ ---
21
+
22
+ ## Weak Scenes
23
+
24
+ ### Scene 3 (TestInterface) — Weakest Scene
25
+
26
+ A viewer who has never used Zenith gains little from watching someone press keyboard shortcuts. The starfield background is visually engaging but the question content (Vietnamese multiple-choice) is illegible at typical video playback scale. The hint demonstration requires switching to practice mode, which creates a jarring mode-switch mid-scene.
27
+
28
+ **Mitigation options:**
29
+ 1. Reduce Scene 3 from 12s to 6s — show only the question card + timer for 3s, then the streaming hint for 3s
30
+ 2. Eliminate the timed mode portion entirely — start directly in practice mode (simpler, fewer mode-switches)
31
+ 3. Keep as-is but add an English caption: "1,500+ real exam questions · KaTeX math rendering"
32
+
33
+ **Recommendation:** Option 1 (6s trimmed version) if video runs over 90s.
34
+
35
+ ### Scene 2 (Exam Select) — Moderate Weakness
36
+
37
+ The value proposition ("40+ exams from 63 provinces") appears as metadata text at small font size. At playback speed, viewers see a list of Vietnamese titles they cannot read. The stagger animation is elegant but doesn't communicate breadth effectively in 9 seconds.
38
+
39
+ **Mitigation:** The text overlay enhancement ("40+ đề thi thật · 63 tỉnh thành") in `ai-enhancement.md` addresses this directly — make sure that overlay is added in post.
40
+
41
+ ### Scene 7 (Account + Pricing) — Risk of Anti-Climax
42
+
43
+ After the peak of Scene 6 (Oracle streaming solution), the Account page with tier pricing can feel like an appendix. The mastery rank badge is visually satisfying but the pricing table is static and text-heavy.
44
+
45
+ **Mitigation:** Lead with the mastery rank animation (the spring-physics pop is the strongest visual here) and only show pricing for the last 4s. Do not scroll through individual plan features — keep the 3-column overview visible the whole time.
46
+
47
+ ---
48
+
49
+ ## Repetitive Interactions to Avoid
50
+
51
+ - **Scrolling appears in both Scene 4 and Scene 6** — in Scene 4 (Results), scroll to AI analysis + school cards; in Scene 6 (Study Plan), scroll to checkpoint. To avoid looking repetitive: enter Scene 6 already scrolled to the checkpoint bar (no visible scroll in that scene).
52
+ - **Text appears word-by-word in Scene 4 (AI analysis) and Scene 5 (Oracle streaming)** — intentional repetition that reinforces the "AI thinking" concept, but avoid scenes back-to-back without the fade-to-black separator (already planned between 4→5).
53
+ - **Two preview modal interactions** would appear if Scene 2 shows the exam preview modal AND a later scene shows another modal — currently only one modal is shown (Scene 2), so no issue.
54
+
55
+ ---
56
+
57
+ ## Confusing Flows for Non-Vietnamese Viewers
58
+
59
+ - **Province selection context:** The school matching section in Scene 4 names "Đại học Bách Khoa Hà Nội" — international viewers will not understand the significance. Add a 2s English caption overlay: "Top Engineering University in Hanoi — selective, score-matched"
60
+ - **Credit system ("Tia"):** If the credit balance or "Không đủ Tia" warning appears, it is entirely opaque. Hide or crop the credit counter in Scenes 1–6; show it only in Scene 7 (Account) where context is provided by the pricing table.
61
+ - **Difficulty labels ("Dễ / Vừa / Khó"):** Color-coded (green/amber/red) so partially self-evident. No action needed if color coding is clearly visible at 1080p.
62
+ - **Exam naming conventions:** "THPT Quốc gia" is an unfamiliar term. The overlay enhancement "40+ đề thi thật" (real past exams) and the narration "seventy real past exams" provide sufficient context without naming the acronym.
63
+ - **Mastery rank names:** "Học sinh Tiến bộ" in Scene 7 is opaque. The color (indigo) and the rank badge design signal "achievement level" — no action needed unless the badge is a primary focus.
64
+
65
+ ---
66
+
67
+ ## Alternative Scene Orderings
68
+
69
+ ### Alternative A: "Oracle First" — Lead with the Solver
70
+
71
+ **Order:** Landing → Oracle → Exam Select → Timed Exam → Score Reveal + AI → Study Plan → Account + CTA
72
+
73
+ **Pros:**
74
+ - Oracle is universally legible — solving a math problem step-by-step requires no cultural context
75
+ - Opens with the deepest technical demonstration, immediately differentiating Zenith from quiz apps
76
+ - Strong hook for international / investor audiences where AI capability is the primary interest
77
+
78
+ **Cons:**
79
+ - Breaks the natural student session loop (you would not go to Oracle before taking an exam)
80
+ - The viewer has not yet invested emotionally in a score, so the AI Analysis and Study Plan lack narrative payoff
81
+ - Moves the confetti reveal to Scene 4 (instead of 3), losing the "peak moment at midpoint" pacing advantage
82
+ - Adds a 500ms+ fade between Oracle and Exam Select (different interaction modes)
83
+
84
+ **Best for:** International investor pitch, conference stage demo
85
+
86
+ ### Alternative B: "Province-First" — Lead with the Differentiator
87
+
88
+ **Order:** Landing → Profile Onboarding (province selection visible) → Exam Select → Timed Exam → Score Reveal → School Cards ONLY → Oracle → Account + CTA (drop Study Plan scene)
89
+
90
+ **Pros:**
91
+ - Province selection in Scene 2 sets up the "Hà Nội student" frame explicitly, making school cards in Scene 5 the payoff of a setup rather than a surprise
92
+ - Tighter — dropping Study Plan keeps the video under 75s comfortably
93
+ - Province-first is the clearest competitive differentiator message for Vietnamese-primary audience
94
+
95
+ **Cons:**
96
+ - Showing profile onboarding (province dropdown UI) risks a weak second scene — standard select list is not visually impressive
97
+ - Removes Study Plan, which is important for demonstrating the product's coaching completeness
98
+ - If onboarding modal has any UI issues (text overflow, province dropdown lag), it is front-and-center
99
+
100
+ **Best for:** Vietnamese student/parent primary audience demo
101
+
102
+ ---
103
+
104
+ ## Cut Priority (if video exceeds 90s)
105
+
106
+ Cut in this order:
107
+
108
+ 1. **Scene 3 (TestInterface) — reduce from 12s to 6s:** Show only the question card for 2s and streaming hint for 4s. Skip the Q1→Q3 keyboard navigation.
109
+ 2. **Scene 7 (Account) — reduce from 11s to 6s:** Enter already scrolled to pricing table. Skip mastery badge animation (let the narration cover it). Show pricing 3 columns for 4s, hold CTA for 2s.
110
+ 3. **Scene 2 (Exam Select) — reduce from 9s to 5s:** Skip the preview modal. Show staggered card grid for 3s, then snap cut on clicking "Bắt đầu thi."
111
+ 4. **End card — reduce from 4s to 3s.**
112
+
113
+ After all four cuts, estimated runtime: 8 + 5 + 6 + 18 + 12 + 10 + 6 + 3 = **68 seconds.** Well under target.
114
+
115
+ ---
116
+
117
+ ## Overall Confidence Score: 7.5 / 10
118
+
119
+ **What earns a high score:**
120
+ - The product has genuinely exceptional visual moments: streaming AI analysis, confetti + CountUp animation, school cards with province-matched names, Oracle KaTeX rendering, checkpoint bar mechanics
121
+ - The dark-mode design is polished and consistent — no visual "embarrassments"
122
+ - The emotional arc (tension → relief → awe → clarity → motivation) is structurally sound
123
+ - The Playwright automation isolates the demo from the existing test suite (different `testDir` config)
124
+ - The demo data is fully pre-seeded — no live API risk for the 3 most critical scenes (Results, Study Plan, AI Analysis)
125
+
126
+ **What limits the score to 7.5:**
127
+ - Scene 3 (TestInterface) is the weakest visual moment and requires a mode-switch to show hints
128
+ - The Vietnamese-only UI creates comprehension gaps for non-Vietnamese viewers that require post-production caption work
129
+ - Province-aware school matching — the single most differentiated feature — depends on Hà Nội being correctly populated in `schools.json` and `province_patterns.json`; this must be verified before any recording session
130
+ - The credit/Tia system appearing in the Navbar is a minor but persistent risk of showing "friction" before value is established
131
+
132
+ **Path to 9/10:** Add English caption overlays for school names (Scene 4), verify all province data files, and reduce Scene 3 to 6s. With those three changes, the demo would be exceptional.
demo-video/storyboard.md ADDED
@@ -0,0 +1,198 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Zenith — Launch Video Storyboard
2
+
3
+ **Target runtime:** 84 seconds · 8 scenes + end card
4
+ **Format:** 1920×1080 · 16:9 · 60fps
5
+ **Persona:** Nguyễn Minh Tuấn — Grade 12 · Hà Nội · Student tier
6
+
7
+ ---
8
+
9
+ ## Scene 1 — Hero Arrival
10
+ **Duration:** 8 seconds
11
+ **Goal:** Brand recognition + emotional hook. Establish dark, premium, Vietnamese-student-first aesthetic.
12
+
13
+ **Actions:**
14
+ - Browser opens the app. Page loads from blank — no browser chrome visible.
15
+ - Hero headline animates in word by word: "Học để hiểu," (gold #F2A20C) then "không học để quên." (white)
16
+ - Ambient glow orbs pulse slowly in the background (blue/gold radial gradients).
17
+ - Zenith logo mark (✦) is visible above the headline.
18
+ - Feature showcase carousel begins auto-cycling below the fold.
19
+
20
+ **Visual Focus:** Full-width hero headline, centered. No scrolling. Let the gold second line land and hold for 2 seconds.
21
+
22
+ **Transition:** Slow cross-dissolve (400ms) → Scene 2.
23
+
24
+ **Narration Hook:**
25
+ > "Every Vietnamese student has a target score. Zenith is the AI that shows you exactly how to reach it."
26
+
27
+ ---
28
+
29
+ ## Scene 2 — Exam Select: Choosing a Real Exam
30
+ **Duration:** 9 seconds
31
+ **Goal:** Show depth of content (40+ exams) and ease of discovery; establish province-aware filtering.
32
+
33
+ **Actions:**
34
+ - Navigate to `/exams`. Timed mode is active (default).
35
+ - Two category sections visible: "Thi THPT Quốc gia" (gold accent) and "Thi vào lớp 10" (blue accent). Cards stagger-animate in.
36
+ - Type "THPT 2024" in the search bar — cards filter to 2 results.
37
+ - Hover over "Đề thi THPT Quốc gia 2024 — Môn Toán" card (scale: 1.015 spring hover).
38
+ - Click "Bắt đầu" — preview modal slides up showing: title, 50 questions, 90 minutes, timed badge.
39
+ - Hold on preview modal for 1.5s so viewer reads the details.
40
+ - Click "Bắt đầu thi" inside the modal.
41
+
42
+ **Visual Focus:** First, wide shot of staggered card grid (3s). Then zoom to preview modal showing duration badge and gold CTA button (3s). Then snap cut on button press.
43
+
44
+ **Transition:** Snap cut → Scene 3 as TestInterface mounts.
45
+
46
+ **Narration Hook:**
47
+ > "Choose from over seventy real past exams — filtered to your province and grade level. One click to start."
48
+
49
+ ---
50
+
51
+ ## Scene 3 — TestInterface: Inside the Exam
52
+ **Duration:** 12 seconds
53
+ **Goal:** Demonstrate the immersive, distraction-free exam experience: timer, progress dots, LaTeX rendering, AI hint.
54
+
55
+ **Actions:**
56
+ 1. TestInterface loads. Starfield canvas background visible. Gold accent bar across the top.
57
+ 2. Nav bar shows: "Câu 1 / 50" left, exam title center (truncated), timer right (counting: 89:42...).
58
+ 3. Progress bar (2px, gold gradient) fills 2% beneath the navbar.
59
+ 4. QuestionCard displays a LaTeX-rendered calculus question (KaTeX rendering visible in real time).
60
+ 5. Topic badge "Tích phân" (gold pill) and difficulty badge "Trung bình" (blue pill) are visible.
61
+ 6. Press keyboard shortcut **B** — choice B highlights with an emerald flash (300ms transition).
62
+ 7. Progress dot for question 1 turns green in the dot row at bottom.
63
+ 8. Press **→** arrow key — advances to question 2. New question slides in.
64
+ 9. Press **→** again to question 3 (the calculus/hint question). Pause 1s.
65
+ 10. In practice mode scene (4b): click "Gợi ý" button — streaming hint text appears word-by-word in a popover below the answer choices.
66
+
67
+ **Visual Focus:** Steps 1–4: full question card (80% of viewport). Steps 8–10: zoom into streaming hint popover showing ~15 words appearing live (50% of viewport).
68
+
69
+ **Transition:** Cross-dissolve (300ms) → Scene 4.
70
+
71
+ **Narration Hook:**
72
+ > "Inside the exam: full-screen focus, a live countdown, and keyboard shortcuts for speed. Stuck? Request an AI hint that guides your thinking — without giving away the answer."
73
+
74
+ ---
75
+
76
+ ## Scene 4 — Submit + Results: Score, Confetti, AI Analysis
77
+ **Duration:** 18 seconds
78
+ **Goal:** The emotional peak. Show the full results flow: score CountUp → confetti burst → streaming AI insights → radar chart → school recommendation cards.
79
+
80
+ **Actions:**
81
+ 1. Results page loads at `/results/result_demo_2024_001`. Score SVG ring animates from 0° to completion.
82
+ 2. CountUp number ticks from 0.0 to **7.6** over 1.8s (gold #F2A20C, 40px).
83
+ 3. "Khá giỏi" label fades in beside the score with time taken and answered count stats.
84
+ 4. At ring completion: **confetti burst** fires (150 particles — green, blue, amber, purple, ~3s fade).
85
+ 5. Scroll down slowly to "Phân tích AI" section. Streaming text appears word-by-word:
86
+ - "Điểm mạnh rõ rệt ở Đại số (89%) và Hàm số (88%)."
87
+ - "Cần tập trung cải thiện Hình học (60%) và Tích phân (57%)."
88
+ 6. Scroll to "Hồ sơ năng lực" — **RadarChart** materializes: 7 topic axes, red for Hình học, emerald for Đại số.
89
+ 7. Click "Trường phù hợp" tab — **school cards** fade in one by one:
90
+ - "Đại học Bách Khoa Hà Nội" — score range 7.0–8.5
91
+ - "Đại học Kinh tế Quốc dân" — score range 6.5–8.0
92
+ - Animated fit bars slide left-to-right.
93
+
94
+ **Visual Focus:** Steps 1–4: centered score ring, then confetti fills frame. Steps 5–6: split view of AI text streaming below score. Step 7: school cards spanning full width.
95
+
96
+ **Transition:** Fade to black (500ms out · 500ms hold · 500ms in) → Scene 5. This breath signals the gear-shift from analysis to active problem-solving.
97
+
98
+ **Narration Hook:**
99
+ > "Submit — and your score appears instantly. If you did well, confetti. Then the AI streams your personal analysis: your strongest topics, your weakest, and — most importantly — the schools in Hà Nội you're on track to enter."
100
+
101
+ ---
102
+
103
+ ## Scene 5 — Oracle AI: Step-by-Step Math Solver
104
+ **Duration:** 12 seconds
105
+ **Goal:** Show the premium power feature — real LaTeX math solved live, step by step.
106
+
107
+ **Actions:**
108
+ 1. Navigate to `/oracle`. Page loads: dark background, Oracle bubble in idle state (slow gold/violet glow pulse), wiki status dot glows green.
109
+ 2. Click the textarea. Type (human-paced): "Tính tích phân ∫₀² (3x² - 2x + 1) dx"
110
+ 3. Symbol palette visible in toolbar below the input.
111
+ 4. Press **Ctrl+Enter** to submit. Oracle bubble transitions to "thinking" state (faster pulse).
112
+ 5. Solution streams in step by step (KaTeX renders mid-stream):
113
+ - "**Bước 1:** Xác định dạng bài: tích phân xác định tuyến tính..."
114
+ - "**Bước 2:** Tìm nguyên hàm: $F(x) = x^3 - x^2 + x + C$"
115
+ - "**Bước 3:** Áp dụng Newton-Leibniz: $F(2) - F(0) = 6$"
116
+ - "**Kết luận:** $\displaystyle\int_0^2 (3x^2 - 2x + 1)\,dx = \boxed{6}$"
117
+ 6. Oracle bubble transitions to "celebrating" state (0.7s spring scale pop).
118
+
119
+ **Visual Focus:** The streaming solution text with KaTeX formulas visually rendering mid-stream. Crop to 70% viewport width centered on the Oracle panel. The pulsing Oracle bubble glow is visible in the corner.
120
+
121
+ **Transition:** Smooth cut → Scene 6.
122
+
123
+ **Narration Hook:**
124
+ > "Oracle AI solves any math problem step by step — not just the answer, but the reasoning. Each step renders in real time, so you understand the why, not just the what."
125
+
126
+ ---
127
+
128
+ ## Scene 6 — Study Plan: Recovery Path with Checkpoint
129
+ **Duration:** 10 seconds
130
+ **Goal:** Close the learning loop — show that Zenith doesn't abandon you after a result.
131
+
132
+ **Actions:**
133
+ 1. Navigate to `/study-plan/result_demo_2024_001`. Page loads showing recovery plan.
134
+ 2. "Mục tiêu" section with score gap: "Cần cải thiện 0.4 điểm để vào Đại học Khoa học Tự nhiên."
135
+ 3. First FocusCard auto-expanded (index 0): topic "Hình học không gian."
136
+ 4. Error pattern alert (yellow): "Sai ở tính thể tích của khối chóp và khối trụ."
137
+ 5. Three practice tasks listed (bulleted).
138
+ 6. **Checkpoint bar** visible: "Đúng 3 câu liên tiếp" — 2/3 progress shown, gold-to-emerald gradient fill, animating.
139
+ 7. Click second FocusCard to expand: "Tích phân" — collapses first, second expands with tasks.
140
+
141
+ **Visual Focus:** The CheckpointBar fill animation is the hero visual. Hold 2s on the bar so the mechanic is legible. The progress fill (0 → 2/3) should be visible for at least 1.5s.
142
+
143
+ **Transition:** Cross-dissolve → Scene 7.
144
+
145
+ **Narration Hook:**
146
+ > "After every exam, Zenith builds a focused recovery path. Each topic has concrete tasks — and a checkpoint: answer three correct in a row to clear it and move on."
147
+
148
+ ---
149
+
150
+ ## Scene 7 — Account: Mastery Rank + Pricing
151
+ **Duration:** 11 seconds
152
+ **Goal:** Commercial close — show progress gamification (rank badge, credit gauge) and the 3-tier pricing structure.
153
+
154
+ **Actions:**
155
+ 1. Navigate to `/account`. Profile visible: "Nguyễn Minh Tuấn", Grade 12 badge, Province "Hà Nội."
156
+ 2. **SVG credit gauge** animates (arc fill, gold color): 50 credits shown on the gauge.
157
+ 3. **Mastery rank badge**: "Học sinh Tiến bộ" in indigo (#818CF8) — spring pop animation (0.7s, stiffness 320).
158
+ 4. Scroll down to pricing table: 3 tier cards animate in:
159
+ - "Cơ bản — Miễn phí" (gray border)
160
+ - "Học sinh — 29,000đ/tháng" with **"PHỔ BIẾN"** badge in gold (highlighted card, raised shadow)
161
+ - "Toàn diện — 59,000đ/tháng" (indigo border)
162
+ 5. Hold 3s on pricing table — wide enough to show all 3 cards clearly.
163
+ 6. "Bắt đầu miễn phí" CTA button on "Cơ bản" card is visible in gold.
164
+
165
+ **Visual Focus:** First 4s on profile + rank badge. Last 4s on pricing table ensuring "PHỔ BIẾN" badge is readable and all 3 tier columns are fully visible.
166
+
167
+ **Transition:** Fade to black (500ms) → End card.
168
+
169
+ **Narration Hook:**
170
+ > "Watch your mastery rank rise with every session. Start free — upgrade when you're ready for unlimited AI, personalized study plans, and score prediction."
171
+
172
+ ---
173
+
174
+ ## End Card
175
+ **Duration:** 4 seconds (not counted in main scene total)
176
+
177
+ **Content:**
178
+ - Background: `#0A0E1A` deep dark with a single slow amber glow pulse
179
+ - Center: Zenith logo mark ✦ in `#F2A20C` at 48px
180
+ - Headline: "Luyện thi thông minh hơn." (Fraunces Italic, 28px, white)
181
+ - Subline: "AI phân tích · 63 tỉnh thành · Từ 29,000đ / tháng" (Jakarta Sans, 14px, `#94A3B8`)
182
+ - URL: "zenith.vn" centered at bottom
183
+
184
+ ---
185
+
186
+ ## Scene Duration Summary
187
+
188
+ | # | Scene | Duration |
189
+ |---|-------|----------|
190
+ | 1 | Hero Arrival | 8s |
191
+ | 2 | Exam Select → Preview → Start | 9s |
192
+ | 3 | TestInterface — Exam + Hint | 12s |
193
+ | 4 | Results — Score + Confetti + AI Analysis + Schools | 18s |
194
+ | 5 | Oracle AI — Streaming Solution | 12s |
195
+ | 6 | Study Plan — Recovery Path + Checkpoint | 10s |
196
+ | 7 | Account — Mastery Rank + Pricing | 11s |
197
+ | — | End Card | 4s |
198
+ | **Total** | | **84s** |
deploy-hf.sh ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # Deploy latest master to Hugging Face Spaces
3
+ # Usage: ./deploy-hf.sh
4
+
5
+ set -e
6
+
7
+ SPACE_REMOTE="space"
8
+ SPACE_URL="https://huggingface.co/spaces/MinhTai/ai-agent-app"
9
+ HEALTH_URL="https://minhtai-ai-agent-app.hf.space/health"
10
+
11
+ echo "==> Checking remote..."
12
+ if ! git remote get-url "$SPACE_REMOTE" &>/dev/null; then
13
+ echo "Adding '$SPACE_REMOTE' remote..."
14
+ git remote add "$SPACE_REMOTE" "$SPACE_URL"
15
+ fi
16
+
17
+ CURRENT_BRANCH=$(git branch --show-current)
18
+
19
+ echo "==> Switching to hf-deploy branch..."
20
+ git checkout hf-deploy
21
+
22
+ echo "==> Merging master..."
23
+ git merge master --no-edit
24
+
25
+ echo "==> Pushing to HF Space..."
26
+ git push --force "$SPACE_REMOTE" hf-deploy:main
27
+
28
+ echo "==> Switching back to $CURRENT_BRANCH..."
29
+ git checkout "$CURRENT_BRANCH"
30
+
31
+ echo ""
32
+ echo "Deployed. Build logs: $SPACE_URL"
33
+ echo "Health check (wait ~30s for rebuild): curl $HEALTH_URL"
design-system/.design-sync/NOTES.md ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Sync Notes
2
+
3
+ ## First sync — June 2026
4
+
5
+ - Shape: `package` (no Storybook; 28 named exports from 15 component files)
6
+ - `vite-plugin-dts` required for component discovery — generates `.d.ts` in `dist/src/`
7
+ - `types` field in `package.json` → `./dist/src/index.d.ts` (not `./dist/index.d.ts`)
8
+ - Google Fonts CDN import triggers `[FONT_REMOTE]` info — non-blocking, families served at runtime
9
+
10
+ ## Browser-event gated components
11
+
12
+ `InstallPrompt` and `OfflineBanner` return `null` until browser events fire
13
+ (`beforeinstallprompt` / `navigator.onLine === false`). Their previews use
14
+ inline-styles reproductions of the visible-state JSX — not the real component.
15
+ If the component UI changes, update `.design-sync/previews/InstallPrompt.tsx`
16
+ and `.design-sync/previews/OfflineBanner.tsx` manually.
17
+
18
+ ## Skeleton exports
19
+
20
+ `Skeleton.jsx` exports 12+ named variants. Each is registered as its own
21
+ component in ds-bundle (SkeletonBlock is the primitive; the rest are page-level
22
+ skeletons with zero props).
23
+
24
+ ## Re-sync
25
+
26
+ Run from `design-system/`:
27
+ ```
28
+ node .ds-sync/resync.mjs
29
+ ```
design-system/.design-sync/config.json ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ {
2
+ "pkg": "@zenith/ui",
3
+ "globalName": "ZenithUI",
4
+ "projectId": "0ec9eeac-7e63-41aa-a395-7ec36d59b131",
5
+ "shape": "package",
6
+ "buildCmd": "npm run build"
7
+ }
design-system/.design-sync/previews/AIErrorBoundary.tsx ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { AIErrorBoundary } from '@zenith/ui'
2
+
3
+ export const Idle = () => (
4
+ <div style={{ padding: 24, background: 'var(--background)' }}>
5
+ <AIErrorBoundary>
6
+ <div style={{ fontFamily: 'var(--font-sans)', color: 'var(--foreground)', fontSize: 14 }}>
7
+ AI analysis content renders here when there is no error.
8
+ </div>
9
+ </AIErrorBoundary>
10
+ </div>
11
+ )
design-system/.design-sync/previews/AccountPageSkeleton.tsx ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import { AccountPageSkeleton } from '@zenith/ui'
2
+
3
+ export const Default = () => (
4
+ <div style={{ padding: 24, background: 'var(--background)' }}>
5
+ <div style={{ maxWidth: 800, margin: '0 auto' }}>
6
+ <AccountPageSkeleton />
7
+ </div>
8
+ </div>
9
+ )
design-system/.design-sync/previews/AchievementCeremony.tsx ADDED
@@ -0,0 +1,43 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { AchievementCeremony } from '@zenith/ui'
2
+
3
+ /**
4
+ * trigger=true — animates in on mount (simulates freshly-earned milestone).
5
+ * Wrap with className for layout context.
6
+ */
7
+ export const PerfectScore = () => (
8
+ <div style={{ padding: 24, background: 'var(--background)', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12 }}>
9
+ <AchievementCeremony trigger={true} className="block text-center">
10
+ <div style={{ fontSize: 64 }}>🏆</div>
11
+ <div style={{ fontSize: 18, fontWeight: 700, color: 'var(--foreground)', marginTop: 8 }}>
12
+ Xuất sắc! Điểm tuyệt đối
13
+ </div>
14
+ <div style={{ fontSize: 13, color: 'var(--primary)', marginTop: 4 }}>
15
+ 10/10 câu đúng — Đề Toán THPT 2024
16
+ </div>
17
+ </AchievementCeremony>
18
+ </div>
19
+ )
20
+
21
+ /**
22
+ * trigger=false — component renders but stays invisible (scale 0.7, opacity 0).
23
+ * Shows the "not yet triggered" resting state.
24
+ */
25
+ export const NotYetEarned = () => (
26
+ <div style={{ padding: 24, background: 'var(--background)', display: 'flex', flexDirection: 'column', alignItems: 'center', gap: 12 }}>
27
+ <div style={{ fontSize: 13, color: 'var(--foreground)', marginBottom: 8 }}>
28
+ Trạng thái chưa đạt (trigger=false — ẩn hoàn toàn):
29
+ </div>
30
+ <AchievementCeremony trigger={false} className="block text-center">
31
+ <div style={{ fontSize: 64 }}>🎖️</div>
32
+ <div style={{ fontSize: 18, fontWeight: 700, color: 'var(--foreground)', marginTop: 8 }}>
33
+ Hoàn thành 7 ngày liên tiếp
34
+ </div>
35
+ <div style={{ fontSize: 13, color: 'var(--accent)', marginTop: 4 }}>
36
+ Streak học tập 7 ngày
37
+ </div>
38
+ </AchievementCeremony>
39
+ <div style={{ fontSize: 11, color: 'var(--foreground)', opacity: 0.4, marginTop: 8 }}>
40
+ (không nhìn thấy nội dung bên trên — đang ở trạng thái ẩn)
41
+ </div>
42
+ </div>
43
+ )
design-system/.design-sync/previews/CreditsTooltip.tsx ADDED
@@ -0,0 +1,64 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { CreditsTooltip } from '@zenith/ui'
2
+
3
+ /**
4
+ * CreditsTooltip is shown once per userId (persisted to localStorage).
5
+ * To force-show in previews we use a unique userId that hasn't been seen.
6
+ * The tooltip is position:absolute, so we need a relative container.
7
+ *
8
+ * SufficientCredits — user has a healthy credit balance (25 lượt).
9
+ * Note: the component manages its own visibility via localStorage.
10
+ * A fresh userId guarantees it renders on first mount.
11
+ */
12
+ export const SufficientCredits = () => (
13
+ <div style={{ padding: 24, background: 'var(--background)', minHeight: 220 }}>
14
+ <div style={{ position: 'relative', display: 'inline-block' }}>
15
+ <div
16
+ style={{
17
+ padding: '6px 12px',
18
+ border: '1px solid var(--border)',
19
+ borderRadius: 8,
20
+ fontSize: 13,
21
+ color: 'var(--foreground)',
22
+ background: 'var(--surface)',
23
+ cursor: 'default',
24
+ }}
25
+ >
26
+ ⚡ 25 lượt
27
+ </div>
28
+ <CreditsTooltip
29
+ userId="preview-user-sufficient-001"
30
+ creditsBalance={25}
31
+ onDismiss={() => {}}
32
+ />
33
+ </div>
34
+ </div>
35
+ )
36
+
37
+ /**
38
+ * LowCredits — user is running low (3 lượt remaining).
39
+ * Same component, different creditsBalance value shown in the heading.
40
+ */
41
+ export const LowCredits = () => (
42
+ <div style={{ padding: 24, background: 'var(--background)', minHeight: 220 }}>
43
+ <div style={{ position: 'relative', display: 'inline-block' }}>
44
+ <div
45
+ style={{
46
+ padding: '6px 12px',
47
+ border: '1px solid var(--border)',
48
+ borderRadius: 8,
49
+ fontSize: 13,
50
+ color: 'var(--foreground)',
51
+ background: 'var(--surface)',
52
+ cursor: 'default',
53
+ }}
54
+ >
55
+ ⚡ 3 lượt
56
+ </div>
57
+ <CreditsTooltip
58
+ userId="preview-user-low-001"
59
+ creditsBalance={3}
60
+ onDismiss={() => {}}
61
+ />
62
+ </div>
63
+ </div>
64
+ )
design-system/.design-sync/previews/ExamSelectSkeleton.tsx ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import { ExamSelectSkeleton } from '@zenith/ui'
2
+
3
+ export const Default = () => (
4
+ <div style={{ padding: 24, background: 'var(--background)' }}>
5
+ <div style={{ maxWidth: 800, margin: '0 auto' }}>
6
+ <ExamSelectSkeleton />
7
+ </div>
8
+ </div>
9
+ )
design-system/.design-sync/previews/FormulaDrawer.tsx ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { FormulaDrawer } from '@zenith/ui'
2
+
3
+ /**
4
+ * FormulaDrawer manages its own open/closed state internally — no props required.
5
+ * It renders as a fixed bottom-sheet overlay in production, so we contain it with
6
+ * position:relative + overflow:hidden to keep the preview self-contained.
7
+ *
8
+ * Open — the toggle button is always visible; click it to open the drawer.
9
+ * Since the drawer is self-controlled, we show the toggle button in context.
10
+ */
11
+ export const ToggleButton = () => (
12
+ <div
13
+ style={{
14
+ padding: 24,
15
+ background: 'var(--background)',
16
+ position: 'relative',
17
+ height: 400,
18
+ overflow: 'hidden',
19
+ display: 'flex',
20
+ flexDirection: 'column',
21
+ gap: 8,
22
+ }}
23
+ >
24
+ <div style={{ fontSize: 13, color: 'var(--foreground)', opacity: 0.6, marginBottom: 8 }}>
25
+ Nhấn nút bên dưới để mở bảng công thức:
26
+ </div>
27
+ <FormulaDrawer />
28
+ </div>
29
+ )
30
+
31
+ /**
32
+ * InContext — simulates FormulaDrawer sitting alongside an exam question,
33
+ * showing how the toggle button fits within a toolbar row.
34
+ */
35
+ export const InContext = () => (
36
+ <div
37
+ style={{
38
+ padding: 24,
39
+ background: 'var(--background)',
40
+ position: 'relative',
41
+ height: 480,
42
+ overflow: 'hidden',
43
+ }}
44
+ >
45
+ {/* Mock question toolbar */}
46
+ <div
47
+ style={{
48
+ padding: '12px 16px',
49
+ background: 'var(--surface)',
50
+ border: '1px solid var(--border)',
51
+ borderRadius: 12,
52
+ marginBottom: 16,
53
+ fontSize: 13,
54
+ color: 'var(--foreground)',
55
+ lineHeight: 1.6,
56
+ }}
57
+ >
58
+ <div style={{ fontWeight: 600, marginBottom: 6 }}>Câu 12.</div>
59
+ <div>
60
+ Giải phương trình: $x^2 - 5x + 6 = 0$
61
+ </div>
62
+ <div style={{ marginTop: 8, display: 'flex', gap: 8, alignItems: 'center' }}>
63
+ <span style={{ fontSize: 11, color: 'var(--foreground)', opacity: 0.5 }}>Công cụ:</span>
64
+ <FormulaDrawer />
65
+ </div>
66
+ </div>
67
+ </div>
68
+ )
design-system/.design-sync/previews/HistoryPageSkeleton.tsx ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import { HistoryPageSkeleton } from '@zenith/ui'
2
+
3
+ export const Default = () => (
4
+ <div style={{ padding: 24, background: 'var(--background)' }}>
5
+ <div style={{ maxWidth: 800, margin: '0 auto' }}>
6
+ <HistoryPageSkeleton />
7
+ </div>
8
+ </div>
9
+ )
design-system/.design-sync/previews/HomePageSkeleton.tsx ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ import { HomePageSkeleton } from '@zenith/ui'
2
+
3
+ export const Default = () => (
4
+ <div style={{ padding: 24, background: 'var(--background)' }}>
5
+ <div style={{ maxWidth: 800, margin: '0 auto' }}>
6
+ <HomePageSkeleton />
7
+ </div>
8
+ </div>
9
+ )
design-system/.design-sync/previews/InstallPrompt.tsx ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ export const Visible = () => (
2
+ <div style={{ padding: 24, background: 'var(--background)' }}>
3
+ <div style={{
4
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
5
+ background: 'var(--surface)', borderRadius: 16, padding: 20, maxWidth: 360,
6
+ border: '1px solid color-mix(in srgb, var(--primary) 20%, transparent)',
7
+ }}>
8
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
9
+ <span style={{ fontFamily: 'var(--font-sans)', fontSize: 13, fontWeight: 600, color: 'var(--foreground)' }}>
10
+ Thêm vào màn hình chính
11
+ </span>
12
+ <span style={{ fontFamily: 'var(--font-sans)', fontSize: 11, color: 'var(--muted-foreground)' }}>
13
+ Trải nghiệm nhanh hơn, offline được
14
+ </span>
15
+ </div>
16
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
17
+ <button style={{
18
+ padding: '6px 16px', borderRadius: 8, border: 'none', cursor: 'pointer',
19
+ fontFamily: 'var(--font-sans)', fontSize: 12, fontWeight: 700,
20
+ background: 'var(--primary)', color: 'var(--background)',
21
+ }}>Thêm</button>
22
+ <span style={{ fontSize: 20, lineHeight: 1, color: 'var(--muted-foreground)', cursor: 'pointer' }}>×</span>
23
+ </div>
24
+ </div>
25
+ </div>
26
+ )
27
+
28
+ export const WithCustomMessage = () => (
29
+ <div style={{ padding: 24, background: 'var(--surface)' }}>
30
+ <div style={{
31
+ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12,
32
+ background: 'var(--background)', borderRadius: 16, padding: 20, maxWidth: 360,
33
+ border: '1px solid color-mix(in srgb, var(--border) 60%, transparent)',
34
+ }}>
35
+ <div style={{ display: 'flex', flexDirection: 'column', gap: 2 }}>
36
+ <span style={{ fontFamily: 'var(--font-sans)', fontSize: 13, fontWeight: 600, color: 'var(--foreground)' }}>
37
+ Thêm vào màn hình chính
38
+ </span>
39
+ <span style={{ fontFamily: 'var(--font-sans)', fontSize: 11, color: 'var(--muted-foreground)' }}>
40
+ Trải nghiệm nhanh hơn, offline được
41
+ </span>
42
+ </div>
43
+ <div style={{ display: 'flex', alignItems: 'center', gap: 8, flexShrink: 0 }}>
44
+ <button style={{
45
+ padding: '6px 16px', borderRadius: 8, border: 'none', cursor: 'pointer',
46
+ fontFamily: 'var(--font-sans)', fontSize: 12, fontWeight: 700,
47
+ background: 'var(--primary)', color: 'var(--background)',
48
+ }}>Thêm</button>
49
+ <span style={{ fontSize: 20, lineHeight: 1, color: 'var(--muted-foreground)', cursor: 'pointer' }}>×</span>
50
+ </div>
51
+ </div>
52
+ </div>
53
+ )
design-system/.design-sync/previews/LockedFeatureCard.tsx ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { LockedFeatureCard } from '@zenith/ui'
2
+
3
+ export const StudentTier = () => (
4
+ <div style={{ padding: 24, background: 'var(--background)', maxWidth: 360 }}>
5
+ <LockedFeatureCard
6
+ label="Kế hoạch học tập AI"
7
+ tier="student"
8
+ />
9
+ </div>
10
+ )
11
+
12
+ export const CompleteTierWithUpgrade = () => (
13
+ <div style={{ padding: 24, background: 'var(--background)', maxWidth: 360 }}>
14
+ <LockedFeatureCard
15
+ label="Phân tích chuyên sâu"
16
+ tier="complete"
17
+ onUpgrade={() => {}}
18
+ />
19
+ </div>
20
+ )
design-system/.design-sync/previews/MarkdownProse.tsx ADDED
@@ -0,0 +1,67 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { MarkdownProse } from '@zenith/ui'
2
+
3
+ /**
4
+ * BasicMarkdown — short paragraph with bold, italic, inline code, and a list.
5
+ */
6
+ export const BasicMarkdown = () => (
7
+ <div style={{ padding: 24, background: 'var(--background)', maxWidth: 600 }}>
8
+ <MarkdownProse>
9
+ {`## Phân tích kết quả học tập
10
+
11
+ Bạn đã hoàn thành **10/12 câu** trong đề thi thử Toán THPT Quốc Gia.
12
+ Đây là kết quả *khá tốt* so với mức trung bình của lớp.
13
+
14
+ Những chủ đề cần ôn tập thêm:
15
+
16
+ - Hàm số và đồ thị
17
+ - Phương trình lượng giác
18
+ - Tích phân bất định
19
+
20
+ Hãy dùng \`/study-plan\` để tạo kế hoạch ôn tập cá nhân hoá.`}
21
+ </MarkdownProse>
22
+ </div>
23
+ )
24
+
25
+ /**
26
+ * WithMath — note: MarkdownProse uses remark-gfm only (no remark-math),
27
+ * so LaTeX expressions render as plain text. This story demonstrates how
28
+ * a math formula looks in the prose context (unstyled, literal string).
29
+ * Use MathBlock/MathText for rendered LaTeX.
30
+ */
31
+ export const WithMath = () => (
32
+ <div style={{ padding: 24, background: 'var(--background)', maxWidth: 600 }}>
33
+ <MarkdownProse>
34
+ {`### Nghiệm của phương trình bậc hai
35
+
36
+ Cho phương trình $ax^2 + bx + c = 0$ (với $a \\neq 0$),
37
+ nghiệm được tính theo công thức:
38
+
39
+ $$x = \\frac{-b \\pm \\sqrt{b^2-4ac}}{2a}$$
40
+
41
+ **Ví dụ:** Giải $2x^2 - 7x + 3 = 0$.
42
+
43
+ Với $a=2$, $b=-7$, $c=3$, ta có $\\Delta = 49 - 24 = 25 > 0$,
44
+ nên phương trình có hai nghiệm phân biệt.`}
45
+ </MarkdownProse>
46
+ </div>
47
+ )
48
+
49
+ /**
50
+ * WithTable — markdown table showing a score breakdown by topic.
51
+ */
52
+ export const WithTable = () => (
53
+ <div style={{ padding: 24, background: 'var(--background)', maxWidth: 640 }}>
54
+ <MarkdownProse>
55
+ {`### Bảng điểm theo chủ đề
56
+
57
+ | Chủ đề | Số câu | Đúng | Tỉ lệ |
58
+ |--------|--------|------|-------|
59
+ | Hàm số | 4 | 3 | 75% |
60
+ | Tích phân | 3 | 2 | 67% |
61
+ | Lượng giác | 3 | 3 | 100% |
62
+ | Tổ hợp – Xác suất | 2 | 1 | 50% |
63
+
64
+ > **Nhận xét:** Bạn làm tốt nhất ở phần Lượng giác và cần cải thiện Tổ hợp – Xác suất.`}
65
+ </MarkdownProse>
66
+ </div>
67
+ )
design-system/.design-sync/previews/MathBlock.tsx ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { MathBlock } from '@zenith/ui'
2
+
3
+ /**
4
+ * Integral — displays a definite integral with solution.
5
+ * MathBlock uses remark-math + rehype-katex so $…$ and $$…$$ are rendered by KaTeX.
6
+ */
7
+ export const Integral = () => (
8
+ <div style={{ padding: 24, background: 'var(--background)', maxWidth: 560 }}>
9
+ <MathBlock>
10
+ {'Tính tích phân sau:\n\n$$\\int_0^1 x^2 \\, dx = \\left[\\frac{x^3}{3}\\right]_0^1 = \\frac{1}{3}$$\n\nVậy diện tích hình phẳng giới hạn bởi $y = x^2$, trục $Ox$ và $x = 1$ bằng $\\dfrac{1}{3}$.'}
11
+ </MathBlock>
12
+ </div>
13
+ )
14
+
15
+ /**
16
+ * QuadraticFormula — the quadratic formula with discriminant discussion.
17
+ */
18
+ export const QuadraticFormula = () => (
19
+ <div style={{ padding: 24, background: 'var(--background)', maxWidth: 560 }}>
20
+ <MathBlock>
21
+ {'**Nghiệm phương trình bậc hai** $ax^2 + bx + c = 0$:\n\n$$x = \\frac{-b \\pm \\sqrt{b^2 - 4ac}}{2a}$$\n\nPhân tích biệt thức $\\Delta = b^2 - 4ac$:\n\n- $\\Delta > 0$: hai nghiệm phân biệt $x_1, x_2$\n- $\\Delta = 0$: nghiệm kép $x = -\\dfrac{b}{2a}$\n- $\\Delta < 0$: vô nghiệm thực'}
22
+ </MathBlock>
23
+ </div>
24
+ )
25
+
26
+ /**
27
+ * Matrix — a 2×2 matrix determinant formula with example.
28
+ */
29
+ export const Matrix = () => (
30
+ <div style={{ padding: 24, background: 'var(--background)', maxWidth: 560 }}>
31
+ <MathBlock>
32
+ {'**Định thức ma trận** $2 \\times 2$:\n\n$$\\det(A) = \\begin{vmatrix} a & b \\\\ c & d \\end{vmatrix} = ad - bc$$\n\n**Ví dụ:** Tính định thức của $A = \\begin{pmatrix} 3 & 1 \\\\ 2 & 4 \\end{pmatrix}$\n\n$$\\det(A) = 3 \\cdot 4 - 1 \\cdot 2 = 12 - 2 = 10$$'}
33
+ </MathBlock>
34
+ </div>
35
+ )
design-system/.design-sync/previews/MathText.tsx ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { MathText } from '@zenith/ui'
2
+
3
+ export const InlineArithmetic = () => (
4
+ <div style={{ padding: 24, background: 'var(--background)', fontFamily: 'var(--font-sans)' }}>
5
+ <p style={{ color: 'var(--foreground)', fontSize: 15, lineHeight: 1.6 }}>
6
+ Cho biết <MathText>$a = 3$</MathText> và <MathText>$b = 4$</MathText>,
7
+ tính <MathText>{'$\\sqrt{a^2 + b^2}$'}</MathText>.
8
+ </p>
9
+ </div>
10
+ )
11
+
12
+ export const Fraction = () => (
13
+ <div style={{ padding: 24, background: 'var(--background)' }}>
14
+ <p style={{ color: 'var(--foreground)', fontSize: 15, lineHeight: 1.6 }}>
15
+ Rút gọn biểu thức: <MathText>{`$\\frac{x^2 - 1}{x + 1}$`}</MathText>
16
+ </p>
17
+ </div>
18
+ )
19
+
20
+ export const Calculus = () => (
21
+ <div style={{ padding: 24, background: 'var(--background)' }}>
22
+ <p style={{ color: 'var(--foreground)', fontSize: 15, lineHeight: 1.6 }}>
23
+ Tính tích phân <MathText>{`$\\int_0^1 x^2 \\, dx$`}</MathText> bằng cách dùng công thức <MathText>{`$\\int x^n dx = \\frac{x^{n+1}}{n+1} + C$`}</MathText>.
24
+ </p>
25
+ </div>
26
+ )
27
+
28
+ export const WithTable = () => (
29
+ <div style={{ padding: 24, background: 'var(--background)', maxWidth: 400 }}>
30
+ <MathText>{`| $x$ | $f(x)$ |\n|---|---|\n| 0 | 1 |\n| 1 | 2 |\n| 2 | 5 |`}</MathText>
31
+ </div>
32
+ )
design-system/.design-sync/previews/NavbarSkeleton.tsx ADDED
@@ -0,0 +1,7 @@
 
 
 
 
 
 
 
 
1
+ import { NavbarSkeleton } from '@zenith/ui'
2
+
3
+ export const Default = () => (
4
+ <div style={{ padding: 24, background: 'var(--background)' }}>
5
+ <NavbarSkeleton />
6
+ </div>
7
+ )
design-system/.design-sync/previews/NumberTicker.tsx ADDED
@@ -0,0 +1,42 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { NumberTicker } from '@zenith/ui'
2
+
3
+ export const Score = () => (
4
+ <div style={{ padding: 24, background: 'var(--background)' }}>
5
+ <NumberTicker
6
+ value={85}
7
+ startValue={0}
8
+ direction="up"
9
+ decimalPlaces={0}
10
+ className="text-4xl font-bold"
11
+ style={{ color: 'var(--primary)', fontFamily: 'var(--font-sans)' }}
12
+ />
13
+ </div>
14
+ )
15
+
16
+ export const Percentage = () => (
17
+ <div style={{ padding: 24, background: 'var(--background)', display: 'flex', alignItems: 'baseline', gap: 2 }}>
18
+ <NumberTicker
19
+ value={67}
20
+ startValue={0}
21
+ direction="up"
22
+ decimalPlaces={0}
23
+ className="text-3xl font-semibold"
24
+ style={{ color: 'var(--foreground)', fontFamily: 'var(--font-sans)' }}
25
+ />
26
+ <span style={{ fontSize: 18, color: 'var(--foreground)', fontFamily: 'var(--font-sans)', opacity: 0.7 }}>%</span>
27
+ </div>
28
+ )
29
+
30
+ export const Large = () => (
31
+ <div style={{ padding: 24, background: 'var(--background)', display: 'flex', alignItems: 'baseline', gap: 4 }}>
32
+ <NumberTicker
33
+ value={1240}
34
+ startValue={0}
35
+ direction="up"
36
+ decimalPlaces={0}
37
+ className="text-5xl font-bold"
38
+ style={{ color: 'var(--accent)', fontFamily: 'var(--font-sans)' }}
39
+ />
40
+ <span style={{ fontSize: 20, color: 'var(--foreground)', fontFamily: 'var(--font-sans)', opacity: 0.6 }}>pts</span>
41
+ </div>
42
+ )