diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..bdfb9857a4f2da20e38fcb2c2f4a494add1e896c --- /dev/null +++ b/.dockerignore @@ -0,0 +1,51 @@ +# Python caches +**/__pycache__/ +**/*.py[cod] +**/*.pyo +.venv/ +venv/ +*.egg-info/ +dist/ +build/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# Node +node_modules/ +exam-app/ +!exam-app/src/data/exams.json +!exam-app/src/data/questions.json + +# Env files (set secrets in Koyeb dashboard, not baked into image) +.env +*.env.local + +# Local wiki artifacts (rebuilt at runtime) +math_wiki.db +math_wiki.db-shm +math_wiki.db-wal +math_wiki.bm25.pkl +math_wiki.faiss +math_wiki.meta.pkl +backend/math_wiki.db + +# Dev/test artifacts +*.png +*.pen +test-results/ +tests/ +docs/ +core/ +validators/ +exam-app-plan.md +response-*.json +.gitnexus/ +.playwright-mcp/ +.claude/ +.kilo/ +playwright.config.js + +# Git +.git/ +.gitignore diff --git a/.env.example b/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..fee382cbbcefda0306b07b3ad8f2eba28fb0c38d --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +ANTHROPIC_BASE_URL=https://ai-router.locdo.tech +ANTHROPIC_AUTH_TOKEN=your-auth-token-here +ANTHROPIC_DEFAULT_OPUS_MODEL=claude-opus-4.6 +ANTHROPIC_DEFAULT_SONNET_MODEL=claude-sonnet-4.6 +ANTHROPIC_DEFAULT_HAIKU_MODEL=claude-haiku-4.5 +EMBEDDING_MODEL_NAME=BAAI/bge-m3 +EMBEDDING_DIM=1024 diff --git a/.github/workflows/admin-key-log.yml b/.github/workflows/admin-key-log.yml new file mode 100644 index 0000000000000000000000000000000000000000..5aa2d6ede9e8398c757106fda622ec9ccc56be36 --- /dev/null +++ b/.github/workflows/admin-key-log.yml @@ -0,0 +1,33 @@ +name: Admin Key Log (fallback) + +# Fallback scheduler — fires if cron-job.org misses a run. +# Primary scheduler: cron-job.org (POST /admin/generate-key-log, X-Cron-Secret header) +# This workflow is a safety net only; cron-job.org is preferred. +# +# Required GitHub repo secrets: +# HF_SPACE_URL — e.g. https://your-space.hf.space +# CRON_SECRET — same value as CRON_SECRET in HF Secrets (≥32 chars) +# +# Schedule: 20:05 UTC Sunday = 03:05 ICT Monday (5 min after cron-job.org fires at 20:00) +# If cron-job.org already ran, the backend will just append a duplicate line — harmless. + +on: + schedule: + - cron: "5 20 * * 0" + workflow_dispatch: + +jobs: + trigger-key-log: + runs-on: ubuntu-latest + steps: + - name: Trigger key log generation + run: | + HTTP_STATUS=$(curl -s -o /dev/null -w "%{http_code}" \ + -X POST "${{ secrets.HF_SPACE_URL }}/admin/generate-key-log" \ + -H "X-Cron-Secret: ${{ secrets.CRON_SECRET }}" \ + -H "Content-Type: application/json") + echo "Response status: $HTTP_STATUS" + if [ "$HTTP_STATUS" != "200" ]; then + echo "Key log generation failed with status $HTTP_STATUS" + exit 1 + fi diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..5ae8d8ff5bcff4d636932ea69cb26112e2464fc1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,81 @@ +# Environment +.env +*.env.local + +# Python +__pycache__/ +*.py[cod] +*.pyo +.venv/ +venv/ +*.egg-info/ +dist/ +build/ +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ + +# Node +node_modules/ +exam-app/dist/ + +# Claude Code local settings (machine-specific) +.claude/settings.local.json + +# GitNexus index (regenerated via `gitnexus analyze`) +.gitnexus/ + +# Ingestion state (local run progress, not source) +exam-app/scripts/ingest/state.json +backend/scripts/ingest/ingest_state.json + +# Cloudflare Pages / Wrangler local state +.wrangler/ + +# Misc +*.pen +.DS_Store +Thumbs.db + +# Screenshots (all root-level PNGs are dev/test artifacts) +*.png +# Exception: question figure images are static assets shipped with the app +!exam-app/public/images/questions/*.png + +# Math wiki local artifacts (regenerated by ingest pipeline) +math_wiki.db +math_wiki.db-shm +math_wiki.db-wal +math_wiki.bm25.pkl +math_wiki.faiss +math_wiki.meta.pkl +backend/math_wiki.db + +# Crawl runtime state +scripts/crawl_progress.json + +# Test results +test-results/ + +# Playwright MCP cache +.playwright-mcp/ + +# Local dev/agent artifacts +.claude/ +docs/ +exam-app-plan.md + +# Kilo Code editor state +.kilo/ + +# Standalone agent framework (not integrated into the app) +core/ + +# Unused standalone validators (not imported by backend or exam-app) +validators/ + +# Empty local dev API response dumps +response-*.json + +# Runtime caches +scripts/.pauls_sentences_cache.json diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000000000000000000000000000000000000..fea47a796830bc36fc1e80d1cd1ffb5c755196e6 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,87 @@ +## Auth & Credit System + +### Auth-required endpoints +All AI endpoints (`/analyze`, `/hint`, `/explain`, `/study-plan`) require a valid JWT in `Authorization: Bearer `. The token is obtained from `POST /auth/google`. + +### Credit deduction per feature +| Feature | Endpoint | Credits | +|---|---|---| +| Socratic hint | POST /hint | 1 | +| Answer explanation | POST /explain | 1 | +| Result analysis | POST /analyze | 3 | +| Study plan | POST /study-plan | 5 | + +`/study-plan` also requires `subscription_tier` ∈ {student, complete} — returns 403 `tier_required` otherwise. + +### Getting the current admin key + +Admin keys rotate automatically (default: weekly). Get the current key from either: +1. **HF Spaces** → Files tab → `/data/admin_keys.txt` → copy the latest line's key +2. **Local fallback**: `python tools/gen_admin_key.py` (prompts for `ADMIN_MASTER_SECRET`) + +### Granting manual top-ups (admin) +``` +POST /admin/users/{user_id}/credits +X-Admin-Key: +{"amount": 500, "reason": "manual_topup_bank_transfer"} +``` + +### Activating subscriptions (admin) +``` +POST /admin/users/{user_id}/subscription +X-Admin-Key: +{"tier": "student", "period": "monthly", "expires_at": "2026-06-15T00:00:00Z", "bonus_credits": 0} +``` + +### Suspending abusive accounts (admin) +``` +POST /admin/users/{user_id}/suspend +X-Admin-Key: +{"reason": "credit_velocity abuse"} +``` + +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`. + + +# GitNexus — Code Intelligence + +This project is indexed by GitNexus as **AI-Agent-App** (5942 symbols, 18107 relationships, 265 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. + +> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. + +## Always Do + +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. +- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. +- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. +- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. +- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. + +## Never Do + +- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. +- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. +- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. +- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. + +## Resources + +| Resource | Use for | +|----------|---------| +| `gitnexus://repo/AI-Agent-App/context` | Codebase overview, check index freshness | +| `gitnexus://repo/AI-Agent-App/clusters` | All functional areas | +| `gitnexus://repo/AI-Agent-App/processes` | All execution flows | +| `gitnexus://repo/AI-Agent-App/process/{name}` | Step-by-step execution trace | + +## CLI + +| Task | Read this skill file | +|------|---------------------| +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | +| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | + + diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000000000000000000000000000000000000..1095d8562e600ba4e81cf84fcac6058a7715d767 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,271 @@ +# AI Agent App + +Zenith — AI-native adaptive learning system for Vietnamese students (FastAPI + Claude via ai-router proxy). + +## Stack + +- **Python** — FastAPI, pydantic-settings, tenacity, openai SDK (>=1.58.0) +- **Runtime** — uvicorn +- **AI** — Claude models via internal OpenAI-compatible proxy at `https://ai-router.locdo.tech` + +## Dev commands + +```bash +# Run both backend + frontend together (preferred) +npm install # install concurrently (root, first time only) +npm run dev # starts backend :8000 and frontend :5173 concurrently + +# Backend only +pip install -r requirements.txt +PYTHONPATH=backend uvicorn app.main:app --reload # http://localhost:8000 +python3 -m pytest backend/tests/ # run tests + +# Frontend only +cd exam-app && npm install && npm run dev # http://localhost:5173 +``` + +## Project structure + +``` +backend/app/ + config.py # Settings (pydantic-settings), get_settings(); ALLOWED_ORIGINS for CORS + dependencies.py # get_ai_client() singleton (AsyncOpenAI) + middleware.py # RateLimitMiddleware — IP (20/min) + per-user (60/min) + rapid-fire hint detection + abuse_detector.py # Background loop (5 min) — credit velocity, burst, score anomaly, new-account checks + main.py # FastAPI routes: /analyze /hint /explain /study-plan /health + # + /auth/google, /users/me, /users/me/profile, /users/me/credits/log + # + /admin/users/{id}/subscription|credits|suspend|unsuspend + # + GET /admin/security-events + agent/ + core.py # call_with_retry() — tenacity retry wrapper for all AI calls + memory.py # compress_conversation() via Haiku + exam_analyzer.py # analyze_exam_result() — grade+province → location-aware school recs + hint_generator.py# generate_hint() — Socratic hints via Haiku + study_planner.py # generate_study_plan() — 4-week study plan with JSON fallback + tests/ + test_ai_endpoints.py # pytest tests covering AI endpoints (LLM mocked) + +exam-app/src/ + api/ + index.js # Static data loaders (questions, exams, schools) + aiClient.js # Axios client wrapping all backend endpoints; wrap() preserves structured errors + components/ + AIInsights.jsx # Renders AI analysis; handles 401/402/403 error codes + credit top-up CTA + AIErrorBoundary.jsx # React error boundary wrapping AI sections + QuestionCard.jsx # Question renderer + hint (⚡1 credit) + explanation toggle (practice mode) + ProfileOnboarding.jsx # Modal: grade (required) + province (required) + school type + ToS gate + LowCreditBanner.jsx # Sticky banner when credits_balance < 10; dismissible per session + Navbar.jsx # ⚡ credits badge → /account; avatar/name → /account + pages/ + Results.jsx # Async AI analysis with grade+province in payload; "Tạo Kế Hoạch" button + StudyPlan.jsx # /study-plan/:resultId — 4-week plan with localStorage checkbox progress + Account.jsx # /account — profile, tier/credits, pricing table (monthly/annual toggle), credit log + ExamSelect.jsx # Auth gate (1 guest trial), grade/tier filter, category lock for non-complete tiers + context/ + ExamContext.jsx # Exam state + hints: {} + SET_HINT action + useHints() hook + AuthContext.jsx # user (all profile fields), login, logout, updateProfile() +``` + +## User profile fields (users table) + +| Field | Values | Effect | +|---|---|---| +| `grade` | '9','10','11','12' | ≤9 → grade10 exams only; 10-12 → thpt only | +| `province` | 63 VN provinces | AI school recs localized to province | +| `school_type` | 'chuyên','công lập','quốc tế' | Optional, informational | +| `subscription_tier` | 'basic','student','complete' | Controls exam access + study-plan gate | +| `subscription_period` | 'monthly','annual' | Annual shown with badge in Navbar/Account | +| `credits_balance` | integer ≥0 | Deducted per AI call; 402 when exhausted | +| `tos_accepted_at` | ISO timestamp | Required before any credit-deducting request | +| `is_suspended` | 0/1 | 403 account_suspended → suspension modal | + +## AI credit costs + +| Endpoint | Credits | +|---|---| +| `/hint` | 1 | +| `/explain` | 1 | +| `/analyze` | 3 | +| `/study-plan` | 5 (student/complete tier only) | + +## Admin endpoints (require X-Admin-Key: current derived key) + +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`. + +- `POST /admin/users/{id}/subscription` — set tier/period/expiry + bonus credits +- `POST /admin/users/{id}/credits` — grant top-up credits +- `POST /admin/users/{id}/suspend` — suspend with reason +- `POST /admin/users/{id}/unsuspend` +- `GET /admin/security-events` — recent HIGH/MEDIUM events with user status +- `POST /admin/generate-key-log` — (cron use only) derive + append current key to log; requires `X-Cron-Secret` header + +## AI router rules (CRITICAL) + +- **SDK**: `openai` (never `anthropic`) +- **Base URL**: `https://ai-router.locdo.tech/v2` (set via `ANTHROPIC_BASE_URL` env var) +- **Auth**: env var `ANTHROPIC_AUTH_TOKEN` — never hardcode +- **Model names use dots**: `claude-sonnet-4.6`, `claude-opus-4.6`, `claude-haiku-4.5` +- **Never hardcode model names** — use `settings.default_model` / `settings.opus_model` / `settings.haiku_model` +- **Never create a new client per request** — use singleton `get_ai_client()` from `dependencies.py` + +## Model tiers + +| Property | Model | Use | +|---|---|---| +| `settings.default_model` | `claude-sonnet-4.6` | Main agent loop | +| `settings.haiku_model` | `claude-haiku-4.5` | Cheap tasks: summarization, compression | +| `settings.opus_model` | `claude-opus-4.6` | Complex reasoning | + +## Env vars + +**`backend/.env`** (copy from `backend/.env.example`, never commit) + +| Variable | Example value | +|---|---| +| `ANTHROPIC_BASE_URL` | `https://ai-router.locdo.tech` | +| `ANTHROPIC_AUTH_TOKEN` | *(your token)* | +| `ANTHROPIC_DEFAULT_OPUS_MODEL` | `claude-opus-4.6` | +| `ANTHROPIC_DEFAULT_SONNET_MODEL` | `claude-sonnet-4.6` | +| `ANTHROPIC_DEFAULT_HAIKU_MODEL` | `claude-haiku-4.5` | +| `ALLOWED_ORIGINS` | `http://localhost:5173` | +| `SQLITE_PATH` | `./math_wiki.db` (local) / `/data/app.db` (HF Spaces) | +| `GOOGLE_CLIENT_ID` | *(Google OAuth client ID)* | +| `JWT_SECRET` | *(≥32 chars, required)* | +| `ADMIN_MASTER_SECRET` | *(≥32 chars — static master; effective key is HMAC-derived + time window)* | +| `ADMIN_KEY_ROTATION_PERIOD` | `weekly` *(daily\|weekly\|monthly\|quarterly\|annual)* | +| `ADMIN_KEY_LOG_PATH` | `./admin_keys.txt` (local) / `/data/admin_keys.txt` (HF Spaces) | +| `ADMIN_KEY_LOG_ENABLED` | `true` | +| `CRON_SECRET` | *(≥32 chars — authenticates POST /admin/generate-key-log from cron-job.org/GitHub Actions)* | + +**`exam-app/.env`** (copy from `exam-app/.env.example`, never commit) + +| Variable | Example value | +|---|---| +| `VITE_API_BASE_URL` | `http://localhost:8000` | + +## Key patterns + +**Error handling** — wrap all `client.chat.completions.create()` with `call_with_retry()` from `agent/core.py`. Catches `RateLimitError` (retry), `APIConnectionError`, `APIStatusError`. + +**Prefix caching** — static system prompt content first (e.g. `STATIC_EXAM_ANALYSIS_INSTRUCTIONS`); dynamic context (student name, score, weak topics) appended last. + +**Pricing** — `PRICE_TABLE` in `tools/registry.py` maps product type → VND/m². Default fallback: 1,600,000 VND/m². + +## Development workflow + +This project uses two collaborating tools for code intelligence and structured work: + +- **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. +- **agent-skills plugin** — structured workflow skills (spec, plan, build, test, review, etc.) that map to common engineering tasks. + +### When to reach for each + +| Task | Use | +|---|---| +| "What calls `run_agent()`?" / "What breaks if I change this?" | GitNexus: `gitnexus_impact`, `gitnexus_context` | +| "How does the tool loop work?" / "Find all entry points" | GitNexus: `gitnexus_query` | +| Adding a new feature end-to-end | agent-skills: `/spec` → `/plan` → `/build` | +| Fixing a bug with proof it's fixed | agent-skills: `/test` (Prove-It pattern) | +| Pre-merge check | agent-skills: `/review` + GitNexus: `gitnexus_detect_changes` | +| Renaming a symbol across files | GitNexus: `gitnexus_rename` | + +### GitNexus rules + +- **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. +- **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. +- **Before committing** — run `gitnexus_detect_changes()` to verify only expected symbols were affected. +- **Never rename with find-and-replace** — use `gitnexus_rename` which understands the call graph. + +### GitNexus skill files + +| Goal | Skill | +|---|---| +| Architecture exploration | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / impact analysis | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Bug tracing | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Refactoring / rename / extract | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Full tool + resource reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | + +### GitNexus index state + +Indexed as **AI-Agent-App** — re-index with `gitnexus analyze /mnt/d/AI-Agent-App --skip-git` after significant changes. + +### GitNexus MCP resources + +| Resource | Use for | +|----------|---------| +| `gitnexus://repo/AI-Agent-App/context` | Codebase overview, index freshness | +| `gitnexus://repo/AI-Agent-App/processes` | All execution flows | + + +# GitNexus — Code Intelligence + +This project is indexed by GitNexus as **AI-Agent-App** (5942 symbols, 18107 relationships, 265 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. + +> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. + +## Always Do + +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. +- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. +- **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. +- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. +- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. + +## Never Do + +- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. +- NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. +- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. +- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. + +## Resources + +| Resource | Use for | +|----------|---------| +| `gitnexus://repo/AI-Agent-App/context` | Codebase overview, check index freshness | +| `gitnexus://repo/AI-Agent-App/clusters` | All functional areas | +| `gitnexus://repo/AI-Agent-App/processes` | All execution flows | +| `gitnexus://repo/AI-Agent-App/process/{name}` | Step-by-step execution trace | + +## CLI + +| Task | Read this skill file | +|------|---------------------| +| Understand architecture / "How does X work?" | `.claude/skills/gitnexus/gitnexus-exploring/SKILL.md` | +| Blast radius / "What breaks if I change X?" | `.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md` | +| Trace bugs / "Why is X failing?" | `.claude/skills/gitnexus/gitnexus-debugging/SKILL.md` | +| Rename / extract / split / refactor | `.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md` | +| Tools, resources, schema reference | `.claude/skills/gitnexus/gitnexus-guide/SKILL.md` | +| Index, status, clean, wiki CLI commands | `.claude/skills/gitnexus/gitnexus-cli/SKILL.md` | + + + +## Deploy commands + +### Hugging Face Space (backend) — orphan push required + +```bash +git checkout master +git checkout --orphan hf-deploy-new +git add -A +git commit -m "deploy: $(git log master --oneline -1 | cut -c1-7)" +git branch -D hf-deploy +git branch -m hf-deploy-new hf-deploy +git push --force space hf-deploy:main +git checkout master +``` + +**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. + +### Cloudflare Pages (frontend) — must use `--branch=main` + +```bash +cd exam-app +VITE_API_BASE_URL=https://minhtai-ai-agent-app.hf.space npm run build +npx wrangler pages deploy dist --project-name exam-app --branch=main --commit-dirty=true +``` + +**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. + +**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. diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000000000000000000000000000000000000..a668404c10a54c2c1ea60384c74bcd1b738b92c1 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,30 @@ +FROM python:3.11-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + libgomp1 \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Fix root→appuser cache path mismatch; must be set before bake AND kept for CMD +ENV HF_HOME=/app/.cache/huggingface + +# Bake BGE-M3 into the image; avoids cold-start download (~570 MB) +RUN python -c "from FlagEmbedding import BGEM3FlagModel; BGEM3FlagModel('BAAI/bge-m3', use_fp16=False)" + +COPY backend/ backend/ +COPY scripts/ scripts/ +COPY exam-app/src/data/ exam-app/src/data/ + +ENV PYTHONPATH=/app/backend:/app/scripts + +# HF Spaces requires a non-root user +RUN useradd -m -u 1000 appuser && chown -R appuser /app +USER appuser + +EXPOSE 7860 + +CMD uvicorn app.main:app --host 0.0.0.0 --port 7860 diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..f05eb593872443c271f55ef6c84dc9d1ab8cc3c7 --- /dev/null +++ b/README.md @@ -0,0 +1,15 @@ +--- +title: AI Agent App +emoji: 🤖 +colorFrom: blue +colorTo: indigo +sdk: docker +pinned: false +app_port: 7860 +--- + +# AI Agent App + +Vietnamese aluminum/glass door sales chatbot + AI-powered exam backend. + +Built with FastAPI + Claude via ai-router proxy. diff --git a/backend/.env.example b/backend/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..e7340bb2804223656716653a62c47f468c0bfdaf --- /dev/null +++ b/backend/.env.example @@ -0,0 +1,23 @@ +ANTHROPIC_BASE_URL=https://ai-router.locdo.tech +ANTHROPIC_AUTH_TOKEN=your_token_here +ANTHROPIC_DEFAULT_OPUS_MODEL=claude-opus-4.6 +ANTHROPIC_DEFAULT_SONNET_MODEL=claude-sonnet-4.6 +ANTHROPIC_DEFAULT_HAIKU_MODEL=claude-haiku-4.5 +ALLOWED_ORIGINS=http://localhost:5173 +# SQLite database path. On HF Spaces use /data/app.db (persistent storage must be enabled). +# For local dev override to e.g. ./math_wiki.db +SQLITE_PATH=/data/app.db +# Enable background wiki crawl on startup (local only; keep false on HF Spaces) +CRAWL_AUTO_SEED_ENABLED=false +# Wipe wiki_units and re-crawl from scratch; app self-disables this after one run +CRAWL_FORCE_RESEED=false +# Crawl only topics with zero wiki units (gap-fill); idempotent — safe to leave true +CRAWL_GAP_FILL_ENABLED=false +# Google OAuth 2.0 Client ID — create at console.cloud.google.com → Credentials → OAuth 2.0 Client IDs +# Add Authorised JavaScript Origins: http://localhost:5173 and your HF Space URL +GOOGLE_CLIENT_ID=your_google_client_id_here +# JWT signing secret — generate with: python -c "import secrets; print(secrets.token_hex(32))" +JWT_SECRET=your_jwt_secret_here +# Static admin key sent in X-Admin-Key header for all admin endpoints. +# Generate with: python -c "import secrets; print(secrets.token_hex(32))" +ADMIN_KEY=your_admin_key_here diff --git a/backend/app/__init__.py b/backend/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/app/abuse_detector.py b/backend/app/abuse_detector.py new file mode 100644 index 0000000000000000000000000000000000000000..a49345c69500ce9924e8e6dd0c0150898143e96a --- /dev/null +++ b/backend/app/abuse_detector.py @@ -0,0 +1,232 @@ +""" +Background abuse detection loop — runs every 5 minutes, no external infrastructure needed. +Launched in lifespan() via asyncio.ensure_future(). +""" +import asyncio +import json +import logging + +logger = logging.getLogger(__name__) + +_INTERVAL = 300 # 5 minutes + + +async def _log_event(pool, user_id, ip, event_type, confidence, detail): + try: + await pool.execute( + "INSERT INTO security_events (user_id, ip, event_type, confidence, detail) VALUES (?, ?, ?, ?, ?)", + user_id, ip, event_type, confidence, json.dumps(detail) if isinstance(detail, dict) else detail, + ) + except Exception as exc: + logger.warning("abuse_detector: could not log event: %s", exc) + + +async def _auto_suspend(pool, user_id, reason): + try: + await pool.execute( + "UPDATE users SET is_suspended = 1, suspension_reason = ? WHERE id = ?", + reason, user_id, + ) + await _log_event(pool, user_id, None, "auto_suspend", "high", reason) + logger.warning("abuse_detector: AUTO-SUSPENDED user %s — %s", user_id, reason) + except Exception as exc: + logger.error("abuse_detector: failed to suspend user %s: %s", user_id, exc) + + +async def _flag_for_review(pool, user_id, detail): + try: + await _log_event(pool, user_id, None, "flagged_for_review", "medium", detail) + logger.info("abuse_detector: flagged user %s for review — %s", user_id, detail) + except Exception as exc: + logger.error("abuse_detector: failed to flag user %s: %s", user_id, exc) + + +async def _check_credit_velocity(pool): + """Credits 0→50+ in <1h after reset → HIGH confidence abuse.""" + try: + rows = await pool.fetch( + """SELECT user_id, COUNT(*) as gains + FROM ai_credits_log + WHERE delta > 0 + AND reason NOT LIKE 'admin_%' + AND reason NOT LIKE 'subscription_%' + AND created_at > datetime('now', '-1 hour') + GROUP BY user_id HAVING gains >= 3""" + ) + for row in rows: + await _auto_suspend(pool, row["user_id"], "credit_velocity: rapid credit gains detected") + except Exception as exc: + logger.warning("abuse_detector: credit_velocity check error: %s", exc) + + +async def _check_burst_patterns(pool): + """More than 100 AI requests in a 10-min window → HIGH confidence.""" + try: + rows = await pool.fetch( + """SELECT user_id, COUNT(*) as cnt + FROM ai_credits_log + WHERE created_at > datetime('now', '-10 minutes') + GROUP BY user_id HAVING cnt > 100""" + ) + for row in rows: + await _auto_suspend( + pool, row["user_id"], + f"burst_pattern: {row['cnt']} AI requests in 10 minutes" + ) + except Exception as exc: + logger.warning("abuse_detector: burst_patterns check error: %s", exc) + + +async def _check_score_anomalies(pool): + """Score=10 on >3 exams in 30 min by same user → flag for review.""" + try: + rows = await pool.fetch( + """SELECT user_id, COUNT(*) as cnt + FROM exam_results + WHERE score = 10 AND created_at > datetime('now', '-30 minutes') + GROUP BY user_id HAVING cnt > 3""" + ) + for row in rows: + await _flag_for_review( + pool, row["user_id"], + f"score_anomaly: {row['cnt']} perfect-score exams in 30 minutes" + ) + except Exception as exc: + logger.warning("abuse_detector: score_anomalies check error: %s", exc) + + +async def _check_new_account_burst(pool): + """Account age <2h AND credits=0 (exhausted immediately) → flag for review.""" + try: + rows = await pool.fetch( + """SELECT id FROM users + WHERE credits_balance = 0 + AND created_at > datetime('now', '-2 hours') + AND is_suspended = 0""" + ) + for row in rows: + await _flag_for_review( + pool, row["id"], + "new_account_burst: new account exhausted credits within 2 hours" + ) + except Exception as exc: + logger.warning("abuse_detector: new_account_burst check error: %s", exc) + + +async def _check_behavioral_anomalies(pool): + """Tab switches >10 or DevTools detected in a single day → behavior_anomaly event.""" + try: + rows = await pool.fetch( + """SELECT user_id, + SUM(CAST(json_extract(payload, '$.tab_switches') AS INTEGER)) AS total_tabs, + MAX(CAST(json_extract(payload, '$.devtools_detected') AS INTEGER)) AS any_devtools + FROM exam_results + WHERE created_at > datetime('now', '-1 day') + GROUP BY user_id + HAVING total_tabs > 10 OR any_devtools = 1""" + ) + for row in rows: + reason = f"behavior_anomaly: tab_switches={row['total_tabs']}, devtools={row['any_devtools']}" + await _log_event(pool, row["user_id"], None, "behavior_anomaly", "medium", reason) + # If user already has a HIGH event in the same window, auto-lock + high_events = await pool.fetchrow( + """SELECT COUNT(*) AS cnt FROM security_events + WHERE user_id = ? AND confidence = 'high' + AND created_at > datetime('now', '-1 day')""", + row["user_id"], + ) + if high_events and high_events["cnt"] > 0: + await pool.execute( + "UPDATE users SET is_locked = 1, lock_reason = ? WHERE id = ? AND is_locked = 0", + f"auto-lock: {reason}", row["user_id"], + ) + await _log_event(pool, row["user_id"], None, "auto_lock", "high", f"auto-lock: {reason}") + from app.dependencies import invalidate_account_cache + invalidate_account_cache(row["user_id"]) + except Exception as exc: + logger.warning("abuse_detector: behavioral_anomalies check error: %s", exc) + + +async def _auto_lock_on_high_confidence(pool): + """Auto-lock users with HIGH confidence abuse events if not already locked.""" + try: + rows = await pool.fetch( + """SELECT DISTINCT user_id FROM security_events + WHERE confidence = 'high' + AND event_type IN ('credit_velocity', 'burst_pattern', 'exam_anomaly') + AND created_at > datetime('now', '-1 hour')""" + ) + for row in rows: + result = await pool.execute( + "UPDATE users SET is_locked = 1, lock_reason = 'auto-lock: high-confidence abuse' WHERE id = ? AND is_locked = 0", + row["user_id"], + ) + if result: + await _log_event(pool, row["user_id"], None, "auto_lock", "high", "auto-lock: high-confidence abuse event") + from app.dependencies import invalidate_account_cache + invalidate_account_cache(row["user_id"]) + except Exception as exc: + logger.warning("abuse_detector: auto_lock check error: %s", exc) + + +_DORMANT_DAYS = 365 +_DELETION_WARNING_DAYS = 30 + + +async def _mark_dormant_accounts(pool): + """Phase 1: mark basic-tier accounts inactive > _DORMANT_DAYS as pending deletion.""" + try: + await pool.execute( + f"""UPDATE users + SET pending_deletion_at = datetime('now', '+{_DELETION_WARNING_DAYS} days') + WHERE subscription_tier = 'basic' + AND is_suspended = 0 AND is_locked = 0 AND is_deactivated = 0 + AND pending_deletion_at IS NULL + AND (last_seen_at IS NULL + OR last_seen_at < datetime('now', '-{_DORMANT_DAYS} days')) + """ + ) + except Exception as exc: + logger.warning("abuse_detector: mark_dormant error: %s", exc) + + +async def _deactivate_expired_pending(pool): + """Phase 2: deactivate accounts whose warning period has expired.""" + try: + rows = await pool.fetch( + """SELECT id FROM users + WHERE pending_deletion_at IS NOT NULL + AND pending_deletion_at < datetime('now') + AND is_deactivated = 0""" + ) + for row in rows: + await pool.execute( + "UPDATE users SET is_deactivated = 1 WHERE id = ?", + row["id"], + ) + await _log_event(pool, row["id"], None, "auto_deactivated", "low", + f"dormant account — no login for {_DORMANT_DAYS} days") + logger.info("abuse_detector: deactivated dormant account %s", row["id"]) + except Exception as exc: + logger.warning("abuse_detector: deactivate_expired_pending error: %s", exc) + + +async def _run_abuse_detector(pool): + """Main detection loop — runs every 5 minutes.""" + logger.info("abuse_detector: starting background loop (interval=%ds)", _INTERVAL) + while True: + try: + await asyncio.sleep(_INTERVAL) + await _check_credit_velocity(pool) + await _check_burst_patterns(pool) + await _check_score_anomalies(pool) + await _check_new_account_burst(pool) + await _check_behavioral_anomalies(pool) + await _auto_lock_on_high_confidence(pool) + await _mark_dormant_accounts(pool) + await _deactivate_expired_pending(pool) + except asyncio.CancelledError: + logger.info("abuse_detector: loop cancelled, shutting down") + break + except Exception as exc: + logger.error("abuse_detector: unhandled error in loop: %s", exc) diff --git a/backend/app/admin_auth.py b/backend/app/admin_auth.py new file mode 100644 index 0000000000000000000000000000000000000000..d9614eb6b48767eca3e8f9fb0b1f9498dd18171b --- /dev/null +++ b/backend/app/admin_auth.py @@ -0,0 +1,67 @@ +import hmac +import hashlib +import datetime + + +def get_window_label(period: str, offset: int = 0) -> str: + today = datetime.date.today() + if period == "daily": + return (today - datetime.timedelta(days=offset)).strftime("%Y-%m-%d") + elif period == "weekly": + return (today - datetime.timedelta(weeks=offset)).strftime("%Y-W%W") + elif period == "monthly": + year, month = today.year, today.month + month -= offset + while month <= 0: + month += 12 + year -= 1 + return f"{year}-{month:02d}" + elif period == "quarterly": + q = ((today.month - 1) // 3) + 1 + year = today.year + q -= offset + while q <= 0: + q += 4 + year -= 1 + return f"{year}-Q{q}" + elif period == "annual": + return str(today.year - offset) + return (today - datetime.timedelta(weeks=offset)).strftime("%Y-W%W") + + +def get_expiry_date(period: str) -> str: + today = datetime.date.today() + if period == "daily": + return (today + datetime.timedelta(days=1)).strftime("%Y-%m-%d") + elif period == "weekly": + days_until_next = (7 - today.weekday()) % 7 or 7 + return (today + datetime.timedelta(days=days_until_next)).strftime("%Y-%m-%d") + elif period == "monthly": + if today.month == 12: + return f"{today.year + 1}-01-01" + return f"{today.year}-{today.month + 1:02d}-01" + elif period == "quarterly": + q = ((today.month - 1) // 3) + 1 + next_q_month = q * 3 + 1 + if next_q_month > 12: + return f"{today.year + 1}-01-01" + return f"{today.year}-{next_q_month:02d}-01" + elif period == "annual": + return f"{today.year + 1}-01-01" + days_until_next = (7 - today.weekday()) % 7 or 7 + return (today + datetime.timedelta(days=days_until_next)).strftime("%Y-%m-%d") + + +def derive_key(master: str, label: str) -> str: + return hmac.new(master.encode(), label.encode(), hashlib.sha256).hexdigest() + + +def validate_admin_key(provided: str, master: str, period: str) -> bool: + if not master or not provided: + return False + current = derive_key(master, get_window_label(period, offset=0)) + previous = derive_key(master, get_window_label(period, offset=1)) + return ( + hmac.compare_digest(provided.encode(), current.encode()) or + hmac.compare_digest(provided.encode(), previous.encode()) + ) diff --git a/backend/app/agent/__init__.py b/backend/app/agent/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/app/agent/core.py b/backend/app/agent/core.py new file mode 100644 index 0000000000000000000000000000000000000000..f448434524adec083cb5f28e7fa662cefc27ba3f --- /dev/null +++ b/backend/app/agent/core.py @@ -0,0 +1,7 @@ +from openai import AsyncOpenAI +from tenacity import retry, stop_after_attempt, wait_exponential + + +@retry(stop=stop_after_attempt(3), wait=wait_exponential(min=1, max=10)) +async def call_with_retry(client: AsyncOpenAI, **kwargs): + return await client.chat.completions.create(**kwargs) diff --git a/backend/app/agent/exam_analyzer.py b/backend/app/agent/exam_analyzer.py new file mode 100644 index 0000000000000000000000000000000000000000..3a79f411ae29dfdae28ad9de43b87f179e555b0a --- /dev/null +++ b/backend/app/agent/exam_analyzer.py @@ -0,0 +1,444 @@ +import json +from openai import AsyncOpenAI +from app.config import get_settings +from app.agent.core import call_with_retry + +THPT_ANALYSIS_CONTEXT = """ +Khi phân tích kết quả thi THPT: +- Đề cập đến phân phối điểm chuẩn vào đại học theo tỉnh thành +- Nhấn mạnh rằng 8.0+ thường cần thiết cho trường top +- Chỉ ra xu hướng đề thi theo năm (khó hơn ở phần hình học không gian và tích phân) +- Ưu tiên gợi ý trường phù hợp với điểm thực tế, không chỉ trường mơ ước +""" + +STATIC_EXAM_ANALYSIS_INSTRUCTIONS = THPT_ANALYSIS_CONTEXT + """Bạn là chuyên gia phân tích kết quả học tập cho học sinh ôn thi Toán. +Phân tích kết quả thi, các câu trả lời đúng/sai cụ thể, và gợi ý trường phù hợp dựa trên điểm số. +Trả lời bằng tiếng Việt. Luôn trả về JSON hợp lệ theo đúng định dạng yêu cầu, không có text ngoài JSON.""" + +# Per-province difficulty data (mirrors provincialData.js) +# topic_weights: approximate % share of each topic in recent provincial grade-9 math exams. +# Derived from analysis of 2021–2024 provincial exam papers. Topics with higher % are +# higher priority in Recovery Path focus area selection. +_PROVINCE_DATA = { + 'Hà Nội': { + 'difficulty': 4, 'typical_cutoff': 8.0, 'top_schools_cutoff': 9.2, + 'topic_weights': {'calculus': 18, 'functions': 15, 'logarithm': 12, 'algebra': 14, 'geometry': 12, 'combinatorics': 10, 'hệ phương trình': 8, 'statistics': 6, 'sequences': 5}, + }, + 'TP.HCM': { + 'difficulty': 4, 'typical_cutoff': 7.8, 'top_schools_cutoff': 9.0, + 'topic_weights': {'calculus': 16, 'functions': 15, 'algebra': 15, 'geometry': 13, 'logarithm': 10, 'combinatorics': 10, 'hệ phương trình': 8, 'statistics': 7, 'trigonometry': 6}, + }, + 'Đà Nẵng': { + 'difficulty': 3, 'typical_cutoff': 7.2, 'top_schools_cutoff': 8.5, + 'topic_weights': {'algebra': 20, 'geometry': 18, 'functions': 13, 'calculus': 10, 'hệ phương trình': 10, 'statistics': 9, 'number_theory': 8, 'logarithm': 7, 'combinatorics': 5}, + }, + 'Hải Phòng': { + 'difficulty': 3, 'typical_cutoff': 7.0, 'top_schools_cutoff': 8.2, + 'topic_weights': {'algebra': 20, 'geometry': 18, 'hệ phương trình': 12, 'functions': 12, 'statistics': 9, 'number_theory': 9, 'calculus': 8, 'logarithm': 7, 'combinatorics': 5}, + }, + 'Cần Thơ': { + 'difficulty': 3, 'typical_cutoff': 6.8, 'top_schools_cutoff': 8.0, + 'topic_weights': {'algebra': 22, 'geometry': 18, 'hệ phương trình': 12, 'arithmetic': 10, 'functions': 10, 'statistics': 9, 'number_theory': 8, 'calculus': 6, 'căn thức': 5}, + }, + 'Bình Dương': { + 'difficulty': 3, 'typical_cutoff': 7.0, 'top_schools_cutoff': 8.2, + 'topic_weights': {'algebra': 20, 'geometry': 18, 'hệ phương trình': 12, 'functions': 12, 'statistics': 9, 'number_theory': 9, 'calculus': 8, 'logarithm': 7, 'combinatorics': 5}, + }, + 'Đồng Nai': { + 'difficulty': 3, 'typical_cutoff': 6.8, 'top_schools_cutoff': 8.0, + 'topic_weights': {'algebra': 22, 'geometry': 18, 'hệ phương trình': 12, 'arithmetic': 10, 'functions': 10, 'statistics': 9, 'number_theory': 8, 'calculus': 6, 'căn thức': 5}, + }, + 'Khánh Hòa': { + 'difficulty': 3, 'typical_cutoff': 6.8, 'top_schools_cutoff': 7.8, + 'topic_weights': {'algebra': 22, 'geometry': 18, 'hệ phương trình': 11, 'arithmetic': 10, 'functions': 10, 'statistics': 9, 'number_theory': 8, 'calculus': 7, 'căn thức': 5}, + }, + 'Nghệ An': { + 'difficulty': 3, 'typical_cutoff': 6.6, 'top_schools_cutoff': 7.8, + 'topic_weights': {'algebra': 22, 'geometry': 19, 'hệ phương trình': 12, 'arithmetic': 10, 'functions': 10, 'statistics': 8, 'number_theory': 8, 'calculus': 6, 'căn thức': 5}, + }, + 'Thanh Hóa': { + 'difficulty': 2, 'typical_cutoff': 6.4, 'top_schools_cutoff': 7.5, + 'topic_weights': {'algebra': 25, 'geometry': 22, 'hệ phương trình': 12, 'arithmetic': 11, 'functions': 10, 'statistics': 8, 'number_theory': 7, 'căn thức': 5}, + }, + 'Hà Tĩnh': { + 'difficulty': 3, 'typical_cutoff': 6.8, 'top_schools_cutoff': 7.8, + 'topic_weights': {'algebra': 22, 'geometry': 18, 'hệ phương trình': 12, 'arithmetic': 10, 'functions': 10, 'statistics': 9, 'number_theory': 8, 'calculus': 6, 'căn thức': 5}, + }, + 'Bắc Ninh': { + 'difficulty': 3, 'typical_cutoff': 7.0, 'top_schools_cutoff': 8.2, + 'topic_weights': {'algebra': 20, 'geometry': 18, 'hệ phương trình': 12, 'functions': 12, 'statistics': 9, 'number_theory': 9, 'calculus': 8, 'logarithm': 7, 'combinatorics': 5}, + }, + 'Vĩnh Phúc': { + 'difficulty': 3, 'typical_cutoff': 6.8, 'top_schools_cutoff': 7.8, + 'topic_weights': {'algebra': 21, 'geometry': 18, 'hệ phương trình': 12, 'arithmetic': 10, 'functions': 10, 'statistics': 9, 'number_theory': 8, 'calculus': 7, 'căn thức': 5}, + }, + 'Hà Giang': { + 'difficulty': 1, 'typical_cutoff': 5.8, 'top_schools_cutoff': 6.8, + 'topic_weights': {'algebra': 30, 'geometry': 25, 'arithmetic': 15, 'hệ phương trình': 10, 'statistics': 8, 'functions': 7, 'number_theory': 5}, + }, + 'Điện Biên': { + 'difficulty': 1, 'typical_cutoff': 5.6, 'top_schools_cutoff': 6.6, + 'topic_weights': {'algebra': 32, 'geometry': 26, 'arithmetic': 16, 'hệ phương trình': 10, 'statistics': 8, 'functions': 5, 'number_theory': 3}, + }, + 'Lai Châu': { + 'difficulty': 1, 'typical_cutoff': 5.6, 'top_schools_cutoff': 6.6, + 'topic_weights': {'algebra': 32, 'geometry': 26, 'arithmetic': 16, 'hệ phương trình': 10, 'statistics': 8, 'functions': 5, 'number_theory': 3}, + }, + 'Sơn La': { + 'difficulty': 1, 'typical_cutoff': 5.8, 'top_schools_cutoff': 6.8, + 'topic_weights': {'algebra': 30, 'geometry': 25, 'arithmetic': 15, 'hệ phương trình': 10, 'statistics': 8, 'functions': 7, 'number_theory': 5}, + }, + 'Cà Mau': { + 'difficulty': 1, 'typical_cutoff': 5.8, 'top_schools_cutoff': 6.8, + 'topic_weights': {'algebra': 30, 'geometry': 25, 'arithmetic': 15, 'hệ phương trình': 10, 'statistics': 8, 'functions': 7, 'number_theory': 5}, + }, + 'Kiên Giang': { + 'difficulty': 2, 'typical_cutoff': 6.2, 'top_schools_cutoff': 7.2, + 'topic_weights': {'algebra': 25, 'geometry': 22, 'hệ phương trình': 12, 'arithmetic': 11, 'functions': 10, 'statistics': 8, 'number_theory': 7, 'căn thức': 5}, + }, + 'Bà Rịa - Vũng Tàu': { + 'difficulty': 3, 'typical_cutoff': 7.0, 'top_schools_cutoff': 8.0, + 'topic_weights': {'algebra': 20, 'geometry': 18, 'hệ phương trình': 12, 'functions': 12, 'statistics': 9, 'number_theory': 9, 'calculus': 8, 'logarithm': 7, 'combinatorics': 5}, + }, + # ── Difficulty-3 provinces (Khá) ────────────────────────────────────────── + 'Thừa Thiên - Huế': { + 'difficulty': 3, 'typical_cutoff': 6.8, 'top_schools_cutoff': 8.0, + 'topic_weights': {'algebra': 20, 'geometry': 18, 'functions': 13, 'calculus': 10, 'hệ phương trình': 10, 'statistics': 9, 'number_theory': 9, 'logarithm': 7, 'combinatorics': 4}, + }, + 'Quảng Ninh': { + 'difficulty': 3, 'typical_cutoff': 7.0, 'top_schools_cutoff': 8.2, + 'topic_weights': {'algebra': 20, 'geometry': 18, 'functions': 12, 'hệ phương trình': 12, 'statistics': 9, 'number_theory': 9, 'calculus': 8, 'logarithm': 7, 'combinatorics': 5}, + }, + 'Nam Định': { + 'difficulty': 3, 'typical_cutoff': 6.8, 'top_schools_cutoff': 7.8, + 'topic_weights': {'algebra': 21, 'geometry': 18, 'hệ phương trình': 12, 'functions': 12, 'statistics': 9, 'number_theory': 9, 'calculus': 8, 'logarithm': 6, 'combinatorics': 5}, + }, + 'Ninh Bình': { + 'difficulty': 3, 'typical_cutoff': 6.6, 'top_schools_cutoff': 7.8, + 'topic_weights': {'algebra': 21, 'geometry': 18, 'hệ phương trình': 12, 'arithmetic': 10, 'functions': 11, 'statistics': 9, 'number_theory': 8, 'calculus': 7, 'căn thức': 4}, + }, + 'Hải Dương': { + 'difficulty': 3, 'typical_cutoff': 6.8, 'top_schools_cutoff': 7.8, + 'topic_weights': {'algebra': 20, 'geometry': 18, 'hệ phương trình': 12, 'functions': 12, 'statistics': 9, 'number_theory': 9, 'calculus': 8, 'logarithm': 7, 'combinatorics': 5}, + }, + 'Hưng Yên': { + 'difficulty': 3, 'typical_cutoff': 7.0, 'top_schools_cutoff': 8.0, + 'topic_weights': {'algebra': 20, 'geometry': 18, 'hệ phương trình': 12, 'functions': 12, 'statistics': 9, 'number_theory': 9, 'calculus': 8, 'logarithm': 7, 'combinatorics': 5}, + }, + 'Hà Nam': { + 'difficulty': 3, 'typical_cutoff': 6.6, 'top_schools_cutoff': 7.6, + 'topic_weights': {'algebra': 21, 'geometry': 18, 'hệ phương trình': 12, 'arithmetic': 10, 'functions': 11, 'statistics': 9, 'number_theory': 8, 'calculus': 7, 'căn thức': 4}, + }, + 'Thái Bình': { + 'difficulty': 3, 'typical_cutoff': 6.8, 'top_schools_cutoff': 7.8, + 'topic_weights': {'algebra': 21, 'geometry': 18, 'hệ phương trình': 12, 'arithmetic': 10, 'functions': 11, 'statistics': 9, 'number_theory': 8, 'calculus': 7, 'căn thức': 4}, + }, + 'Lâm Đồng': { + 'difficulty': 3, 'typical_cutoff': 6.8, 'top_schools_cutoff': 7.8, + 'topic_weights': {'algebra': 20, 'geometry': 18, 'hệ phương trình': 12, 'arithmetic': 10, 'functions': 11, 'statistics': 9, 'number_theory': 8, 'calculus': 7, 'căn thức': 5}, + }, + 'Thái Nguyên': { + 'difficulty': 3, 'typical_cutoff': 6.8, 'top_schools_cutoff': 7.8, + 'topic_weights': {'algebra': 20, 'geometry': 18, 'hệ phương trình': 12, 'arithmetic': 10, 'functions': 11, 'statistics': 9, 'number_theory': 8, 'calculus': 7, 'căn thức': 5}, + }, + 'Bình Định': { + 'difficulty': 3, 'typical_cutoff': 6.8, 'top_schools_cutoff': 7.8, + 'topic_weights': {'algebra': 21, 'geometry': 18, 'hệ phương trình': 11, 'arithmetic': 10, 'functions': 11, 'statistics': 9, 'number_theory': 8, 'calculus': 7, 'căn thức': 5}, + }, + 'Quảng Nam': { + 'difficulty': 3, 'typical_cutoff': 6.6, 'top_schools_cutoff': 7.6, + 'topic_weights': {'algebra': 22, 'geometry': 18, 'hệ phương trình': 12, 'arithmetic': 10, 'functions': 10, 'statistics': 9, 'number_theory': 8, 'calculus': 6, 'căn thức': 5}, + }, + 'Phú Thọ': { + 'difficulty': 3, 'typical_cutoff': 6.8, 'top_schools_cutoff': 7.8, + 'topic_weights': {'algebra': 20, 'geometry': 18, 'hệ phương trình': 12, 'arithmetic': 10, 'functions': 11, 'statistics': 9, 'number_theory': 8, 'calculus': 7, 'căn thức': 5}, + }, + 'Bắc Giang': { + 'difficulty': 3, 'typical_cutoff': 6.6, 'top_schools_cutoff': 7.6, + 'topic_weights': {'algebra': 21, 'geometry': 18, 'hệ phương trình': 12, 'arithmetic': 10, 'functions': 11, 'statistics': 9, 'number_theory': 8, 'calculus': 7, 'căn thức': 4}, + }, + 'Quảng Bình': { + 'difficulty': 3, 'typical_cutoff': 6.6, 'top_schools_cutoff': 7.6, + 'topic_weights': {'algebra': 22, 'geometry': 18, 'hệ phương trình': 12, 'arithmetic': 10, 'functions': 10, 'statistics': 9, 'number_theory': 8, 'calculus': 6, 'căn thức': 5}, + }, + # ── Difficulty-2 provinces (Trung bình) ─────────────────────────────────── + 'An Giang': { + 'difficulty': 2, 'typical_cutoff': 6.2, 'top_schools_cutoff': 7.2, + 'topic_weights': {'algebra': 25, 'geometry': 22, 'hệ phương trình': 12, 'arithmetic': 11, 'functions': 10, 'statistics': 8, 'number_theory': 7, 'căn thức': 5}, + }, + 'Bạc Liêu': { + 'difficulty': 2, 'typical_cutoff': 6.0, 'top_schools_cutoff': 7.0, + 'topic_weights': {'algebra': 26, 'geometry': 22, 'hệ phương trình': 12, 'arithmetic': 11, 'functions': 9, 'statistics': 8, 'number_theory': 7, 'căn thức': 5}, + }, + 'Bến Tre': { + 'difficulty': 2, 'typical_cutoff': 6.2, 'top_schools_cutoff': 7.2, + 'topic_weights': {'algebra': 25, 'geometry': 22, 'hệ phương trình': 12, 'arithmetic': 11, 'functions': 10, 'statistics': 8, 'number_theory': 7, 'căn thức': 5}, + }, + 'Bình Phước': { + 'difficulty': 2, 'typical_cutoff': 6.2, 'top_schools_cutoff': 7.2, + 'topic_weights': {'algebra': 25, 'geometry': 22, 'hệ phương trình': 12, 'arithmetic': 11, 'functions': 10, 'statistics': 8, 'number_theory': 7, 'căn thức': 5}, + }, + 'Bình Thuận': { + 'difficulty': 2, 'typical_cutoff': 6.4, 'top_schools_cutoff': 7.4, + 'topic_weights': {'algebra': 25, 'geometry': 21, 'hệ phương trình': 12, 'arithmetic': 11, 'functions': 10, 'statistics': 8, 'number_theory': 8, 'căn thức': 5}, + }, + 'Đắk Lắk': { + 'difficulty': 2, 'typical_cutoff': 6.2, 'top_schools_cutoff': 7.2, + 'topic_weights': {'algebra': 25, 'geometry': 22, 'hệ phương trình': 12, 'arithmetic': 11, 'functions': 10, 'statistics': 8, 'number_theory': 7, 'căn thức': 5}, + }, + 'Đắk Nông': { + 'difficulty': 2, 'typical_cutoff': 6.0, 'top_schools_cutoff': 7.0, + 'topic_weights': {'algebra': 26, 'geometry': 22, 'hệ phương trình': 12, 'arithmetic': 11, 'functions': 9, 'statistics': 8, 'number_theory': 7, 'căn thức': 5}, + }, + 'Đồng Tháp': { + 'difficulty': 2, 'typical_cutoff': 6.2, 'top_schools_cutoff': 7.2, + 'topic_weights': {'algebra': 25, 'geometry': 22, 'hệ phương trình': 12, 'arithmetic': 11, 'functions': 10, 'statistics': 8, 'number_theory': 7, 'căn thức': 5}, + }, + 'Gia Lai': { + 'difficulty': 2, 'typical_cutoff': 6.0, 'top_schools_cutoff': 7.0, + 'topic_weights': {'algebra': 26, 'geometry': 22, 'hệ phương trình': 12, 'arithmetic': 11, 'functions': 9, 'statistics': 8, 'number_theory': 7, 'căn thức': 5}, + }, + 'Hậu Giang': { + 'difficulty': 2, 'typical_cutoff': 6.0, 'top_schools_cutoff': 7.0, + 'topic_weights': {'algebra': 26, 'geometry': 22, 'hệ phương trình': 12, 'arithmetic': 11, 'functions': 9, 'statistics': 8, 'number_theory': 7, 'căn thức': 5}, + }, + 'Hòa Bình': { + 'difficulty': 2, 'typical_cutoff': 6.0, 'top_schools_cutoff': 7.0, + 'topic_weights': {'algebra': 26, 'geometry': 23, 'hệ phương trình': 11, 'arithmetic': 12, 'functions': 9, 'statistics': 8, 'number_theory': 6, 'căn thức': 5}, + }, + 'Kon Tum': { + 'difficulty': 2, 'typical_cutoff': 5.8, 'top_schools_cutoff': 6.8, + 'topic_weights': {'algebra': 27, 'geometry': 23, 'arithmetic': 13, 'hệ phương trình': 11, 'statistics': 8, 'functions': 8, 'number_theory': 6, 'căn thức': 4}, + }, + 'Lạng Sơn': { + 'difficulty': 2, 'typical_cutoff': 6.0, 'top_schools_cutoff': 7.0, + 'topic_weights': {'algebra': 26, 'geometry': 23, 'hệ phương trình': 11, 'arithmetic': 12, 'functions': 9, 'statistics': 8, 'number_theory': 6, 'căn thức': 5}, + }, + 'Lào Cai': { + 'difficulty': 2, 'typical_cutoff': 6.2, 'top_schools_cutoff': 7.2, + 'topic_weights': {'algebra': 25, 'geometry': 23, 'hệ phương trình': 11, 'arithmetic': 12, 'functions': 9, 'statistics': 8, 'number_theory': 7, 'căn thức': 5}, + }, + 'Long An': { + 'difficulty': 2, 'typical_cutoff': 6.4, 'top_schools_cutoff': 7.4, + 'topic_weights': {'algebra': 25, 'geometry': 21, 'hệ phương trình': 12, 'arithmetic': 11, 'functions': 10, 'statistics': 8, 'number_theory': 8, 'căn thức': 5}, + }, + 'Ninh Thuận': { + 'difficulty': 2, 'typical_cutoff': 6.2, 'top_schools_cutoff': 7.2, + 'topic_weights': {'algebra': 25, 'geometry': 22, 'hệ phương trình': 12, 'arithmetic': 11, 'functions': 10, 'statistics': 8, 'number_theory': 7, 'căn thức': 5}, + }, + 'Phú Yên': { + 'difficulty': 2, 'typical_cutoff': 6.2, 'top_schools_cutoff': 7.2, + 'topic_weights': {'algebra': 25, 'geometry': 22, 'hệ phương trình': 12, 'arithmetic': 11, 'functions': 10, 'statistics': 8, 'number_theory': 7, 'căn thức': 5}, + }, + 'Quảng Ngãi': { + 'difficulty': 2, 'typical_cutoff': 6.2, 'top_schools_cutoff': 7.2, + 'topic_weights': {'algebra': 25, 'geometry': 22, 'hệ phương trình': 12, 'arithmetic': 11, 'functions': 10, 'statistics': 8, 'number_theory': 7, 'căn thức': 5}, + }, + 'Quảng Trị': { + 'difficulty': 2, 'typical_cutoff': 6.2, 'top_schools_cutoff': 7.2, + 'topic_weights': {'algebra': 25, 'geometry': 22, 'hệ phương trình': 12, 'arithmetic': 11, 'functions': 10, 'statistics': 8, 'number_theory': 7, 'căn thức': 5}, + }, + 'Sóc Trăng': { + 'difficulty': 2, 'typical_cutoff': 6.0, 'top_schools_cutoff': 7.0, + 'topic_weights': {'algebra': 26, 'geometry': 22, 'hệ phương trình': 12, 'arithmetic': 11, 'functions': 9, 'statistics': 8, 'number_theory': 7, 'căn thức': 5}, + }, + 'Tây Ninh': { + 'difficulty': 2, 'typical_cutoff': 6.4, 'top_schools_cutoff': 7.4, + 'topic_weights': {'algebra': 25, 'geometry': 21, 'hệ phương trình': 12, 'arithmetic': 11, 'functions': 10, 'statistics': 8, 'number_theory': 8, 'căn thức': 5}, + }, + 'Tiền Giang': { + 'difficulty': 2, 'typical_cutoff': 6.2, 'top_schools_cutoff': 7.2, + 'topic_weights': {'algebra': 25, 'geometry': 22, 'hệ phương trình': 12, 'arithmetic': 11, 'functions': 10, 'statistics': 8, 'number_theory': 7, 'căn thức': 5}, + }, + 'Tuyên Quang': { + 'difficulty': 2, 'typical_cutoff': 5.8, 'top_schools_cutoff': 6.8, + 'topic_weights': {'algebra': 27, 'geometry': 23, 'arithmetic': 13, 'hệ phương trình': 11, 'statistics': 8, 'functions': 8, 'number_theory': 5, 'căn thức': 5}, + }, + 'Vĩnh Long': { + 'difficulty': 2, 'typical_cutoff': 6.2, 'top_schools_cutoff': 7.2, + 'topic_weights': {'algebra': 25, 'geometry': 22, 'hệ phương trình': 12, 'arithmetic': 11, 'functions': 10, 'statistics': 8, 'number_theory': 7, 'căn thức': 5}, + }, + 'Yên Bái': { + 'difficulty': 2, 'typical_cutoff': 5.8, 'top_schools_cutoff': 6.8, + 'topic_weights': {'algebra': 27, 'geometry': 23, 'arithmetic': 14, 'hệ phương trình': 11, 'statistics': 8, 'functions': 7, 'number_theory': 5, 'căn thức': 5}, + }, + # ── Difficulty-1 provinces (Dễ / vùng cao) ─────────────────────────────── + 'Bắc Kạn': { + 'difficulty': 1, 'typical_cutoff': 5.8, 'top_schools_cutoff': 6.8, + 'topic_weights': {'algebra': 30, 'geometry': 25, 'arithmetic': 15, 'hệ phương trình': 10, 'statistics': 8, 'functions': 7, 'number_theory': 5}, + }, + 'Cao Bằng': { + 'difficulty': 1, 'typical_cutoff': 5.6, 'top_schools_cutoff': 6.6, + 'topic_weights': {'algebra': 32, 'geometry': 25, 'arithmetic': 15, 'hệ phương trình': 10, 'statistics': 8, 'functions': 5, 'number_theory': 5}, + }, +} +_DIFFICULTY_LABELS = {1: 'Dễ', 2: 'Trung bình', 3: 'Khá', 4: 'Khó', 5: 'Rất khó'} + + +def _get_province_context(province: str | None) -> str: + if not province or province not in _PROVINCE_DATA: + return "National average THPT Math 2024: 6.51. Calibrate recommendations to general Vietnamese exam standards." + d = _PROVINCE_DATA[province] + label = _DIFFICULTY_LABELS.get(d['difficulty'], 'Trung bình') + return ( + f"Province: {province} | Difficulty: {label} ({d['difficulty']}/5) | " + f"Typical Math cutoff: {d['typical_cutoff']} | Top schools require: {d['top_schools_cutoff']}+ | " + f"National avg: 6.51. Calibrate school recommendations to {province} standards specifically." + ) + + +def _strip_code_fence(text: str) -> str: + if text.startswith("```"): + parts = text.split("```") + text = parts[1] if len(parts) > 1 else text + if text.startswith("json"): + text = text[4:] + return text.strip() + + +def build_analyze_prompt( + result: dict, + history: list[dict], + student_name: str = "", + wrong_questions: list[dict] = None, + school_recommendations: list[dict] = None, + exam_category: str = "", + user_profile: dict = None, + learner_archetype: str | None = None, + device_province: str | None = None, +) -> str: + topic_breakdown = result.get("topicBreakdown", {}) + weak_topics = [t for t, tb in topic_breakdown.items() if tb.get("accuracy", 1) < 0.6] + + dynamic_parts = [] + if student_name: + dynamic_parts.append(f"Học sinh: {student_name}") + dynamic_parts.append(f"Điểm: {result.get('score', 0)}/10") + dynamic_parts.append(f"Độ chính xác: {round(result.get('accuracy', 0) * 100)}%") + dynamic_parts.append(f"Chủ đề yếu (< 60%): {', '.join(weak_topics) or 'Không có'}") + dynamic_parts.append(f"Chi tiết theo chủ đề: {json.dumps(topic_breakdown, ensure_ascii=False)}") + if len(history) >= 2: + recent_scores = [r.get("score", 0) for r in history[-5:]] + dynamic_parts.append(f"Điểm gần đây: {recent_scores}") + + if wrong_questions: + wrong_summary = [ + {"topic": q.get("topic"), "difficulty": q.get("difficulty"), "question": q.get("question", "")[:80]} + for q in wrong_questions[:5] + ] + dynamic_parts.append(f"Câu sai ({len(wrong_questions)} câu, ví dụ): {json.dumps(wrong_summary, ensure_ascii=False)}") + + grade = str((user_profile or {}).get("grade", "")) + province = (user_profile or {}).get("province", "") or (user_profile or {}).get("location", "") + + if school_recommendations: + school_list = [ + f"{s['school']['name']} ({s['matchStrength']}, điểm chuẩn Toán: {s['cutoff']})" + for s in school_recommendations[:6] + ] + # Derive school type from grade: ≤9 → high school (lớp 10), 10-12 → university + if grade and grade.isdigit() and int(grade) <= 9: + exam_type = "lớp 10" + school_type_note = "trường THPT" + else: + exam_type = "đại học/THPT" + school_type_note = "trường đại học/cao đẳng" + loc_note = f" tại {province}" if province else "" + dynamic_parts.append( + f"Trường gợi ý{loc_note} ({school_type_note}, kỳ thi {exam_type}): {'; '.join(school_list)}" + ) + + # Add grade + province context for personalized school recommendation prompt + if grade: + dynamic_parts.append(f"Lớp học sinh: {grade}") + if province: + dynamic_parts.append(f"Tỉnh/thành phố: {province}") + if learner_archetype: + dynamic_parts.append(f"Learner type: {learner_archetype}") + + # Append per-province difficulty context (dynamic, not in static system prompt) + province_ctx = _get_province_context(province or None) + dynamic_parts.append(f"Provincial context: {province_ctx}") + + # Device-detected location context — supplements (does not replace) user-selected province + if device_province: + note = f"Vị trí thiết bị phát hiện: {device_province}" + if device_province != province: + note += f" (khác với tỉnh trong hồ sơ: {province or 'chưa đặt'})" + dynamic_parts.append( + f"{note}. Dùng thông tin này để bổ sung nhận xét về đặc thù đề thi địa phương " + f"(trọng số chủ đề, mức độ cạnh tranh) trong phần insights và recommendations. " + f"Không thêm tên trường vào insights/recommendations — danh sách trường được hiển thị riêng." + ) + if not province: + dynamic_parts.append(f"Device provincial context: {_get_province_context(device_province)}") + + school_json_field = "" + if school_recommendations: + if grade and grade.isdigit() and int(grade) <= 9: + school_insight_hint = "Nhận xét ngắn 1-2 câu tổng quan về trường THPT phù hợp để thi vào lớp 10" + school_type_example = "THPT" + else: + school_insight_hint = "Nhận xét ngắn 1-2 câu tổng quan về trường đại học/cao đẳng phù hợp" + school_type_example = "Đại học" + school_json_field = ( + f',\n "school_insight": "{school_insight_hint}",' + f'\n "schools": [' + f'\n {{' + f'\n "name": "Tên trường đầy đủ",' + f'\n "score_range": "Ngưỡng điểm chuẩn Toán (vd: 7.5–8.5 điểm)",' + f'\n "type": "{school_type_example}",' + f'\n "region_note": "Tỉnh/thành của trường — quan hệ với tỉnh học sinh (cùng tỉnh/tỉnh lân cận/...)",' + f'\n "note": "1 câu nhận xét tại sao phù hợp với điểm số này"' + f'\n }}' + f'\n ] // Liệt kê 3-5 trường phù hợp nhất theo thứ tự ưu tiên' + ) + + prompt = "\n".join(dynamic_parts) + f""" + +Trả về JSON (không có text ngoài JSON): +{{ + "insights": "Nhận xét tổng quan 2-3 câu về kết quả thi", + "question_analysis": "Phân tích cụ thể các câu trả lời sai nếu có, chỉ ra điểm cần cải thiện (2-3 câu)", + "weak_topics": ["topic_key1", "topic_key2"], + "recommendations": ["khuyến nghị 1", "khuyến nghị 2", "khuyến nghị 3"]{school_json_field} +}}""" + return prompt + + +async def analyze_exam_result( + client: AsyncOpenAI, + result: dict, + history: list[dict], + student_name: str = "", + wrong_questions: list[dict] = None, + school_recommendations: list[dict] = None, + exam_category: str = "", + user_profile: dict = None, + learner_archetype: str | None = None, + device_province: str | None = None, +) -> dict: + settings = get_settings() + + prompt = build_analyze_prompt( + result, history, student_name, + wrong_questions=wrong_questions, + school_recommendations=school_recommendations, + exam_category=exam_category, + user_profile=user_profile, + learner_archetype=learner_archetype, + device_province=device_province, + ) + + response = await call_with_retry( + client, + model=settings.default_model, + max_tokens=1200, + messages=[ + {"role": "system", "content": STATIC_EXAM_ANALYSIS_INSTRUCTIONS}, + {"role": "user", "content": prompt}, + ], + ) + + content = _strip_code_fence(response.choices[0].message.content or "{}") + return json.loads(content) diff --git a/backend/app/agent/exam_explainer.py b/backend/app/agent/exam_explainer.py new file mode 100644 index 0000000000000000000000000000000000000000..03aec78664490aa02a1e6680f6bcf20e52959abc --- /dev/null +++ b/backend/app/agent/exam_explainer.py @@ -0,0 +1,118 @@ +import json +from openai import AsyncOpenAI +from app.config import get_settings +from app.agent.core import call_with_retry + +THPT_CONTEXT = """ +Bối cảnh: Đây là kỳ thi THPT Quốc gia Việt Nam. Các câu hỏi thường có bẫy sau: +- Nhầm lẫn giữa điều kiện cần và điều kiện đủ trong bài toán logarit, hàm số +- Bỏ sót nghiệm ngoài miền xác định +- Tính sai dấu khi khai triển công thức lượng giác +- Nhầm chiều tích phân hoặc quên hằng số C +Luôn gợi ý học sinh kiểm tra lại điều kiện trước khi kết luận. +""" + +STATIC_EXPLAIN_INSTRUCTIONS = """Bạn là gia sư toán học chuyên ôn thi lớp 10 TPHCM. \ +Phân tích câu hỏi trắc nghiệm, xác định đáp án đúng bằng lập luận toán học, rồi giải thích ngắn gọn. \ +Trả lời bằng tiếng Việt.""" + +LABELS = ["A", "B", "C", "D"] + + +import re + +def _extract_json(text: str) -> str: + """Return the first {...} block found anywhere in the text.""" + match = re.search(r'\{[^{}]*\}', text, re.DOTALL) + if match: + return match.group(0) + # Fallback: strip code fences and return + text = re.sub(r'^```(?:json)?\s*', '', text.strip()) + text = re.sub(r'\s*```$', '', text) + return text.strip() + + +_EXPLANATION_DEPTH_INSTRUCTIONS = { + "brief": "Giải thích ngắn gọn — chỉ 2-3 câu nêu bật ý chính.", + "detailed": "Giải thích đầy đủ, chi tiết để học sinh hiểu rõ.", + "step-by-step": "Trình bày giải thích theo các bước đánh số. Mỗi bước trên một dòng riêng.", +} + +_ENCOURAGEMENT_INSTRUCTIONS = { + 'minimal': 'Be concise and direct. Skip praise.', + 'moderate': 'Brief encouragement is welcome.', + 'high': 'Be warm and encouraging throughout.', +} + + +async def generate_explanation( + client: AsyncOpenAI, + question: dict, + chosen_index: int, + ai_preferences: dict | None = None, +) -> dict: + settings = get_settings() + + explanation_depth = (ai_preferences or {}).get("explanation_depth", "detailed") + depth_instruction = _EXPLANATION_DEPTH_INSTRUCTIONS.get(explanation_depth, _EXPLANATION_DEPTH_INSTRUCTIONS["detailed"]) + encouragement_level = (ai_preferences or {}).get("encouragement_level", "moderate") + encouragement_instruction = _ENCOURAGEMENT_INSTRUCTIONS.get(encouragement_level, _ENCOURAGEMENT_INSTRUCTIONS["moderate"]) + choices = question.get("choices", []) + + # Ground truth from question data — never let AI guess the correct answer + correct_index = int(question.get("correct", 0)) + correct_index = max(0, min(correct_index, len(choices) - 1)) + base_explanation = question.get("explanation", "") + + chosen_label = LABELS[chosen_index] if chosen_index < len(LABELS) else str(chosen_index) + correct_label = LABELS[correct_index] if correct_index < len(LABELS) else str(correct_index) + + choices_text = "\n".join( + f" {LABELS[i]}. {c}" for i, c in enumerate(choices) if i < len(LABELS) + ) + + # If the question already has an explanation, use it directly without an AI call + if base_explanation: + student_context = ( + f"Bạn đã chọn đúng ({correct_label})! " if chosen_index == correct_index + else f"Bạn chọn {chosen_label}, đáp án đúng là {correct_label}. " + ) + return { + "correct_index": correct_index, + "explanation": student_context + base_explanation, + } + + # No pre-written explanation — ask AI to explain, but correct_index is already known + prompt = f"""Câu hỏi trắc nghiệm toán lớp 10: +{question.get('question', '')} + +Các lựa chọn: +{choices_text} + +Chủ đề: {question.get('topic', '')} | Mức độ: {question.get('difficulty', '')} +Học sinh đã chọn: {chosen_label} +Đáp án đúng: {correct_label} (index {correct_index}) — đây là sự thật, không được thay đổi. + +QUAN TRỌNG: Chỉ trả về JSON, không có bất kỳ văn bản nào khác trước hoặc sau. +Giải thích ngắn gọn tại sao đáp án {correct_label} đúng: +{{"correct_index": {correct_index}, "explanation": "<2–3 câu tiếng Việt giải thích, không dùng markdown>"}}""" + + response = await call_with_retry( + client, + model=settings.haiku_model, + max_tokens=400, + messages=[ + {"role": "system", "content": THPT_CONTEXT + STATIC_EXPLAIN_INSTRUCTIONS + "\n" + depth_instruction + "\n" + encouragement_instruction}, + {"role": "user", "content": prompt}, + ], + ) + + raw = response.choices[0].message.content or "" + content = _extract_json(raw) + try: + data = json.loads(content) + # Always use ground-truth correct_index regardless of what AI returns + data["correct_index"] = correct_index + return data + except (json.JSONDecodeError, ValueError): + return {"correct_index": correct_index, "explanation": raw.strip()} diff --git a/backend/app/agent/fsrs.py b/backend/app/agent/fsrs.py new file mode 100644 index 0000000000000000000000000000000000000000..223784c1ea6f90b7f89260453dd39c96a9391a71 --- /dev/null +++ b/backend/app/agent/fsrs.py @@ -0,0 +1,59 @@ +""" +FSRS v5 spaced-repetition algorithm — mirrors the frontend implementation exactly. + +Parameters (FSRS_W) are identical to exam-app/src/pages/ReviewSession.jsx so that +server-computed next_review_date matches what the client would have computed. + +Quality scale (same as frontend): + 1 = Đoán (Again / forgot) + 3 = Khá (Good) + 5 = Chắc (Easy / remembered well) +""" +import math + +FSRS_W = [0.4, 0.6, 2.4, 5.8, 4.93, 0.94, 0.86, 0.01, 1.49, 0.14, 0.94, 2.18, 0.05, 0.34, 1.26, 0.29, 2.61] + + +def fsrs_update( + stability: float, + difficulty: float, + elapsed: int, + quality: int, +) -> tuple[float, float, int]: + """ + Apply one FSRS review step and return (new_stability, new_difficulty, interval_days). + + quality: 1 | 3 | 5 (frontend scale) + """ + stability = max(0.5, float(stability)) + difficulty = max(1.0, min(10.0, float(difficulty))) + elapsed = max(1, int(elapsed)) + + # Map frontend quality (1/3/5) to FSRS internal scale (1/3/4) + q = 1 if quality <= 1 else (3 if quality <= 3 else 4) + + retrievability = math.exp(math.log(0.9) * elapsed / stability) + + if q >= 3: + new_stability = stability * ( + math.exp(FSRS_W[8]) + * (11 - difficulty) + * math.pow(stability, -FSRS_W[9]) + * (math.exp(FSRS_W[10] * (1 - retrievability)) - 1) + + 1 + ) + else: + new_stability = ( + FSRS_W[11] + * math.pow(difficulty, -FSRS_W[12]) + * (math.pow(stability + 1, FSRS_W[13]) - 1) + * math.exp(FSRS_W[14] * (1 - retrievability)) + ) + + new_stability = max(0.5, new_stability) + # interval = round(new_stability) — the log(0.9)/log(0.9) terms cancel to 1.0 + interval = max(1, round(new_stability)) + + new_difficulty = max(1.0, min(10.0, difficulty + FSRS_W[6] * (3 - q))) + + return new_stability, new_difficulty, interval diff --git a/backend/app/agent/hint_generator.py b/backend/app/agent/hint_generator.py new file mode 100644 index 0000000000000000000000000000000000000000..f5f65654c54718accafb22297a8472835959db64 --- /dev/null +++ b/backend/app/agent/hint_generator.py @@ -0,0 +1,90 @@ +import json +from openai import AsyncOpenAI +from app.config import get_settings +from app.agent.core import call_with_retry + +THPT_CONTEXT = """ +Bối cảnh: Đây là kỳ thi THPT Quốc gia Việt Nam. Các câu hỏi thường có bẫy sau: +- Nhầm lẫn giữa điều kiện cần và điều kiện đủ trong bài toán logarit, hàm số +- Bỏ sót nghiệm ngoài miền xác định +- Tính sai dấu khi khai triển công thức lượng giác +- Nhầm chiều tích phân hoặc quên hằng số C +Luôn gợi ý học sinh kiểm tra lại điều kiện trước khi kết luận. +""" + +STATIC_HINT_INSTRUCTIONS = """Bạn là trợ lý AI của ứng dụng luyện thi toán lớp 10 TPHCM. \ +Hỗ trợ tạo nội dung giáo dục. Trả lời bằng tiếng Việt.""" + +_DETAIL_LEVEL = { + 1: "gợi ý nhẹ — chỉ gợi hướng tư duy, không tiết lộ bất kỳ thông tin về đáp án", + 2: "gợi ý vừa — chỉ ra phương pháp giải cụ thể, vẫn không tiết lộ đáp án", + 3: "gợi ý chi tiết — giải thích từng bước tiếp cận, nhưng để học sinh tự chọn đáp án", +} + + +def _strip_code_fence(text: str) -> str: + if text.startswith("```"): + parts = text.split("```") + text = parts[1] if len(parts) > 1 else text + if text.startswith("json"): + text = text[4:] + return text.strip() + + +_HINT_STYLE_INSTRUCTIONS = { + "socratic": "Hướng dẫn bằng cách đặt câu hỏi gợi mở, KHÔNG tiết lộ đáp án — hãy để học sinh tự khám phá.", + "direct": "Đưa ra gợi ý trực tiếp, rõ ràng về cách tiếp cận từng bước. Hãy cụ thể và rõ ràng.", + "visual": "Trình bày gợi ý theo các bước đánh số rõ ràng. Dùng ký hiệu toán học chuẩn và nhãn bước rõ ràng.", +} + +_ENCOURAGEMENT_INSTRUCTIONS = { + 'minimal': 'Be concise and direct. Skip praise.', + 'moderate': 'Brief encouragement is welcome.', + 'high': 'Be warm and encouraging throughout.', +} + + +async def generate_hint( + client: AsyncOpenAI, + question: dict, + attempt_count: int = 1, + previous_hints: list[str] | None = None, + ai_preferences: dict | None = None, +) -> dict: + settings = get_settings() + level = _DETAIL_LEVEL.get(min(attempt_count, 3), _DETAIL_LEVEL[3]) + + hint_style = (ai_preferences or {}).get("hint_style", "socratic") + style_instruction = _HINT_STYLE_INSTRUCTIONS.get(hint_style, _HINT_STYLE_INSTRUCTIONS["socratic"]) + encouragement_level = (ai_preferences or {}).get("encouragement_level", "moderate") + encouragement_instruction = _ENCOURAGEMENT_INSTRUCTIONS.get(encouragement_level, _ENCOURAGEMENT_INSTRUCTIONS["moderate"]) + + prev_context = "" + if previous_hints: + shown = "\n".join(f" Lần {i+1}: {h}" for i, h in enumerate(previous_hints)) + prev_context = f"\nCác gợi ý đã cung cấp (KHÔNG lặp lại, phải tiến xa hơn):\n{shown}\n" + + prompt = f"""Tôi cần bạn tạo một GỢI Ý ngắn (KHÔNG phải lời giải) cho câu hỏi toán sau. +Yêu cầu ({level}): Đặt 1–2 câu hỏi gợi mở hoặc nhắc 1 khái niệm liên quan để học sinh tự suy nghĩ. +Quy tắc bắt buộc: +- Tối đa 2 câu, viết liền mạch, không xuống dòng +- KHÔNG dùng markdown, KHÔNG dùng số thứ tự, KHÔNG dùng gạch đầu dòng +- KHÔNG tiết lộ đáp án hay ký hiệu A/B/C/D +Chủ đề: {question.get('topic', '')} | Mức độ: {question.get('difficulty', '')} | Lần {attempt_count}/3 +Câu hỏi: {question.get('question', '')}{prev_context} +Trả về đúng định dạng JSON sau, không thêm text nào khác: +{{"hint": "<1–2 câu gợi ý tiếng Việt, không markdown>", "difficulty_note": ""}}""" + + response = await call_with_retry( + client, + model=settings.hint_model, + max_tokens=512, + messages=[ + {"role": "system", "content": THPT_CONTEXT + STATIC_HINT_INSTRUCTIONS + "\n" + style_instruction + "\n" + encouragement_instruction}, + {"role": "user", "content": prompt}, + ], + ) + + raw = response.choices[0].message.content or "" + content = _strip_code_fence(raw) + return json.loads(content) diff --git a/backend/app/agent/memory.py b/backend/app/agent/memory.py new file mode 100644 index 0000000000000000000000000000000000000000..17783e9dff5bbb026d79cc9d8702849d7f7a3fc2 --- /dev/null +++ b/backend/app/agent/memory.py @@ -0,0 +1,27 @@ +import json +from openai import AsyncOpenAI +from app.config import get_settings + + +async def compress_conversation( + client: AsyncOpenAI, + messages: list[dict], +) -> str: + settings = get_settings() + history_text = "\n".join( + f"{m['role'].upper()}: {m['content'] if isinstance(m['content'], str) else json.dumps(m['content'], ensure_ascii=False)}" + for m in messages + if m["role"] != "system" + ) + response = await client.chat.completions.create( + model=settings.haiku_model, + max_tokens=512, + messages=[ + { + "role": "system", + "content": "Tóm tắt ngắn gọn cuộc hội thoại dưới đây, giữ lại các thông tin quan trọng về yêu cầu và sản phẩm của khách.", + }, + {"role": "user", "content": history_text}, + ], + ) + return response.choices[0].message.content or "" diff --git a/backend/app/agent/study_planner.py b/backend/app/agent/study_planner.py new file mode 100644 index 0000000000000000000000000000000000000000..8f97689ff6453e3b1cea4584f66de4087ae45f45 --- /dev/null +++ b/backend/app/agent/study_planner.py @@ -0,0 +1,131 @@ +import json +from openai import AsyncOpenAI +from app.config import get_settings +from app.agent.core import call_with_retry +from app.agent.exam_analyzer import _PROVINCE_DATA + +STATIC_RECOVERY_PATH_INSTRUCTIONS = """Bạn là huấn luyện viên thi Toán lớp 9 vào lớp 10. +Nhiệm vụ: nhìn vào điểm thi và từng câu sai cụ thể, xác định 1–2 lỗ hổng kiến thức quan trọng nhất, rồi tạo Recovery Path ngắn hạn (2–3 tuần) nhắm trực tiếp vào các lỗ hổng đó. + +Nguyên tắc bắt buộc: +- score_gap: nêu khoảng cách điểm cụ thể và trường mục tiêu (nếu có). Không nói chung chung. +- focus_areas: tối đa 2 chủ đề. Ưu tiên chủ đề sai nhiều nhất hoặc ảnh hưởng điểm nhất. +- error_pattern: nêu đúng lỗi kỹ thuật cụ thể (không phải tên chủ đề). Ví dụ: "Sai ở bước xác định miền xác định logarit — xuất hiện 4/5 lần". +- tasks: 2–3 nhiệm vụ luyện tập cụ thể, liên hệ trực tiếp đến lỗi đã xác định. +- checkpoint: số câu cần trả lời đúng liên tiếp để coi là nắm vững (target: 3–5). +- LATEX BẮT BUỘC: mọi ký hiệu toán học trong tasks/error_pattern PHẢI bọc trong $...$. Ví dụ: $\\Delta > 0$, $\\log_a x$, $x^2 - 5x + 6 = 0$. +Trả lời bằng tiếng Việt. Luôn trả về JSON hợp lệ, không có text ngoài JSON.""" + + +def _strip_code_fence(text: str) -> str: + if text.startswith("```"): + parts = text.split("```") + text = parts[1] if len(parts) > 1 else text + if text.startswith("json"): + text = text[4:] + return text.strip() + + +async def generate_study_plan( + client: AsyncOpenAI, + result: dict, + history: list[dict], + wrong_questions: list[dict] | None = None, + topic_miss_counts: dict | None = None, + student_name: str = "", + learner_archetype: str | None = None, + province: str = "", +) -> dict: + settings = get_settings() + + lines = [] + if student_name: + lines.append(f"Học sinh: {student_name}") + lines.append(f"Điểm: {result.get('score', 0)}/10 ({round(result.get('accuracy', 0) * 100)}% đúng)") + lines.append(f"Số đề đã thi: {len(history)}") + if province and province in _PROVINCE_DATA: + p = _PROVINCE_DATA[province] + lines.append( + f"Tỉnh: {province} | Mức điểm an toàn: {p['typical_cutoff']} | " + f"Trường tốt yêu cầu: {p['top_schools_cutoff']}+ | " + f"Dùng các ngưỡng này để xác định khoảng cách điểm (score_gap) cụ thể cho học sinh." + ) + if p.get("topic_weights"): + top_topics = sorted(p["topic_weights"].items(), key=lambda x: -x[1])[:5] + weights_str = ", ".join(f"{t} ({w}%)" for t, w in top_topics) + lines.append( + f"Phân bố chủ đề đề thi {province} (5 chủ đề chiếm tỷ trọng cao nhất): {weights_str}. " + f"Ưu tiên chọn focus_areas từ các chủ đề này vì chúng xuất hiện nhiều nhất trong đề thi tỉnh." + ) + elif province: + lines.append(f"Tỉnh: {province}") + + if wrong_questions: + if topic_miss_counts: + summary = ", ".join(f"{t}: {c} câu sai" for t, c in topic_miss_counts.items()) + lines.append(f"Tổng hợp câu sai theo chủ đề: {summary}") + + lines.append( + f"\nCâu sai đại diện ({len(wrong_questions)} câu — câu khó nhất mỗi chủ đề):" + ) + for i, wq in enumerate(wrong_questions, 1): + topic = wq.get("topic", "") + diff = wq.get("difficulty", "") + q_text = wq.get("question", "")[:130] + correct = wq.get("correct_answer", "") + expl = wq.get("explanation", "")[:100] + lines.append(f"\nCâu {i} [{topic} / {diff}]: {q_text}") + lines.append(f" Đáp án đúng: {correct}") + if expl: + lines.append(f" Vì sao: {expl}") + else: + topic_breakdown = result.get("topicBreakdown", {}) + weak = [t for t, tb in topic_breakdown.items() if tb.get("accuracy", 1) < 0.6] + lines.append(f"Chủ đề yếu: {', '.join(weak) or 'Không có'}") + + prompt = "\n".join(lines) + """ + +Tạo Recovery Path dựa trên dữ liệu trên. +Trả về JSON (không có text ngoài JSON): +{ + "score_gap": "Mô tả khoảng cách điểm và mục tiêu cụ thể (1–2 câu)", + "focus_areas": [ + { + "topic": "Tên chủ đề", + "error_pattern": "Mô tả lỗi kỹ thuật cụ thể", + "tasks": ["Nhiệm vụ luyện tập cụ thể 1", "Nhiệm vụ 2"], + "checkpoint": {"target": 5, "description": "Trả lời đúng 5 câu [chủ đề] liên tiếp"} + } + ], + "retake_note": "Sau khi hoàn thành Focus 1 → Thử lại đề thi để so sánh điểm" +}""" + + try: + response = await call_with_retry( + client, + model=settings.default_model, + max_tokens=1200, + messages=[ + {"role": "system", "content": STATIC_RECOVERY_PATH_INSTRUCTIONS}, + {"role": "user", "content": prompt}, + ], + ) + content = _strip_code_fence(response.choices[0].message.content or "{}") + return json.loads(content) + except Exception: + return { + "score_gap": "Phân tích cho thấy còn một số lỗ hổng cần bù. Tiếp tục ôn tập và thử lại đề thi.", + "focus_areas": [ + { + "topic": "Chủ đề yếu nhất", + "error_pattern": "Xem lại giải thích từng câu sai để xác định lỗi cụ thể.", + "tasks": [ + "Xem lại giải thích chi tiết từng câu sai", + "Luyện 5–10 câu cùng dạng", + "Ghi chú kỹ thuật cần nhớ", + ], + "checkpoint": {"target": 3, "description": "Trả lời đúng 3 câu cùng dạng liên tiếp"}, + } + ], + "retake_note": "Sau khi luyện xong → Thử lại đề thi để so sánh điểm", + } diff --git a/backend/app/auth.py b/backend/app/auth.py new file mode 100644 index 0000000000000000000000000000000000000000..57d2fb8cfec2756f3f65157e6ef87b77e3e27428 --- /dev/null +++ b/backend/app/auth.py @@ -0,0 +1,41 @@ +import asyncio +from datetime import datetime, timedelta, timezone + +import jwt +import google.auth.exceptions +import google.auth.transport.requests +import google.oauth2.id_token + +from app.config import get_settings + + +async def verify_google_token(id_token_str: str) -> dict: + """Verify a Google ID token and return its payload. Raises ValueError on failure.""" + settings = get_settings() + try: + payload = await asyncio.to_thread( + google.oauth2.id_token.verify_oauth2_token, + id_token_str, + google.auth.transport.requests.Request(), + settings.google_client_id, + ) + return payload + except google.auth.exceptions.GoogleAuthError as exc: + raise ValueError(f"Invalid or expired Google token: {exc}") from exc + + +def create_jwt(user_id: int) -> str: + settings = get_settings() + now = datetime.now(tz=timezone.utc) + payload = { + "sub": str(user_id), + "iat": now, + "exp": now + timedelta(days=7), + "aud": "exam-app", + } + return jwt.encode(payload, settings.jwt_secret, algorithm="HS256") + + +def decode_jwt(token: str) -> dict: + settings = get_settings() + return jwt.decode(token, settings.jwt_secret, algorithms=["HS256"], audience="exam-app") diff --git a/backend/app/config.py b/backend/app/config.py new file mode 100644 index 0000000000000000000000000000000000000000..178e297526482dc7791467530cba0a400f8b935e --- /dev/null +++ b/backend/app/config.py @@ -0,0 +1,90 @@ +from functools import lru_cache +from pathlib import Path +from pydantic_settings import BaseSettings, SettingsConfigDict + +# Resolve .env relative to this file so it works regardless of CWD (e.g. npm run dev from repo root) +_ENV_FILE = Path(__file__).parent.parent / ".env" + + +class Settings(BaseSettings): + model_config = SettingsConfigDict(env_file=str(_ENV_FILE), env_file_encoding="utf-8", extra="ignore") + + anthropic_base_url: str = "https://ai-router.locdo.tech" + anthropic_auth_token: str + + anthropic_default_opus_model: str = "claude-opus-4.6" + anthropic_default_sonnet_model: str = "claude-sonnet-4.6" + anthropic_default_haiku_model: str = "claude-haiku-4.5" + anthropic_default_hint_model: str = "claude-haiku-4.5" + + allowed_origins: str = "http://localhost:5173,https://exam-app-ey0.pages.dev" + database_url: str = "" + # Set to "true" to run the background wiki crawl on startup. + # Keep "false" on HF Spaces until local testing is complete. + crawl_auto_seed_enabled: bool = False + # Set to "true" to wipe wiki_units and re-crawl everything from scratch. + # The app self-disables this flag via the HF Spaces API after one successful run. + crawl_force_reseed: bool = False + # Set to "true" to crawl only topics that have zero wiki units (gap-fill). + # Idempotent: re-runs are safe — the zero-unit check is the gate. + crawl_gap_fill_enabled: bool = False + # Set to "true" to fix non-canonical topic/type labels and remove content duplicates on startup. + # Self-disables via HF Spaces API after one successful run. + wiki_sanitize_enabled: bool = False + # Set to "true" to translate English wiki units (exam_upload source) to Vietnamese. + # Self-disables via HF Spaces API after one successful run. + wiki_fix_english_enabled: bool = False + embedding_model_name: str = "BAAI/bge-m3" + google_client_id: str = "" + jwt_secret: str = "" + admin_key: str = "" + admin_master_secret: str = "" + admin_key_rotation_period: str = "weekly" + admin_key_log_path: str = "./admin_keys.txt" + admin_key_log_enabled: bool = True + admin_key_webhook_url: str = "" + cron_secret: str = "" + sqlite_path: str = "/data/app.db" + payment_bank_name: str = "" + payment_account_number: str = "" + payment_account_name: str = "" + + def __init__(self, **data): + super().__init__(**data) + if not self.jwt_secret: + raise RuntimeError("JWT_SECRET must be set in environment variables") + if len(self.jwt_secret) < 32: + raise RuntimeError("JWT_SECRET must be at least 32 characters") + if self.admin_master_secret and len(self.admin_master_secret) < 32: + raise RuntimeError("ADMIN_MASTER_SECRET must be at least 32 characters if set") + if self.cron_secret and len(self.cron_secret) < 32: + raise RuntimeError("CRON_SECRET must be at least 32 characters if set") + embedding_dim: int = 1024 + use_sqlite_vec: bool = True + vector_top_k: int = 20 + crag_threshold: float = 0.20 + + @property + def allowed_origins_list(self) -> list[str]: + return [o.strip() for o in self.allowed_origins.split(",")] + + @property + def default_model(self) -> str: + return self.anthropic_default_sonnet_model + + @property + def opus_model(self) -> str: + return self.anthropic_default_opus_model + + @property + def haiku_model(self) -> str: + return self.anthropic_default_haiku_model + + @property + def hint_model(self) -> str: + return self.anthropic_default_hint_model + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/backend/app/data/__init__.py b/backend/app/data/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/app/data/concepts.py b/backend/app/data/concepts.py new file mode 100644 index 0000000000000000000000000000000000000000..5ae91ea062efeb40997b7b05f29b6b26cfa455e1 --- /dev/null +++ b/backend/app/data/concepts.py @@ -0,0 +1,503 @@ +""" +Concept taxonomy for the Zenith Learning Graph — 62 concepts, grades 9–12. + +Each concept has: + prerequisite_ids — edges pointing from required → this concept (DAG) + exam_weight — relative THPT exam frequency (1.0 = rare, 5.0 = very common) + bloom_ceiling — highest Bloom's level typically reached for this concept + topic — canonical topic label (matches wiki_units.topic) + +Edge semantics: "B requires A" means A.prerequisite_ids is upstream of B, +i.e. A → B is encoded as B.prerequisite_ids includes A. +""" + +CONCEPTS: list[dict] = [ + + # ── Grade 9 foundation ──────────────────────────────────────────────────── + + { + "id": "linear_eq", + "name": "Linear Equations", + "name_vi": "Phương trình bậc nhất", + "grade": 9, "topic": "algebra", + "prerequisite_ids": [], + "exam_weight": 2.0, "bloom_ceiling": 3, + }, + { + "id": "linear_systems", + "name": "Systems of Linear Equations", + "name_vi": "Hệ phương trình bậc nhất", + "grade": 9, "topic": "algebra", + "prerequisite_ids": ["linear_eq"], + "exam_weight": 2.5, "bloom_ceiling": 4, + }, + { + "id": "quad_eq", + "name": "Quadratic Equations", + "name_vi": "Phương trình bậc hai", + "grade": 9, "topic": "algebra", + "prerequisite_ids": ["linear_eq"], + "exam_weight": 3.5, "bloom_ceiling": 4, + }, + { + "id": "radicals", + "name": "Radical Expressions", + "name_vi": "Căn thức và biến đổi", + "grade": 9, "topic": "algebra", + "prerequisite_ids": ["linear_eq"], + "exam_weight": 2.0, "bloom_ceiling": 3, + }, + { + "id": "inequalities", + "name": "Inequalities", + "name_vi": "Bất phương trình", + "grade": 9, "topic": "algebra", + "prerequisite_ids": ["linear_eq", "quad_eq"], + "exam_weight": 2.5, "bloom_ceiling": 4, + }, + { + "id": "basic_geo", + "name": "Basic Plane Geometry", + "name_vi": "Hình học phẳng cơ bản", + "grade": 9, "topic": "geometry", + "prerequisite_ids": [], + "exam_weight": 2.0, "bloom_ceiling": 4, + }, + { + "id": "triangles", + "name": "Triangles", + "name_vi": "Tam giác và các tính chất", + "grade": 9, "topic": "geometry", + "prerequisite_ids": ["basic_geo"], + "exam_weight": 2.5, "bloom_ceiling": 5, + }, + { + "id": "circles", + "name": "Circles", + "name_vi": "Đường tròn", + "grade": 9, "topic": "geometry", + "prerequisite_ids": ["basic_geo", "triangles"], + "exam_weight": 2.5, "bloom_ceiling": 4, + }, + { + "id": "stats_basic", + "name": "Basic Statistics", + "name_vi": "Thống kê cơ bản", + "grade": 9, "topic": "statistics", + "prerequisite_ids": [], + "exam_weight": 1.5, "bloom_ceiling": 3, + }, + { + "id": "prob_basic", + "name": "Basic Probability", + "name_vi": "Xác suất cơ bản", + "grade": 9, "topic": "probability", + "prerequisite_ids": ["stats_basic"], + "exam_weight": 2.0, "bloom_ceiling": 4, + }, + { + "id": "combinatorics", + "name": "Combinatorics", + "name_vi": "Tổ hợp và chỉnh hợp", + "grade": 9, "topic": "combinatorics", + "prerequisite_ids": [], + "exam_weight": 2.5, "bloom_ceiling": 4, + }, + { + "id": "number_theory", + "name": "Number Theory", + "name_vi": "Lý thuyết số cơ bản", + "grade": 9, "topic": "number_theory", + "prerequisite_ids": [], + "exam_weight": 1.5, "bloom_ceiling": 5, + }, + { + "id": "sets", + "name": "Sets", + "name_vi": "Tập hợp", + "grade": 9, "topic": "algebra", + "prerequisite_ids": [], + "exam_weight": 1.0, "bloom_ceiling": 3, + }, + + # ── Grade 10 ────────────────────────────────────────────────────────────── + + { + "id": "linear_func", + "name": "Linear Functions", + "name_vi": "Hàm số bậc nhất", + "grade": 10, "topic": "functions_and_graphs", + "prerequisite_ids": ["linear_eq"], + "exam_weight": 2.0, "bloom_ceiling": 4, + }, + { + "id": "quad_func", + "name": "Quadratic Functions & Parabola", + "name_vi": "Hàm số bậc hai và parabol", + "grade": 10, "topic": "functions_and_graphs", + "prerequisite_ids": ["quad_eq"], + "exam_weight": 2.5, "bloom_ceiling": 4, + }, + { + "id": "coord_geo", + "name": "Coordinate Geometry", + "name_vi": "Hình học tọa độ Oxy", + "grade": 10, "topic": "geometry", + "prerequisite_ids": ["linear_eq", "basic_geo"], + "exam_weight": 2.5, "bloom_ceiling": 4, + }, + { + "id": "trig_basic", + "name": "Basic Trigonometry", + "name_vi": "Lượng giác cơ bản", + "grade": 10, "topic": "trigonometry", + "prerequisite_ids": ["basic_geo", "triangles"], + "exam_weight": 2.5, "bloom_ceiling": 4, + }, + { + "id": "vectors", + "name": "Plane Vectors", + "name_vi": "Vectơ phẳng", + "grade": 10, "topic": "geometry", + "prerequisite_ids": ["coord_geo"], + "exam_weight": 2.0, "bloom_ceiling": 4, + }, + { + "id": "sequences", + "name": "Sequences & Series", + "name_vi": "Dãy số", + "grade": 10, "topic": "algebra", + "prerequisite_ids": ["quad_eq"], + "exam_weight": 2.0, "bloom_ceiling": 4, + }, + { + "id": "financial_math", + "name": "Financial Mathematics", + "name_vi": "Toán tài chính", + "grade": 10, "topic": "algebra", + "prerequisite_ids": ["sequences"], + "exam_weight": 1.5, "bloom_ceiling": 3, + }, + + # ── Grade 11 ────────────────────────────────────────────────────────────── + + { + "id": "trig_advanced", + "name": "Advanced Trigonometry", + "name_vi": "Lượng giác nâng cao — công thức cộng và hàm", + "grade": 11, "topic": "trigonometry", + "prerequisite_ids": ["trig_basic"], + "exam_weight": 3.0, "bloom_ceiling": 4, + }, + { + "id": "trig_eq", + "name": "Trigonometric Equations", + "name_vi": "Phương trình lượng giác", + "grade": 11, "topic": "trigonometry", + "prerequisite_ids": ["trig_advanced"], + "exam_weight": 3.5, "bloom_ceiling": 4, + }, + { + "id": "exponential", + "name": "Exponential Functions", + "name_vi": "Hàm số mũ và hàm số lũy thừa", + "grade": 11, "topic": "algebra", + "prerequisite_ids": ["quad_func"], + "exam_weight": 3.5, "bloom_ceiling": 4, + }, + { + "id": "logarithm", + "name": "Logarithms", + "name_vi": "Logarit và hàm logarit", + "grade": 11, "topic": "algebra", + "prerequisite_ids": ["exponential"], + "exam_weight": 3.5, "bloom_ceiling": 4, + }, + { + "id": "exp_log_eq", + "name": "Exponential & Logarithmic Equations", + "name_vi": "Phương trình mũ và logarit", + "grade": 11, "topic": "algebra", + "prerequisite_ids": ["logarithm", "exponential"], + "exam_weight": 4.0, "bloom_ceiling": 4, + }, + { + "id": "exp_log_ineq", + "name": "Exponential & Logarithmic Inequalities", + "name_vi": "Bất phương trình mũ và logarit", + "grade": 11, "topic": "algebra", + "prerequisite_ids": ["exp_log_eq", "inequalities"], + "exam_weight": 3.0, "bloom_ceiling": 5, + }, + { + "id": "spatial_geo", + "name": "Spatial Geometry — Lines & Planes", + "name_vi": "Hình học không gian — đường thẳng và mặt phẳng", + "grade": 11, "topic": "geometry", + "prerequisite_ids": ["basic_geo", "vectors"], + "exam_weight": 3.0, "bloom_ceiling": 4, + }, + { + "id": "spatial_solids", + "name": "Spatial Geometry — Polyhedra & Solids", + "name_vi": "Hình học không gian — khối đa diện và hình tròn xoay", + "grade": 11, "topic": "geometry", + "prerequisite_ids": ["spatial_geo"], + "exam_weight": 3.5, "bloom_ceiling": 4, + }, + { + "id": "comb_advanced", + "name": "Advanced Combinatorics", + "name_vi": "Tổ hợp nâng cao — nhị thức Newton", + "grade": 11, "topic": "combinatorics", + "prerequisite_ids": ["combinatorics"], + "exam_weight": 2.5, "bloom_ceiling": 5, + }, + { + "id": "prob_advanced", + "name": "Advanced Probability", + "name_vi": "Xác suất nâng cao — xác suất có điều kiện", + "grade": 11, "topic": "probability", + "prerequisite_ids": ["prob_basic", "comb_advanced"], + "exam_weight": 3.0, "bloom_ceiling": 5, + }, + { + "id": "complex_numbers", + "name": "Complex Numbers", + "name_vi": "Số phức", + "grade": 11, "topic": "algebra", + "prerequisite_ids": ["quad_eq", "trig_basic"], + "exam_weight": 1.5, "bloom_ceiling": 4, + }, + + # ── Grade 12 — Functions & Calculus ────────────────────────────────────── + + { + "id": "func_monotone", + "name": "Function Monotonicity", + "name_vi": "Tính đơn điệu của hàm số", + "grade": 12, "topic": "calculus", + "prerequisite_ids": ["quad_func", "logarithm"], + "exam_weight": 4.5, "bloom_ceiling": 4, + }, + { + "id": "func_extrema", + "name": "Local Extrema", + "name_vi": "Cực trị của hàm số", + "grade": 12, "topic": "calculus", + "prerequisite_ids": ["func_monotone"], + "exam_weight": 4.5, "bloom_ceiling": 4, + }, + { + "id": "func_asymptote", + "name": "Asymptotes", + "name_vi": "Đường tiệm cận", + "grade": 12, "topic": "calculus", + "prerequisite_ids": ["func_monotone"], + "exam_weight": 3.5, "bloom_ceiling": 4, + }, + { + "id": "func_graph", + "name": "Function Graph Sketching", + "name_vi": "Khảo sát và vẽ đồ thị hàm số", + "grade": 12, "topic": "calculus", + "prerequisite_ids": ["func_extrema", "func_asymptote"], + "exam_weight": 4.0, "bloom_ceiling": 5, + }, + { + "id": "derivative_rules", + "name": "Differentiation Rules", + "name_vi": "Quy tắc tính đạo hàm", + "grade": 12, "topic": "calculus", + "prerequisite_ids": ["linear_func", "exponential", "trig_basic"], + "exam_weight": 4.0, "bloom_ceiling": 3, + }, + { + "id": "derivative_apps", + "name": "Derivative Applications", + "name_vi": "Ứng dụng đạo hàm — tốc độ, tối ưu", + "grade": 12, "topic": "calculus", + "prerequisite_ids": ["derivative_rules", "func_extrema"], + "exam_weight": 3.5, "bloom_ceiling": 5, + }, + { + "id": "global_extrema", + "name": "Global Extrema on Closed Interval", + "name_vi": "GTLN và GTNN trên đoạn", + "grade": 12, "topic": "calculus", + "prerequisite_ids": ["func_extrema", "derivative_rules"], + "exam_weight": 4.0, "bloom_ceiling": 4, + }, + + # ── Grade 12 — Integrals ────────────────────────────────────────────────── + + { + "id": "antiderivative", + "name": "Antiderivatives", + "name_vi": "Nguyên hàm", + "grade": 12, "topic": "calculus", + "prerequisite_ids": ["derivative_rules"], + "exam_weight": 4.0, "bloom_ceiling": 3, + }, + { + "id": "definite_integral", + "name": "Definite Integrals", + "name_vi": "Tích phân xác định — Newton-Leibniz", + "grade": 12, "topic": "calculus", + "prerequisite_ids": ["antiderivative"], + "exam_weight": 4.5, "bloom_ceiling": 3, + }, + { + "id": "integral_area", + "name": "Area Under a Curve", + "name_vi": "Ứng dụng tích phân — diện tích hình phẳng", + "grade": 12, "topic": "calculus", + "prerequisite_ids": ["definite_integral"], + "exam_weight": 4.0, "bloom_ceiling": 4, + }, + { + "id": "integral_volume", + "name": "Volume of Revolution", + "name_vi": "Ứng dụng tích phân — thể tích vật thể tròn xoay", + "grade": 12, "topic": "calculus", + "prerequisite_ids": ["integral_area"], + "exam_weight": 3.0, "bloom_ceiling": 5, + }, + { + "id": "integral_by_parts", + "name": "Integration by Parts", + "name_vi": "Tích phân từng phần", + "grade": 12, "topic": "calculus", + "prerequisite_ids": ["antiderivative"], + "exam_weight": 2.5, "bloom_ceiling": 4, + }, + { + "id": "integral_substitution", + "name": "Integration by Substitution", + "name_vi": "Tích phân bằng phương pháp đổi biến", + "grade": 12, "topic": "calculus", + "prerequisite_ids": ["antiderivative"], + "exam_weight": 3.0, "bloom_ceiling": 4, + }, + + # ── Grade 12 — 3D Geometry (Oxyz) ───────────────────────────────────────── + + { + "id": "oxyz_coords", + "name": "Oxyz Coordinate System", + "name_vi": "Hệ tọa độ không gian Oxyz", + "grade": 12, "topic": "geometry", + "prerequisite_ids": ["spatial_geo", "vectors"], + "exam_weight": 3.5, "bloom_ceiling": 3, + }, + { + "id": "oxyz_plane", + "name": "Planes in Space", + "name_vi": "Phương trình mặt phẳng", + "grade": 12, "topic": "geometry", + "prerequisite_ids": ["oxyz_coords"], + "exam_weight": 4.0, "bloom_ceiling": 4, + }, + { + "id": "oxyz_line", + "name": "Lines in Space", + "name_vi": "Phương trình đường thẳng trong không gian", + "grade": 12, "topic": "geometry", + "prerequisite_ids": ["oxyz_coords"], + "exam_weight": 3.5, "bloom_ceiling": 4, + }, + { + "id": "oxyz_distance", + "name": "Distance & Angle in Space", + "name_vi": "Khoảng cách và góc trong không gian", + "grade": 12, "topic": "geometry", + "prerequisite_ids": ["oxyz_plane", "oxyz_line"], + "exam_weight": 4.0, "bloom_ceiling": 5, + }, + { + "id": "oxyz_sphere", + "name": "Sphere Equation", + "name_vi": "Phương trình mặt cầu", + "grade": 12, "topic": "geometry", + "prerequisite_ids": ["oxyz_coords"], + "exam_weight": 3.0, "bloom_ceiling": 4, + }, + + # ── Grade 12 — Probability & Statistics ────────────────────────────────── + + { + "id": "bayes", + "name": "Bayes' Theorem", + "name_vi": "Công thức Bayes và xác suất toàn phần", + "grade": 12, "topic": "probability", + "prerequisite_ids": ["prob_advanced"], + "exam_weight": 3.5, "bloom_ceiling": 5, + }, + { + "id": "binomial_dist", + "name": "Binomial Distribution", + "name_vi": "Phân phối nhị thức", + "grade": 12, "topic": "probability", + "prerequisite_ids": ["comb_advanced", "prob_advanced"], + "exam_weight": 2.5, "bloom_ceiling": 4, + }, + { + "id": "normal_dist", + "name": "Normal Distribution", + "name_vi": "Phân phối chuẩn và ứng dụng", + "grade": 12, "topic": "statistics", + "prerequisite_ids": ["binomial_dist", "stats_basic"], + "exam_weight": 2.0, "bloom_ceiling": 4, + }, + + # ── Advanced / Cross-domain ─────────────────────────────────────────────── + + { + "id": "lhopital", + "name": "L'Hôpital's Rule", + "name_vi": "Quy tắc L'Hôpital", + "grade": 12, "topic": "calculus", + "prerequisite_ids": ["derivative_rules"], + "exam_weight": 1.5, "bloom_ceiling": 4, + }, + { + "id": "optimization", + "name": "Optimization Problems", + "name_vi": "Bài toán tối ưu hóa thực tế", + "grade": 12, "topic": "calculus", + "prerequisite_ids": ["derivative_apps", "global_extrema"], + "exam_weight": 4.0, "bloom_ceiling": 6, + }, + { + "id": "math_induction", + "name": "Mathematical Induction", + "name_vi": "Quy nạp toán học", + "grade": 11, "topic": "algebra", + "prerequisite_ids": ["sequences"], + "exam_weight": 1.5, "bloom_ceiling": 6, + }, + { + "id": "coordinate_circle", + "name": "Circle Equation in Oxy", + "name_vi": "Phương trình đường tròn trong mặt phẳng tọa độ", + "grade": 10, "topic": "geometry", + "prerequisite_ids": ["coord_geo", "circles"], + "exam_weight": 3.0, "bloom_ceiling": 4, + }, + { + "id": "conic_sections", + "name": "Conic Sections", + "name_vi": "Elip, hypebol, parabol trong tọa độ", + "grade": 12, "topic": "geometry", + "prerequisite_ids": ["coord_geo", "quad_func"], + "exam_weight": 2.5, "bloom_ceiling": 5, + }, + { + "id": "func_rational", + "name": "Rational Functions", + "name_vi": "Hàm số phân thức bậc nhất trên bậc nhất", + "grade": 12, "topic": "calculus", + "prerequisite_ids": ["func_asymptote", "derivative_rules"], + "exam_weight": 4.0, "bloom_ceiling": 4, + }, +] diff --git a/backend/app/data/question_answers.json b/backend/app/data/question_answers.json new file mode 100644 index 0000000000000000000000000000000000000000..b922f39f3ceb3c04d7862797ff660be5fd7f601c --- /dev/null +++ b/backend/app/data/question_answers.json @@ -0,0 +1 @@ +{"q_amc8_001":0,"q_amc8_002":0,"q_amc8_003":0,"q_amc8_004":0,"q_amc8_005":0,"q_amc8_006":0,"q_amc8_007":0,"q_amc8_008":0,"q_amc8_009":0,"q_amc8_010":0,"q_amc8_011":0,"q_amc8_012":0,"q_amc8_013":0,"q_amc8_014":0,"q_amc8_015":0,"q_amc8_016":0,"q_amc8_017":0,"q_amc8_018":0,"q_amc8_019":0,"q_amc8_020":0,"q_amc10_001":0,"q_amc10_002":0,"q_amc10_003":0,"q_amc10_004":0,"q_amc10_005":0,"q_amc10_006":0,"q_amc10_007":0,"q_amc10_008":0,"q_amc10_009":0,"q_amc10_010":0,"q_amc10_011":0,"q_amc10_012":0,"q_amc10_013":0,"q_amc10_014":0,"q_amc10_015":0,"q_amc10_016":0,"q_amc10_017":0,"q_amc10_018":0,"q_amc10_019":0,"q_amc10_020":0,"q_gcse_001":0,"q_gcse_002":0,"q_gcse_003":0,"q_gcse_004":0,"q_gcse_005":0,"q_gcse_006":0,"q_gcse_007":0,"q_gcse_008":0,"q_gcse_009":0,"q_gcse_010":0,"q_gcse_011":0,"q_gcse_012":0,"q_gcse_013":0,"q_gcse_014":0,"q_gcse_015":0,"q_gcse_016":0,"q_gcse_017":0,"q_gcse_018":0,"q_gcse_019":0,"q_gcse_020":0,"q_sgol_001":0,"q_sgol_002":0,"q_sgol_003":0,"q_sgol_004":0,"q_sgol_005":0,"q_sgol_006":0,"q_sgol_007":0,"q_sgol_008":0,"q_sgol_009":0,"q_sgol_010":0,"q_sgol_011":0,"q_sgol_012":0,"q_sgol_013":0,"q_sgol_014":0,"q_sgol_015":0,"q_sgol_016":0,"q_sgol_017":0,"q_sgol_018":0,"q_sgol_019":0,"q_sgol_020":0,"q_amc_au_001":0,"q_amc_au_002":0,"q_amc_au_003":0,"q_amc_au_004":0,"q_amc_au_005":0,"q_amc_au_006":0,"q_amc_au_007":0,"q_amc_au_008":0,"q_amc_au_009":0,"q_amc_au_010":0,"q_amc_au_011":0,"q_amc_au_012":0,"q_amc_au_013":0,"q_amc_au_014":0,"q_amc_au_015":0,"q_amc_au_016":0,"q_amc_au_017":0,"q_amc_au_018":0,"q_amc_au_019":0,"q_amc_au_020":0,"q_vn_hn_001":0,"q_vn_hn_002":0,"q_vn_hn_003":0,"q_vn_hn_004":0,"q_vn_hn_005":0,"q_vn_hn_006":0,"q_vn_hn_007":0,"q_vn_hn_008":0,"q_vn_hn_009":0,"q_vn_hn_010":0,"q_vn_hn_011":0,"q_vn_hn_012":0,"q_vn_hn_013":0,"q_vn_hn_014":0,"q_vn_hn_015":0,"q_vn_hn_016":0,"q_vn_hn_017":0,"q_vn_hn_018":0,"q_vn_hn_019":0,"q_vn_hn_020":0,"q_vn_hcmc_001":0,"q_vn_hcmc_002":0,"q_vn_hcmc_003":0,"q_vn_hcmc_004":0,"q_vn_hcmc_005":0,"q_vn_hcmc_006":0,"q_vn_hcmc_007":0,"q_vn_hcmc_008":0,"q_vn_hcmc_009":0,"q_vn_hcmc_010":0,"q_vn_hcmc_011":0,"q_vn_hcmc_012":0,"q_vn_hcmc_013":0,"q_vn_hcmc_014":0,"q_vn_hcmc_015":0,"q_vn_hcmc_016":0,"q_vn_hcmc_017":0,"q_vn_hcmc_018":0,"q_vn_hcmc_019":0,"q_vn_hcmc_020":0,"q_vn_dn_001":0,"q_vn_dn_002":0,"q_vn_dn_003":0,"q_vn_dn_004":0,"q_vn_dn_005":0,"q_vn_dn_006":0,"q_vn_dn_007":0,"q_vn_dn_008":0,"q_vn_dn_009":0,"q_vn_dn_010":0,"q_vn_dn_011":0,"q_vn_dn_012":0,"q_vn_dn_013":0,"q_vn_dn_014":0,"q_vn_dn_015":0,"q_vn_dn_016":0,"q_vn_dn_017":0,"q_vn_dn_018":0,"q_vn_dn_019":0,"q_vn_dn_020":0,"q_vn_hp_001":0,"q_vn_hp_002":0,"q_vn_hp_003":0,"q_vn_hp_004":0,"q_vn_hp_005":0,"q_vn_hp_006":0,"q_vn_hp_007":0,"q_vn_hp_008":0,"q_vn_hp_009":0,"q_vn_hp_010":0,"q_vn_hp_011":0,"q_vn_hp_012":0,"q_vn_hp_013":0,"q_vn_hp_014":0,"q_vn_hp_015":0,"q_vn_hp_016":0,"q_vn_hp_017":0,"q_vn_hp_018":0,"q_vn_hp_019":0,"q_vn_hp_020":0,"q_vn_bgd_001":0,"q_vn_bgd_002":0,"q_vn_bgd_003":0,"q_vn_bgd_004":0,"q_vn_bgd_005":0,"q_vn_bgd_006":0,"q_vn_bgd_007":0,"q_vn_bgd_008":0,"q_vn_bgd_009":0,"q_vn_bgd_010":0,"q_vn_bgd_011":0,"q_vn_bgd_012":0,"q_vn_bgd_013":0,"q_vn_bgd_014":0,"q_vn_bgd_015":0,"q_vn_bgd_016":0,"q_vn_bgd_017":0,"q_vn_bgd_018":0,"q_vn_bgd_019":0,"q_vn_bgd_020":0,"q_thpt24_001":2,"q_thpt24_002":2,"q_thpt24_003":1,"q_thpt24_004":2,"q_thpt24_005":0,"q_thpt24_006":1,"q_thpt24_007":1,"q_thpt24_008":2,"q_thpt24_009":1,"q_thpt24_010":1,"q_thpt24_011":2,"q_thpt24_012":0,"q_thpt24_013":0,"q_thpt24_014":1,"q_thpt24_015":1,"q_thpt24_016":0,"q_thpt24_017":1,"q_thpt24_018":2,"q_thpt24_019":1,"q_thpt24_020":0,"q_thpt24_021":1,"q_thpt24_022":2,"q_thpt24_023":2,"q_thpt24_024":0,"q_thpt24_025":0,"q_thpt24_026":1,"q_thpt24_027":1,"q_thpt24_028":1,"q_thpt24_029":1,"q_thpt24_030":1,"q_thpt24_031":1,"q_thpt24_032":2,"q_thpt24_033":1,"q_thpt24_034":1,"q_thpt24_035":2,"q_thpt24_036":2,"q_thpt24_037":0,"q_thpt24_038":1,"q_thpt24_039":1,"q_thpt24_040":3,"q_thpt24_041":1,"q_thpt24_042":1,"q_thpt24_043":1,"q_thpt24_044":2,"q_thpt24_045":1,"q_thpt24_046":1,"q_thpt24_047":1,"q_thpt24_048":2,"q_thpt24_049":1,"q_thpt24_050":2,"q_thpt23_001":0,"q_thpt23_002":1,"q_thpt23_003":2,"q_thpt23_004":0,"q_thpt23_005":1,"q_thpt23_006":0,"q_thpt23_007":2,"q_thpt23_008":1,"q_thpt23_009":1,"q_thpt23_010":1,"q_thpt23_011":0,"q_thpt23_012":1,"q_thpt23_013":0,"q_thpt23_014":1,"q_thpt23_015":0,"q_thpt23_016":2,"q_thpt23_017":1,"q_thpt23_018":1,"q_thpt23_019":1,"q_thpt23_020":0,"q_thpt23_021":2,"q_thpt23_022":2,"q_thpt23_023":1,"q_thpt23_024":1,"q_thpt23_025":0,"q_thpt23_026":2,"q_thpt23_027":0,"q_thpt23_028":2,"q_thpt23_029":1,"q_thpt23_030":2,"q_thpt23_031":1,"q_thpt23_032":0,"q_thpt23_033":0,"q_thpt23_034":1,"q_thpt23_035":1,"q_thpt23_036":1,"q_thpt23_037":0,"q_thpt23_038":2,"q_thpt23_039":1,"q_thpt23_040":1,"q_thpt23_041":0,"q_thpt23_042":3,"q_thpt23_043":1,"q_thpt23_044":3,"q_thpt23_045":2,"q_thpt23_046":1,"q_thpt23_047":2,"q_thpt23_048":1,"q_thpt23_049":0,"q_thpt23_050":2,"q_thpt22_001":0,"q_thpt22_002":1,"q_thpt22_003":0,"q_thpt22_004":0,"q_thpt22_005":0,"q_thpt22_006":0,"q_thpt22_007":2,"q_thpt22_008":1,"q_thpt22_009":2,"q_thpt22_010":1,"q_thpt22_011":0,"q_thpt22_012":0,"q_thpt22_013":2,"q_thpt22_014":1,"q_thpt22_015":1,"q_thpt22_016":1,"q_thpt22_017":0,"q_thpt22_018":1,"q_thpt22_019":0,"q_thpt22_020":0,"q_thpt22_021":0,"q_thpt22_022":1,"q_thpt22_023":2,"q_thpt22_024":1,"q_thpt22_025":1,"q_thpt22_026":1,"q_thpt22_027":1,"q_thpt22_028":0,"q_thpt22_029":1,"q_thpt22_030":1,"q_thpt22_031":1,"q_thpt22_032":1,"q_thpt22_033":2,"q_thpt22_034":2,"q_thpt22_035":0,"q_thpt22_036":1,"q_thpt22_037":0,"q_thpt22_038":0,"q_thpt22_039":1,"q_thpt22_040":1,"q_thpt22_041":1,"q_thpt22_042":0,"q_thpt22_043":1,"q_thpt22_044":1,"q_thpt22_045":0,"q_thpt22_046":1,"q_thpt22_047":1,"q_thpt22_048":1,"q_thpt22_049":0,"q_thpt22_050":1,"q_thithu_hn24_001":1,"q_thithu_hn24_002":0,"q_thithu_hn24_003":1,"q_thithu_hn24_004":0,"q_thithu_hn24_005":1,"q_thithu_hn24_006":0,"q_thithu_hn24_007":2,"q_thithu_hn24_008":0,"q_thithu_hn24_009":0,"q_thithu_hn24_010":0,"q_thithu_hn24_011":2,"q_thithu_hn24_012":1,"q_thithu_hn24_013":0,"q_thithu_hn24_014":0,"q_thithu_hn24_015":1,"q_thithu_hn24_016":1,"q_thithu_hn24_017":2,"q_thithu_hn24_018":0,"q_thithu_hn24_019":0,"q_thithu_hn24_020":1,"q_thithu_hn24_021":0,"q_thithu_hn24_022":0,"q_thithu_hn24_023":0,"q_thithu_hn24_024":0,"q_thithu_hn24_025":0,"q_thithu_hn24_026":0,"q_thithu_hn24_027":0,"q_thithu_hn24_028":0,"q_thithu_hn24_029":0,"q_thithu_hn24_030":0,"q_thithu_hn24_031":0,"q_thithu_hn24_032":0,"q_thithu_hn24_033":0,"q_thithu_hn24_034":0,"q_thithu_hn24_035":0,"q_thithu_hn24_036":0,"q_thithu_hn24_037":0,"q_thithu_hn24_038":0,"q_thithu_hn24_039":0,"q_thithu_hn24_040":0,"q_thithu_hn24_041":0,"q_thithu_hn24_042":0,"q_thithu_hn24_043":0,"q_thithu_hn24_044":0,"q_thithu_hn24_045":0,"q_thithu_hn24_046":0,"q_thithu_hn24_047":0,"q_thithu_hn24_048":0,"q_thithu_hn24_049":0,"q_thithu_hn24_050":0,"q_thithu_hcm24_001":2,"q_thithu_hcm24_002":1,"q_thithu_hcm24_003":1,"q_thithu_hcm24_004":2,"q_thithu_hcm24_005":2,"q_thithu_hcm24_006":1,"q_thithu_hcm24_007":0,"q_thithu_hcm24_008":1,"q_thithu_hcm24_009":0,"q_thithu_hcm24_010":0,"q_thithu_hcm24_011":1,"q_thithu_hcm24_012":1,"q_thithu_hcm24_013":0,"q_thithu_hcm24_014":0,"q_thithu_hcm24_015":2,"q_thithu_hcm24_016":2,"q_thithu_hcm24_017":1,"q_thithu_hcm24_018":1,"q_thithu_hcm24_019":0,"q_thithu_hcm24_020":1,"q_thithu_hcm24_021":0,"q_thithu_hcm24_022":0,"q_thithu_hcm24_023":0,"q_thithu_hcm24_024":0,"q_thithu_hcm24_025":0,"q_thithu_hcm24_026":0,"q_thithu_hcm24_027":0,"q_thithu_hcm24_028":0,"q_thithu_hcm24_029":0,"q_thithu_hcm24_030":0,"q_thithu_hcm24_031":0,"q_thithu_hcm24_032":0,"q_thithu_hcm24_033":0,"q_thithu_hcm24_034":0,"q_thithu_hcm24_035":0,"q_thithu_hcm24_036":0,"q_thithu_hcm24_037":0,"q_thithu_hcm24_038":0,"q_thithu_hcm24_039":0,"q_thithu_hcm24_040":0,"q_thithu_hcm24_041":0,"q_thithu_hcm24_042":0,"q_thithu_hcm24_043":0,"q_thithu_hcm24_044":0,"q_thithu_hcm24_045":0,"q_thithu_hcm24_046":0,"q_thithu_hcm24_047":0,"q_thithu_hcm24_048":0,"q_thithu_hcm24_049":0,"q_thithu_hcm24_050":0,"q_sat_001":0,"q_sat_002":0,"q_sat_003":0,"q_sat_004":0,"q_sat_005":0,"q_sat_006":0,"q_sat_007":0,"q_sat_008":0,"q_sat_009":0,"q_sat_010":0,"q_sat_011":0,"q_sat_012":0,"q_sat_013":0,"q_sat_014":0,"q_sat_015":0,"q_sat_016":0,"q_sat_017":0,"q_sat_018":0,"q_sat_019":0,"q_sat_020":0,"q_gauss_001":0,"q_gauss_002":0,"q_gauss_003":0,"q_gauss_004":0,"q_gauss_005":0,"q_gauss_006":0,"q_gauss_007":0,"q_gauss_008":0,"q_gauss_009":0,"q_gauss_010":0,"q_gauss_011":0,"q_gauss_012":0,"q_gauss_013":0,"q_gauss_014":0,"q_gauss_015":0,"q_gauss_016":0,"q_gauss_017":0,"q_gauss_018":0,"q_gauss_019":0,"q_gauss_020":0,"q_ib_sl_001":0,"q_ib_sl_002":0,"q_ib_sl_003":0,"q_ib_sl_004":0,"q_ib_sl_005":0,"q_ib_sl_006":0,"q_ib_sl_007":0,"q_ib_sl_008":0,"q_ib_sl_009":0,"q_ib_sl_010":0,"q_ib_sl_011":0,"q_ib_sl_012":0,"q_ib_sl_013":0,"q_ib_sl_014":0,"q_ib_sl_015":0,"q_ib_sl_016":0,"q_ib_sl_017":0,"q_ib_sl_018":0,"q_ib_sl_019":0,"q_ib_sl_020":0,"q_ksat_001":0,"q_ksat_002":0,"q_ksat_003":0,"q_ksat_004":0,"q_ksat_005":0,"q_ksat_006":0,"q_ksat_007":0,"q_ksat_008":0,"q_ksat_009":0,"q_ksat_010":0,"q_ksat_011":0,"q_ksat_012":0,"q_ksat_013":0,"q_ksat_014":0,"q_ksat_015":0,"q_ksat_016":0,"q_ksat_017":0,"q_ksat_018":0,"q_ksat_019":0,"q_ksat_020":0,"q_jee_001":0,"q_jee_002":0,"q_jee_003":0,"q_jee_004":0,"q_jee_005":0,"q_jee_006":0,"q_jee_007":0,"q_jee_008":0,"q_jee_009":0,"q_jee_010":0,"q_jee_011":0,"q_jee_012":0,"q_jee_013":0,"q_jee_014":0,"q_jee_015":0,"q_jee_016":0,"q_jee_017":0,"q_jee_018":0,"q_jee_019":0,"q_jee_020":0,"q_sat_021":0,"q_sat_022":0,"q_sat_023":0,"q_sat_024":0,"q_sat_025":0,"q_sat_026":0,"q_sat_027":0,"q_sat_028":0,"q_sat_029":0,"q_sat_030":0,"q_sat_031":0,"q_sat_032":0,"q_sat_033":0,"q_sat_034":0,"q_sat_035":0,"q_sat_036":0,"q_sat_037":0,"q_sat_038":0,"q_sat_039":0,"q_sat_040":0,"q_sat_041":0,"q_sat_042":0,"q_sat_043":0,"q_sat_044":0,"q_gauss_021":0,"q_gauss_022":0,"q_gauss_023":0,"q_gauss_024":0,"q_gauss_025":0,"q_ib_sl_021":0,"q_ib_sl_022":0,"q_ib_sl_023":0,"q_ib_sl_024":0,"q_ib_sl_025":0,"q_ib_sl_026":0,"q_ib_sl_027":0,"q_ib_sl_028":0,"q_ib_sl_029":0,"q_ib_sl_030":0,"q_ksat_021":0,"q_ksat_022":0,"q_ksat_023":0,"q_ksat_024":0,"q_ksat_025":0,"q_ksat_026":0,"q_ksat_027":0,"q_ksat_028":0,"q_ksat_029":0,"q_ksat_030":0,"q_jee_021":0,"q_jee_022":0,"q_jee_023":0,"q_jee_024":0,"q_jee_025":0,"q_jee_026":0,"q_jee_027":0,"q_jee_028":0,"q_jee_029":0,"q_jee_030":0,"q_mx01_001":3,"q_mx01_002":3,"q_mx01_003":1,"q_mx01_004":0,"q_mx01_005":1,"q_mx01_006":3,"q_mx01_007":1,"q_mx01_008":2,"q_mx01_009":0,"q_mx01_010":2,"q_mx01_011":1,"q_mx01_012":3,"q_mx01_013":3,"q_mx01_014":3,"q_mx01_015":0,"q_mx01_016":0,"q_mx01_017":3,"q_mx01_018":1,"q_mx01_019":0,"q_mx01_020":0,"q_mx01_021":1,"q_mx01_022":3,"q_mx01_023":1,"q_mx01_024":1,"q_mx01_025":3,"q_mx01_026":1,"q_mx01_027":0,"q_mx01_028":0,"q_mx01_029":1,"q_mx01_030":1,"q_mx01_031":2,"q_mx01_032":3,"q_mx01_033":0,"q_mx01_034":2,"q_mx01_035":3,"q_mx01_036":2,"q_mx01_037":2,"q_mx01_038":2,"q_mx01_039":3,"q_mx01_040":3,"q_mx01_041":1,"q_mx01_042":1,"q_mx01_043":0,"q_mx01_044":3,"q_mx01_045":0,"q_mx01_046":0,"q_mx01_047":2,"q_mx01_048":3,"q_mx01_049":0,"q_mx01_050":1,"q_vj01_01":1,"q_vj01_02":2,"q_vj01_03":1,"q_vj01_04":0,"q_vj01_05":3,"q_vj01_06":0,"q_vj01_07":2,"q_vj01_08":3,"q_vj02_01":2,"q_vj02_02":0,"q_vj02_03":3,"q_vj02_04":2,"q_vj02_05":1,"q_vj02_06":0,"q_vj02_07":1,"q_vj02_08":2,"q_vj03_01":3,"q_vj03_02":0,"q_vj03_03":3,"q_vj03_04":1,"q_vj03_05":0,"q_vj03_06":2,"q_vj03_07":1,"q_vj03_08":2,"q_vj04_01":1,"q_vj04_02":2,"q_vj04_03":0,"q_vj04_04":3,"q_vj04_05":0,"q_vj04_06":3,"q_vj04_07":2,"q_vj04_08":1,"q_vj05_01":2,"q_vj05_02":3,"q_vj05_03":0,"q_vj05_04":3,"q_vj05_05":1,"q_vj05_06":0,"q_vj05_07":3,"q_vj05_08":1,"q_lgh_173342_01":3,"q_lgh_173342_02":1,"q_lgh_173342_03":1,"q_lgh_173342_04":2,"q_lgh_173342_05":3,"q_lgh_173342_06":1,"q_lgh_173342_07":1,"q_lgh_173342_08":0,"q_lgh_173342_09":0,"q_lgh_173342_10":2,"q_lgh_173342_11":3,"q_lgh_173342_12":3,"q_lgh_173478_01":0,"q_lgh_173478_02":3,"q_lgh_173478_03":1,"q_lgh_173478_04":1,"q_lgh_173478_05":2,"q_lgh_173478_06":3,"q_lgh_173478_07":3,"q_lgh_173478_08":0,"q_lgh_173478_09":3,"q_lgh_173478_10":1,"q_lgh_173478_11":3,"q_lgh_173478_12":3,"q_lgh_173753_01":1,"q_lgh_173753_02":3,"q_lgh_173753_03":0,"q_lgh_173753_04":0,"q_lgh_173753_05":1,"q_lgh_173753_06":3,"q_lgh_173753_07":3,"q_lgh_173753_08":0,"q_lgh_173753_09":1,"q_lgh_173753_10":1,"q_lgh_173753_11":1,"q_lgh_173753_12":1,"q_lgh_173871_01":3,"q_lgh_173871_02":2,"q_lgh_173871_03":1,"q_lgh_173871_04":0,"q_lgh_173871_05":3,"q_lgh_173871_06":1,"q_lgh_173871_07":2,"q_lgh_173871_08":0,"q_lgh_173871_09":3,"q_lgh_173871_10":2,"q_lgh_173871_11":3,"q_lgh_173871_12":0,"q_lgh_173945_01":2,"q_lgh_173945_02":1,"q_lgh_173945_03":2,"q_lgh_173945_04":2,"q_lgh_173945_05":3,"q_lgh_173945_06":3,"q_lgh_173945_07":2,"q_lgh_173945_08":2,"q_lgh_173945_09":3,"q_lgh_173945_10":2,"q_lgh_173945_11":1,"q_lgh_173945_12":3,"q_lgh_180179_01":2,"q_lgh_180179_02":0,"q_lgh_180179_03":3,"q_lgh_180179_04":3,"q_lgh_180179_05":2,"q_lgh_180179_06":1,"q_lgh_180840_01":0,"q_lgh_180840_02":1,"q_lgh_180840_03":0,"q_lgh_180840_04":0,"q_lgh_180840_05":2,"q_lgh_180840_06":1,"q_lgh_180918_01":0,"q_lgh_180918_02":2,"q_lgh_180918_03":3,"q_lgh_180918_04":2,"q_lgh_180918_05":1,"q_lgh_180918_06":2,"q_lgh_180978_01":2,"q_lgh_180978_02":0,"q_lgh_180978_03":1,"q_lgh_180978_04":3,"q_lgh_180978_05":2,"q_lgh_180978_06":2,"q_lgh_181040_01":3,"q_lgh_181040_02":2,"q_lgh_181040_03":2,"q_lgh_181040_04":3,"q_lgh_181040_05":1,"q_lgh_181040_06":1,"q_lgh_177656_01":0,"q_lgh_177656_02":3,"q_lgh_177656_03":1,"q_lgh_177656_04":2,"q_lgh_177656_05":2,"q_lgh_177656_06":2,"q_lgh_177656_07":1,"q_lgh_177656_08":3,"q_lgh_177656_09":0,"q_lgh_177656_10":1,"q_lgh_177656_11":3,"q_lgh_177656_12":3,"q_lgh_177657_01":0,"q_lgh_177657_02":0,"q_lgh_177657_03":3,"q_lgh_177657_04":2,"q_lgh_177657_05":2,"q_lgh_177657_06":0,"q_lgh_177657_07":3,"q_lgh_177657_08":1,"q_lgh_177657_09":0,"q_lgh_177657_10":3,"q_lgh_177657_11":1,"q_lgh_177657_12":3,"q_lgh_177661_01":1,"q_lgh_177661_02":0,"q_lgh_177661_03":2,"q_lgh_177661_04":0,"q_lgh_177661_05":1,"q_lgh_177661_06":2,"q_lgh_177661_07":3,"q_lgh_177661_08":1,"q_lgh_177661_10":0,"q_lgh_177661_11":3,"q_lgh_177661_12":2,"q_lgh_177714_01":2,"q_lgh_177714_02":2,"q_lgh_177714_03":1,"q_lgh_177714_04":3,"q_lgh_177714_05":1,"q_lgh_177714_06":1,"q_lgh_177714_07":2,"q_lgh_177714_08":2,"q_lgh_177714_09":1,"q_lgh_177714_10":2,"q_lgh_177714_11":0,"q_lgh_177714_12":0,"q_lgh_177717_01":3,"q_lgh_177717_02":0,"q_lgh_177717_03":2,"q_lgh_177717_04":1,"q_lgh_177717_05":2,"q_lgh_177717_06":3,"q_lgh_177717_07":2,"q_lgh_177717_08":2,"q_lgh_177717_09":3,"q_lgh_177717_10":0,"q_lgh_177717_11":1,"q_lgh_177717_12":3,"q_lgh_182201_01":2,"q_lgh_182201_02":2,"q_lgh_182201_03":2,"q_lgh_182201_04":2,"q_lgh_182201_05":3,"q_lgh_182201_06":3,"q_lgh_182201_07":1,"q_lgh_182201_08":0,"q_lgh_182201_09":0,"q_lgh_182201_10":2,"q_lgh_182201_11":3,"q_lgh_182201_12":1,"q_lgh_182313_01":1,"q_lgh_182313_02":1,"q_lgh_182313_03":2,"q_lgh_182313_04":2,"q_lgh_182313_05":2,"q_lgh_182313_06":0,"q_lgh_182313_07":3,"q_lgh_182313_08":1,"q_lgh_182313_09":3,"q_lgh_182313_10":0,"q_lgh_182313_11":2,"q_lgh_182313_12":1,"q_lgh_182339_01":0,"q_lgh_182339_02":3,"q_lgh_182339_03":1,"q_lgh_182339_04":2,"q_lgh_182339_05":3,"q_lgh_182339_06":2,"q_lgh_182339_07":2,"q_lgh_182339_08":3,"q_lgh_182339_09":3,"q_lgh_182339_10":1,"q_lgh_182339_11":0,"q_lgh_182339_12":2,"q_lgh_182635_01":1,"q_lgh_182635_02":2,"q_lgh_182635_03":2,"q_lgh_182635_04":3,"q_lgh_182635_05":2,"q_lgh_182635_06":2,"q_lgh_182635_07":1,"q_lgh_182635_09":2,"q_lgh_182635_10":3,"q_lgh_182635_11":1,"q_lgh_182635_12":2,"q_lgh_182707_01":0,"q_lgh_182707_02":0,"q_lgh_182707_03":1,"q_lgh_182707_04":0,"q_lgh_182707_05":1,"q_lgh_182707_06":3,"q_lgh_182707_07":3,"q_lgh_182707_08":0,"q_lgh_182707_09":2,"q_lgh_182707_10":0,"q_lgh_182707_11":1,"q_lgh_182707_12":0,"q_bece22_01":1,"q_bece22_02":0,"q_bece22_03":2,"q_bece22_04":3,"q_bece22_05":1,"q_bece22_06":1,"q_bece22_07":1,"q_bece22_08":2,"q_bece22_09":3,"q_bece22_10":1,"q_bece22_11":3,"q_bece22_12":1,"q_bece22_13":0,"q_bece22_14":0,"q_bece22_15":1,"q_bece22_16":1,"q_bece22_17":2,"q_bece22_18":2,"q_bece22_19":3,"q_bece22_20":0,"q_bece22_21":1,"q_bece22_22":3,"q_bece22_23":0,"q_bece22_24":3,"q_bece22_25":2,"q_bece21_01":0,"q_bece21_02":0,"q_bece21_03":2,"q_bece21_04":2,"q_bece21_05":2,"q_bece21_06":0,"q_bece21_07":2,"q_bece21_08":2,"q_bece21_09":0,"q_bece21_10":2,"q_bece21_11":2,"q_bece21_12":3,"q_bece21_13":0,"q_bece21_14":2,"q_bece21_15":2,"q_bece21_16":1,"q_bece21_17":1,"q_bece21_18":2,"q_bece21_19":1,"q_bece21_20":2,"q_bece21_21":0,"q_bece21_22":3,"q_bece21_23":1,"q_bece21_24":0,"q_bece21_25":0,"q_bece24_01":3,"q_bece24_02":1,"q_bece24_03":2,"q_bece24_04":1,"q_bece24_05":0,"q_bece24_06":0,"q_bece24_07":0,"q_bece24_08":0,"q_bece24_09":1,"q_bece24_10":3,"q_bece24_11":1,"q_bece24_12":2,"q_bece24_13":1,"q_bece24_14":3,"q_bece24_15":0,"q_bece24_16":0,"q_bece24_17":0,"q_bece24_18":1,"q_bece24_19":0,"q_bece24_20":2,"q_bece24_21":2,"q_bece24_22":3,"q_bece24_23":2,"q_bece24_24":1,"q_bece24_25":2,"q_cbse24p1_01":3,"q_cbse24p1_02":1,"q_cbse24p1_03":0,"q_cbse24p1_04":0,"q_cbse24p1_05":0,"q_cbse24p1_06":2,"q_cbse24p1_07":1,"q_cbse24p1_08":0,"q_cbse24p1_09":0,"q_cbse24p1_10":3,"q_cbse24p1_11":1,"q_cbse24p1_12":1,"q_cbse24p1_13":1,"q_cbse24p1_14":3,"q_cbse24p1_15":1,"q_cbse24p1_16":1,"q_cbse24p1_17":0,"q_cbse24p1_18":1,"q_cbse24p1_19":2,"q_cbse24p1_20":3,"q_cbse24p1_21":0,"q_cbse24p1_22":1,"q_cbse24p1_23":0,"q_cbse24p1_24":0,"q_cbse24p1_25":0,"q_cbse24p2_01":1,"q_cbse24p2_02":0,"q_cbse24p2_03":2,"q_cbse24p2_04":1,"q_cbse24p2_05":0,"q_cbse24p2_06":0,"q_cbse24p2_07":1,"q_cbse24p2_08":1,"q_cbse24p2_09":2,"q_cbse24p2_10":2,"q_cbse24p2_11":1,"q_cbse24p2_12":0,"q_cbse24p2_13":2,"q_cbse24p2_14":0,"q_cbse24p2_15":0,"q_cbse24p2_16":3,"q_cbse24p2_17":2,"q_cbse24p2_18":2,"q_cbse24p2_19":1,"q_cbse24p2_20":1,"q_cbse24p2_21":3,"q_cbse24p2_22":0,"q_cbse24p2_23":1,"q_cbse24p2_24":0,"q_cbse24p2_25":0,"q_bece22_26":0,"q_bece22_27":1,"q_bece22_28":1,"q_bece22_29":0,"q_bece22_30":2,"q_bece22_31":1,"q_bece22_32":0,"q_bece22_33":2,"q_bece22_34":1,"q_bece22_35":0,"q_bece22_36":1,"q_bece22_37":1,"q_bece22_38":1,"q_bece22_39":1,"q_bece21_26":1,"q_bece21_27":2,"q_bece21_28":0,"q_bece21_29":0,"q_bece21_30":0,"q_bece21_31":0,"q_bece21_32":0,"q_bece21_33":0,"q_bece21_34":0,"q_bece21_35":0,"q_bece21_36":3,"q_bece21_37":1,"q_bece21_38":0,"q_bece21_39":1,"q_bece24_26":2,"q_bece24_27":0,"q_bece24_28":1,"q_bece24_29":1,"q_bece24_30":2,"q_bece24_31":2,"q_bece24_32":1,"q_bece24_33":3,"q_bece24_34":2,"q_bece24_35":0,"q_bece24_36":1,"q_bece24_37":1,"q_bece24_38":3,"q_bece24_39":3,"q_cbse24p1_26":2,"q_cbse24p1_27":0,"q_cbse24p1_28":0,"q_cbse24p1_29":1,"q_cbse24p1_30":1,"q_cbse24p1_31":3,"q_cbse24p1_32":2,"q_cbse24p1_33":2,"q_cbse24p1_34":0,"q_cbse24p1_35":1,"q_cbse24p1_36":0,"q_cbse24p1_37":1,"q_cbse24p1_38":0,"q_cbse24p1_39":1,"q_cbse24p1_40":0,"q_cbse24p2_26":0,"q_cbse24p2_27":1,"q_cbse24p2_28":1,"q_cbse24p2_29":1,"q_cbse24p2_30":2,"q_cbse24p2_31":1,"q_cbse24p2_32":1,"q_cbse24p2_33":3,"q_cbse24p2_34":1,"q_cbse24p2_35":2,"q_cbse24p2_36":2,"q_cbse24p2_37":0,"q_cbse24p2_38":0,"q_cbse24p2_39":1,"q_cbse24p2_40":0} \ No newline at end of file diff --git a/backend/app/db.py b/backend/app/db.py new file mode 100644 index 0000000000000000000000000000000000000000..4ee078f0f01dc469bb9a88b1e00976c96d93156b --- /dev/null +++ b/backend/app/db.py @@ -0,0 +1,214 @@ +"""asyncpg-compatible wrapper around aiosqlite for local SQLite storage. + +Drop-in replacement for the asyncpg connection pool: same acquire(), fetchrow(), +fetch(), fetchval(), execute(), and transaction() API, so pg_db.py / analytics.py / +sanitizer.py require zero changes. + +SQL translation handled automatically: + $N placeholders → ? + = ANY($N) → IN (?,?,?) (array expansion) + ::type casts → stripped + NOW() → datetime('now') + list params → JSON-serialised (for embedding storage) + +Architecture note: + A single persistent aiosqlite connection is shared across all callers. An + asyncio.Lock serialises every acquire() so only one coroutine touches the + SQLite file at a time. This eliminates the concurrent-writer corruption that + WAL mode's shared-memory coordination (-shm/-wal files) causes on + network/container filesystems (NFS, Docker overlays, HuggingFace Spaces /data). +""" +import asyncio +import json +import logging +import re +import sqlite3 +from contextlib import asynccontextmanager +from pathlib import Path +from typing import Any, AsyncGenerator, Optional + +import aiosqlite + +logger = logging.getLogger(__name__) + + +class Row(dict): + """Dict that also supports positional (integer) access like asyncpg Record.""" + + def __getitem__(self, key: Any) -> Any: + if isinstance(key, int): + return list(self.values())[key] + return super().__getitem__(key) + + +def _translate(query: str, params: tuple) -> tuple[str, list]: + """Translate PostgreSQL query + params to SQLite equivalents.""" + # Detect which $N positions are used by ANY($N) (0-based) + any_positions: set[int] = set() + for m in re.finditer(r"=\s*ANY\(\$(\d+)\)", query, re.IGNORECASE): + any_positions.add(int(m.group(1)) - 1) + + # Expand = ANY($N) → IN (?,?,?) + def _expand_any(m: re.Match) -> str: + idx = int(m.group(1)) - 1 + arr = list(params[idx]) if params[idx] else [] + return f"IN ({','.join(['?'] * len(arr))})" if arr else "IN (NULL)" + + query = re.sub(r"=\s*ANY\(\$(\d+)\)", _expand_any, query, flags=re.IGNORECASE) + + # Strip PostgreSQL type casts: ::jsonb, ::timestamptz, ::text[], ::float, etc. + query = re.sub(r"::[a-zA-Z_][\w\[\]]*", "", query) + + # Replace NOW() with SQLite equivalent + query = re.sub(r"\bNOW\(\)", "datetime('now')", query, flags=re.IGNORECASE) + + # Build flat params, expanding ANY arrays and serialising lists→JSON + new_params: list = [] + for i, p in enumerate(params): + if i in any_positions: + new_params.extend(list(p) if p else []) + elif isinstance(p, list): + new_params.append(json.dumps(p)) + else: + new_params.append(p) + + # Replace remaining $N placeholders with ? + query = re.sub(r"\$\d+", "?", query) + + return query, new_params + + +class _Connection: + def __init__(self, conn: aiosqlite.Connection) -> None: + self._conn = conn + self._in_transaction = False + + async def fetchrow(self, query: str, *args) -> Optional[Row]: + q, params = _translate(query, args) + cur = await self._conn.execute(q, params) + row = await cur.fetchone() + # Commit after fetch so INSERT … RETURNING rows are both readable and persisted. + if not self._in_transaction: + await self._conn.commit() + if row is None or cur.description is None: + return None + cols = [d[0] for d in cur.description] + return Row(zip(cols, row)) + + async def fetch(self, query: str, *args) -> list[Row]: + q, params = _translate(query, args) + cur = await self._conn.execute(q, params) + rows = await cur.fetchall() + if not self._in_transaction: + await self._conn.commit() + if cur.description is None: + return [] + cols = [d[0] for d in cur.description] + return [Row(zip(cols, r)) for r in rows] + + async def fetchval(self, query: str, *args) -> Any: + q, params = _translate(query, args) + cur = await self._conn.execute(q, params) + row = await cur.fetchone() + if not self._in_transaction: + await self._conn.commit() + return row[0] if row else None + + async def execute(self, query: str, *args) -> str: + q, params = _translate(query, args) + cur = await self._conn.execute(q, params) + if not self._in_transaction: + await self._conn.commit() + return f"UPDATE {cur.rowcount}" + + @asynccontextmanager + async def transaction(self) -> AsyncGenerator[None, None]: + self._in_transaction = True + try: + yield + await self._conn.commit() + except BaseException: + await self._conn.rollback() + raise + finally: + self._in_transaction = False + + +class AsyncSQLitePool: + """Single-connection asyncpg-compatible pool backed by a local SQLite file. + + One persistent aiosqlite connection is shared by all callers. An asyncio.Lock + ensures only one coroutine executes against the connection at a time, which is + both sufficient (single uvicorn process) and necessary (prevents WAL-mode + shared-memory corruption on container/NFS filesystems). + """ + + def __init__(self, db_path: str) -> None: + self._path = db_path + self._conn: Optional[aiosqlite.Connection] = None + self._lock: asyncio.Lock = asyncio.Lock() + Path(db_path).parent.mkdir(parents=True, exist_ok=True) + + async def initialize(self) -> None: + """Open the persistent connection; auto-recover if the DB file is corrupted.""" + try: + conn = await aiosqlite.connect(self._path) + await conn.execute("PRAGMA foreign_keys = ON") + await conn.execute("PRAGMA cache_size = -64000") # 64 MB in-process page cache + await conn.execute("PRAGMA busy_timeout = 5000") # queue 5 s before failing + cur = await conn.execute("PRAGMA integrity_check") + row = await cur.fetchone() + if row and row[0] != "ok": + await conn.close() + raise sqlite3.DatabaseError(f"integrity_check: {row[0]}") + await conn.commit() + self._conn = conn + except (sqlite3.DatabaseError, Exception) as exc: + logger.warning("DB at %s is corrupt (%s) — wiping and recreating", self._path, exc) + if self._conn is not None: + try: + await self._conn.close() + except Exception: + pass + self._conn = None + path = Path(self._path) + for suffix in ("", "-wal", "-shm"): + candidate = Path(str(path) + suffix) + if candidate.exists(): + candidate.unlink() + conn = await aiosqlite.connect(self._path) + await conn.execute("PRAGMA foreign_keys = ON") + await conn.execute("PRAGMA cache_size = -64000") + await conn.execute("PRAGMA busy_timeout = 5000") + await conn.commit() + self._conn = conn + logger.info("Fresh DB created at %s", self._path) + + @asynccontextmanager + async def acquire(self) -> AsyncGenerator[_Connection, None]: + async with self._lock: + yield _Connection(self._conn) + + # Shortcut methods (asyncpg pools expose these directly) + + async def fetchrow(self, query: str, *args) -> Optional[Row]: + async with self.acquire() as conn: + return await conn.fetchrow(query, *args) + + async def fetch(self, query: str, *args) -> list[Row]: + async with self.acquire() as conn: + return await conn.fetch(query, *args) + + async def fetchval(self, query: str, *args) -> Any: + async with self.acquire() as conn: + return await conn.fetchval(query, *args) + + async def execute(self, query: str, *args) -> str: + async with self.acquire() as conn: + return await conn.execute(query, *args) + + async def close(self) -> None: + async with self._lock: + if self._conn is not None: + await self._conn.close() + self._conn = None diff --git a/backend/app/dependencies.py b/backend/app/dependencies.py new file mode 100644 index 0000000000000000000000000000000000000000..8190c06c2e1c31b8c82c7438fa0b83f5726e42a2 --- /dev/null +++ b/backend/app/dependencies.py @@ -0,0 +1,108 @@ +import time +from functools import lru_cache +from openai import AsyncOpenAI +from fastapi import Depends, HTTPException, Request, status +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +from pydantic import BaseModel +import jwt +from cachetools import TTLCache + +from app.config import get_settings +from app.auth import decode_jwt + +# Cache account status (suspended/locked/deactivated) for 30 s per user. +_account_status_cache: TTLCache = TTLCache(maxsize=500, ttl=30) + +_last_seen_flush: dict[int, float] = {} +_SEEN_DEBOUNCE = 60 # seconds + + +def invalidate_account_cache(user_id: int) -> None: + _account_status_cache.pop(user_id, None) + + +@lru_cache +def get_ai_client() -> AsyncOpenAI: + settings = get_settings() + router_root = settings.anthropic_base_url.rstrip("/") + return AsyncOpenAI( + api_key=settings.anthropic_auth_token, + base_url=f"{router_root}/v2", + ) + + +# Backward-compat alias +get_anthropic_client = get_ai_client + + +class CurrentUser(BaseModel): + user_id: int + email: str + + +_bearer = HTTPBearer(auto_error=False) + + +async def get_current_user( + request: Request, + credentials: HTTPAuthorizationCredentials | None = Depends(_bearer), +) -> CurrentUser: + if not credentials: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Not authenticated") + try: + payload = decode_jwt(credentials.credentials) + except jwt.ExpiredSignatureError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expired") + except jwt.InvalidTokenError: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid token") + + user = CurrentUser(user_id=int(payload["sub"]), email=payload.get("email", "")) + + pool = getattr(request.app.state, "pool", None) + if not pool: + raise HTTPException(status_code=503, detail="Service unavailable") + ip = request.client.host if request.client else None + + cached = _account_status_cache.get(user.user_id) + if cached is None: + row = await pool.fetchrow( + "SELECT is_suspended, suspension_reason, is_locked, lock_reason, is_deactivated FROM users WHERE id = ?", + user.user_id, + ) + cached = { + "suspended": bool(row and row["is_suspended"]), + "suspension_reason": (row["suspension_reason"] or "") if row else "", + "locked": bool(row and row["is_locked"]), + "lock_reason": (row["lock_reason"] or "") if row else "", + "deactivated": bool(row and row["is_deactivated"]), + } + _account_status_cache[user.user_id] = cached + + if cached["locked"]: + raise HTTPException( + status_code=403, + detail={"code": "account_locked", "reason": cached["lock_reason"]}, + ) + if cached["suspended"]: + raise HTTPException( + status_code=403, + detail={"code": "account_suspended", "reason": cached["suspension_reason"]}, + ) + if cached["deactivated"]: + raise HTTPException( + status_code=403, + detail={"code": "account_deactivated"}, + ) + if ip: + now_mono = time.monotonic() + needs_seen = (now_mono - _last_seen_flush.get(user.user_id, 0)) >= _SEEN_DEBOUNCE + if needs_seen: + _last_seen_flush[user.user_id] = now_mono + await pool.execute( + "UPDATE users SET last_ip = ?, last_seen_at = datetime('now') WHERE id = ?", + ip, user.user_id, + ) + else: + await pool.execute("UPDATE users SET last_ip = ? WHERE id = ?", ip, user.user_id) + + return user diff --git a/backend/app/main.py b/backend/app/main.py new file mode 100644 index 0000000000000000000000000000000000000000..bac23f3b4fc3254a840eaa4b266318f82f3435ac --- /dev/null +++ b/backend/app/main.py @@ -0,0 +1,5287 @@ +import asyncio +import hashlib +import httpx +import io +import json +import logging +import re +from contextlib import asynccontextmanager +from datetime import datetime, timedelta +from pathlib import Path +from fastapi import BackgroundTasks, FastAPI, Depends, HTTPException, Request, UploadFile, File +from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel, Field +from openai import AsyncOpenAI, APIStatusError, APIConnectionError, RateLimitError +from app.config import get_settings +from app.dependencies import get_ai_client, get_current_user, CurrentUser +from app.middleware import RateLimitMiddleware +from app.math_wiki.admin_router import router as admin_router +from app.auth import verify_google_token, create_jwt +from app.admin_auth import validate_admin_key, get_window_label, derive_key, get_expiry_date + +logger = logging.getLogger(__name__) + +# --------------------------------------------------------------------------- +# Daily-challenge helpers +# --------------------------------------------------------------------------- +_ANSWER_KEY_PATH = Path(__file__).parent / "data" / "question_answers.json" +_answer_key: dict | None = None # {question_id: correct_index} + +def _load_answer_key() -> dict: + global _answer_key + if _answer_key is None: + try: + with open(_ANSWER_KEY_PATH) as f: + _answer_key = json.load(f) + except Exception: + _answer_key = {} + return _answer_key + +def _select_daily_questions(all_ids: list[str], date_str: str, n: int = 5) -> list[str]: + """Deterministically select n question IDs seeded by date string.""" + if len(all_ids) <= n: + return all_ids[:n] + result: list[str] = [] + seen: set[int] = set() + counter = 0 + while len(result) < n: + digest = hashlib.md5(f"{date_str}:{counter}".encode()).digest() + idx = int.from_bytes(digest[:4], "big") % len(all_ids) + if idx not in seen: + seen.add(idx) + result.append(all_ids[idx]) + counter += 1 + return result + + +_SCHEMA_DDL = [ + """CREATE TABLE IF NOT EXISTS wiki_units ( + id TEXT PRIMARY KEY, type TEXT NOT NULL, topic TEXT NOT NULL, + subtopic TEXT NOT NULL, content TEXT NOT NULL, + problem_ids TEXT NOT NULL DEFAULT '[]', + source TEXT NOT NULL DEFAULT 'manual', source_url TEXT, + deleted INTEGER NOT NULL DEFAULT 0, + version INTEGER NOT NULL DEFAULT 1, last_edited_by TEXT, + embedding TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )""", + """CREATE TABLE IF NOT EXISTS problems ( + problem_id TEXT PRIMARY KEY, problem_text TEXT NOT NULL, + choices TEXT, correct_answer TEXT, + topic TEXT NOT NULL, subtopic TEXT NOT NULL, + difficulty TEXT NOT NULL, problem_type TEXT NOT NULL, + figure_svg TEXT, problem_hash TEXT, + figure_type TEXT NOT NULL DEFAULT 'svg' + )""", + """CREATE TABLE IF NOT EXISTS wiki_unit_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, unit_id TEXT NOT NULL, + version INTEGER NOT NULL, content TEXT NOT NULL, + edited_by TEXT, reason TEXT, + edited_at TEXT NOT NULL DEFAULT (datetime('now')) + )""", + """CREATE TABLE IF NOT EXISTS unit_feedback ( + id INTEGER PRIMARY KEY AUTOINCREMENT, unit_id TEXT NOT NULL, + problem_text TEXT, feedback_type TEXT NOT NULL DEFAULT 'general', + comment TEXT, resolved INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )""", + """CREATE TABLE IF NOT EXISTS flagged_solutions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, problem_text TEXT NOT NULL, + problem_hash TEXT NOT NULL, solver_output TEXT NOT NULL, + flag_reason TEXT, reviewed INTEGER NOT NULL DEFAULT 0, + flagged_at TEXT NOT NULL DEFAULT (datetime('now')) + )""", + """CREATE TABLE IF NOT EXISTS wiki_drafts ( + draft_id TEXT PRIMARY KEY, source_url TEXT, + source_text TEXT NOT NULL, + proposed_units_json TEXT NOT NULL DEFAULT '[]', + final_units_json TEXT, topic_hint TEXT, + status TEXT NOT NULL DEFAULT 'pending', + reviewed_by TEXT, reviewed_at TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )""", + """CREATE TABLE IF NOT EXISTS solution_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, problem_text TEXT NOT NULL, + problem_hash TEXT NOT NULL, classified_topic TEXT NOT NULL, + retrieved_ids TEXT NOT NULL DEFAULT '[]', + used_knowledge_ids TEXT NOT NULL DEFAULT '[]', + solver_confidence TEXT NOT NULL DEFAULT 'medium', + validation_valid INTEGER NOT NULL DEFAULT 0, + validation_issues TEXT NOT NULL DEFAULT '[]', + wiki_assisted INTEGER NOT NULL DEFAULT 1, + created_at TEXT NOT NULL DEFAULT (datetime('now')) + )""", + """CREATE TABLE IF NOT EXISTS staged_wiki_units ( + staged_id TEXT PRIMARY KEY, unit_data TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'manual', source_url TEXT, + status TEXT NOT NULL DEFAULT 'pending', + proposed_by TEXT NOT NULL DEFAULT 'system', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) + )""", + # Live migrations for existing DBs (ALTER TABLE is idempotent via try/except in _apply_schema) + "ALTER TABLE users ADD COLUMN grade TEXT CHECK(grade IN ('9','10','11','12'))", + "ALTER TABLE users ADD COLUMN school_type TEXT CHECK(school_type IN ('chuyên','công lập','quốc tế'))", + "ALTER TABLE users ADD COLUMN province TEXT", + "ALTER TABLE users ADD COLUMN subscription_tier TEXT NOT NULL DEFAULT 'basic'", + "ALTER TABLE users ADD COLUMN subscription_period TEXT NOT NULL DEFAULT 'monthly'", + "ALTER TABLE users ADD COLUMN subscription_expires_at TEXT", + "ALTER TABLE users ADD COLUMN credits_balance INTEGER NOT NULL DEFAULT 50", + "ALTER TABLE users ADD COLUMN credits_reset_at TEXT", + "ALTER TABLE users ADD COLUMN is_suspended INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE users ADD COLUMN suspension_reason TEXT", + "ALTER TABLE users ADD COLUMN tos_accepted_at TEXT", + "ALTER TABLE users ADD COLUMN last_ip TEXT", + "ALTER TABLE users ADD COLUMN custom_display_name TEXT", + "CREATE UNIQUE INDEX IF NOT EXISTS idx_users_custom_display_name ON users(custom_display_name) WHERE custom_display_name IS NOT NULL", + "ALTER TABLE users ADD COLUMN last_seen_at TEXT DEFAULT NULL", + "ALTER TABLE users ADD COLUMN pending_deletion_at TEXT DEFAULT NULL", + "CREATE INDEX IF NOT EXISTS idx_users_last_seen ON users(last_seen_at)", + """CREATE TABLE IF NOT EXISTS ai_credits_log ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + delta INTEGER NOT NULL, + reason TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + )""", + """CREATE TABLE IF NOT EXISTS security_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + ip TEXT, + event_type TEXT NOT NULL, + confidence TEXT DEFAULT 'medium', + detail TEXT, + created_at TEXT DEFAULT (datetime('now')) + )""", + "CREATE INDEX IF NOT EXISTS ai_credits_log_user_idx ON ai_credits_log (user_id, created_at)", + "CREATE INDEX IF NOT EXISTS security_events_user_idx ON security_events (user_id, created_at)", + "CREATE INDEX IF NOT EXISTS security_events_type_idx ON security_events (event_type, created_at)", + """CREATE TABLE IF NOT EXISTS question_reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + question_id TEXT NOT NULL, + user_id INTEGER, + reason TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')) + )""", + "ALTER TABLE users ADD COLUMN trial_used INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE users ADD COLUMN trial_expires_at TEXT", + "ALTER TABLE users ADD COLUMN is_deactivated INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE users ADD COLUMN deactivated_at TEXT", + "ALTER TABLE users ADD COLUMN is_locked INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE users ADD COLUMN lock_reason TEXT", + """CREATE TABLE IF NOT EXISTS deleted_google_subs ( + google_sub TEXT PRIMARY KEY, + trial_used INTEGER NOT NULL DEFAULT 0, + deleted_at TEXT DEFAULT (datetime('now')) + )""", + "CREATE INDEX IF NOT EXISTS wiki_units_topic_idx ON wiki_units (topic)", + "CREATE INDEX IF NOT EXISTS wiki_units_deleted_idx ON wiki_units (deleted)", + "CREATE INDEX IF NOT EXISTS problems_hash_idx ON problems (problem_hash)", + "CREATE INDEX IF NOT EXISTS solution_logs_created_idx ON solution_logs (created_at)", + "CREATE INDEX IF NOT EXISTS staged_wiki_units_status_idx ON staged_wiki_units (status)", + # Phase 1 — Bloom's taxonomy level on wiki units + "ALTER TABLE wiki_units ADD COLUMN bloom_level INTEGER NOT NULL DEFAULT 0", + # Phase 2 — Calibration: track whether solution was actually correct + "ALTER TABLE solution_logs ADD COLUMN actual_correct INTEGER DEFAULT NULL", + # Phase 2b — Link solution log to a known problem ID (for AutoIRT calibration) + "ALTER TABLE solution_logs ADD COLUMN problem_id TEXT DEFAULT NULL", + # Phase 3 — Richer concept graph: explicit typed edges + """CREATE TABLE IF NOT EXISTS concept_edges ( + from_id TEXT NOT NULL, + to_id TEXT NOT NULL, + edge_type TEXT NOT NULL DEFAULT 'prerequisite', + PRIMARY KEY (from_id, to_id, edge_type) + )""", + "CREATE INDEX IF NOT EXISTS concept_edges_from_idx ON concept_edges (from_id)", + "CREATE INDEX IF NOT EXISTS concept_edges_to_idx ON concept_edges (to_id)", + """CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + google_sub TEXT UNIQUE NOT NULL, + email TEXT NOT NULL, + display_name TEXT, + avatar_url TEXT, + grade TEXT CHECK(grade IN ('9','10','11','12')), + school_type TEXT CHECK(school_type IN ('chuyên','công lập','quốc tế')), + province TEXT, + subscription_tier TEXT NOT NULL DEFAULT 'basic', + subscription_period TEXT NOT NULL DEFAULT 'monthly', + subscription_expires_at TEXT, + credits_balance INTEGER NOT NULL DEFAULT 50 CHECK(credits_balance >= 0), + credits_reset_at TEXT, + is_suspended INTEGER NOT NULL DEFAULT 0, + suspension_reason TEXT, + tos_accepted_at TEXT, + last_ip TEXT, + streak_freeze_count INTEGER NOT NULL DEFAULT 0, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + )""", + """CREATE TABLE IF NOT EXISTS exam_results ( + result_id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + exam_id TEXT, + score REAL, + payload TEXT, + created_at TEXT DEFAULT (datetime('now')) + )""", + "ALTER TABLE users ADD COLUMN referral_code TEXT", + "CREATE UNIQUE INDEX IF NOT EXISTS users_referral_code_idx ON users (referral_code)", + """CREATE TABLE IF NOT EXISTS classes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + teacher_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + code TEXT UNIQUE NOT NULL, + name TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + max_students INTEGER NOT NULL DEFAULT 60, + active INTEGER NOT NULL DEFAULT 1 + )""", + """CREATE TABLE IF NOT EXISTS class_members ( + class_id INTEGER NOT NULL REFERENCES classes(id) ON DELETE CASCADE, + student_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + joined_at TEXT DEFAULT (datetime('now')), + PRIMARY KEY (class_id, student_id) + )""", + """CREATE TABLE IF NOT EXISTS referral_grants ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + referrer_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + referred_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + granted_at TEXT DEFAULT (datetime('now')), + UNIQUE (referrer_id, referred_user_id) + )""", + """CREATE TABLE IF NOT EXISTS exam_leaderboard ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + exam_id TEXT NOT NULL, + score REAL NOT NULL, + submitted_at TEXT DEFAULT (datetime('now')) + )""", + """CREATE INDEX IF NOT EXISTS idx_leaderboard_exam ON exam_leaderboard (exam_id)""", + """CREATE TABLE IF NOT EXISTS daily_challenge_leaderboard ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id TEXT NOT NULL, + display_name TEXT NOT NULL DEFAULT '', + date TEXT NOT NULL, + score INTEGER NOT NULL, + total INTEGER NOT NULL DEFAULT 5, + time_seconds INTEGER NOT NULL DEFAULT 0, + submitted_at TEXT DEFAULT (datetime('now')), + UNIQUE(user_id, date) + )""", + """CREATE INDEX IF NOT EXISTS idx_daily_lb_date ON daily_challenge_leaderboard (date, score DESC, time_seconds ASC)""", + "ALTER TABLE users ADD COLUMN strategy_used_at TEXT DEFAULT NULL", + # Part 9 — questions/exams from DB + """CREATE TABLE IF NOT EXISTS exams ( + id TEXT PRIMARY KEY, + year INTEGER, + title TEXT NOT NULL, + duration INTEGER, + source TEXT, + category TEXT NOT NULL, + mode TEXT, + total_questions INTEGER, + created_at TEXT DEFAULT (datetime('now')) + )""", + """CREATE TABLE IF NOT EXISTS questions ( + id TEXT PRIMARY KEY, + source TEXT, + year INTEGER, + topic TEXT, + difficulty TEXT, + question TEXT NOT NULL, + choices TEXT NOT NULL, + correct INTEGER NOT NULL, + explanation TEXT, + created_at TEXT DEFAULT (datetime('now')) + )""", + """CREATE TABLE IF NOT EXISTS exam_questions ( + exam_id TEXT NOT NULL REFERENCES exams(id) ON DELETE CASCADE, + question_id TEXT NOT NULL REFERENCES questions(id), + position INTEGER NOT NULL, + PRIMARY KEY (exam_id, question_id) + )""", + "CREATE INDEX IF NOT EXISTS idx_eq_exam ON exam_questions(exam_id)", + "CREATE INDEX IF NOT EXISTS idx_q_topic ON questions(topic)", + # Sprint 1 — Learning Graph Foundation + """CREATE TABLE IF NOT EXISTS concepts ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + name_vi TEXT NOT NULL, + grade INTEGER NOT NULL CHECK(grade IN (9, 10, 11, 12)), + topic TEXT NOT NULL, + prerequisite_ids TEXT NOT NULL DEFAULT '[]', + exam_weight REAL NOT NULL DEFAULT 1.0, + created_at TEXT DEFAULT (datetime('now')) + )""", + """CREATE TABLE IF NOT EXISTS concept_mastery ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + concept_id TEXT NOT NULL REFERENCES concepts(id), + mastery_score REAL NOT NULL DEFAULT 0.0, + stage INTEGER NOT NULL DEFAULT 0 CHECK(stage BETWEEN 0 AND 5), + velocity REAL NOT NULL DEFAULT 0.0, + last_practiced TEXT, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + UNIQUE(user_id, concept_id) + )""", + """CREATE TABLE IF NOT EXISTS review_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + question_id TEXT NOT NULL, + concept_id TEXT REFERENCES concepts(id), + stability REAL NOT NULL DEFAULT 1.0, + difficulty REAL NOT NULL DEFAULT 5.0, + elapsed INTEGER NOT NULL DEFAULT 1, + interval INTEGER NOT NULL DEFAULT 1, + next_review_date TEXT NOT NULL, + quality_last INTEGER, + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')), + UNIQUE(user_id, question_id) + )""", + "CREATE INDEX IF NOT EXISTS idx_review_items_due ON review_items(user_id, next_review_date)", + "CREATE INDEX IF NOT EXISTS idx_concept_mastery_user ON concept_mastery(user_id)", + # concept_id on questions — nullable; backfilled gradually as concepts are tagged + "ALTER TABLE questions ADD COLUMN concept_id TEXT REFERENCES concepts(id)", + # Sprint 2 — Daily Engine + """CREATE TABLE IF NOT EXISTS concept_elo ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + concept_id TEXT NOT NULL REFERENCES concepts(id), + rating REAL NOT NULL DEFAULT 1000.0, + updated_at TEXT DEFAULT (datetime('now')), + UNIQUE(user_id, concept_id) + )""", + """CREATE TABLE IF NOT EXISTS learning_sessions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + completed_at TEXT NOT NULL DEFAULT (datetime('now')), + sm2_reviewed INTEGER NOT NULL DEFAULT 0, + advance_concept_id TEXT, + session_date TEXT NOT NULL + )""", + "CREATE INDEX IF NOT EXISTS idx_concept_elo_user ON concept_elo(user_id)", + "CREATE INDEX IF NOT EXISTS idx_learning_sessions_user ON learning_sessions(user_id, session_date)", + "ALTER TABLE concept_mastery ADD COLUMN review_count INTEGER NOT NULL DEFAULT 0", + # Sprint 3 — Oracle Memory Layer + """CREATE TABLE IF NOT EXISTS concept_memory ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + concept_id TEXT NOT NULL REFERENCES concepts(id), + preferred_style TEXT NOT NULL DEFAULT 'formula' CHECK(preferred_style IN ('visual','formula','example','analogy')), + updated_at TEXT DEFAULT (datetime('now')), + UNIQUE(user_id, concept_id) + )""", + """CREATE TABLE IF NOT EXISTS error_patterns ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + concept_id TEXT REFERENCES concepts(id), + error_type TEXT NOT NULL, + count INTEGER NOT NULL DEFAULT 1, + last_seen TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE(user_id, concept_id, error_type) + )""", + "CREATE INDEX IF NOT EXISTS idx_error_patterns_user ON error_patterns(user_id, concept_id)", + # Sprint 4 — Extended Onboarding fields (additive, all nullable) + "ALTER TABLE users ADD COLUMN target_school TEXT", + "ALTER TABLE users ADD COLUMN exam_date TEXT", + "ALTER TABLE users ADD COLUMN weekly_study_hours INTEGER", + "ALTER TABLE users ADD COLUMN extended_onboarding_done INTEGER NOT NULL DEFAULT 0", + "ALTER TABLE users ADD COLUMN streak_freeze_count INTEGER NOT NULL DEFAULT 0", + # Sprint 19 — MOAT 3: Teacher/Class Integration Foundation + """CREATE TABLE IF NOT EXISTS teacher_classes ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + class_code TEXT UNIQUE NOT NULL, + teacher_name TEXT NOT NULL, + subject TEXT NOT NULL DEFAULT 'Toán', + created_at TEXT DEFAULT (datetime('now')) + )""", + """CREATE TABLE IF NOT EXISTS teacher_class_members ( + class_id INTEGER REFERENCES teacher_classes(id), + user_id INTEGER REFERENCES users(id), + joined_at TEXT DEFAULT (datetime('now')), + PRIMARY KEY (class_id, user_id) + )""", + # Sprint 21 — MOAT 5: Study Partner Matching + """CREATE TABLE IF NOT EXISTS study_partner_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + requester_id INTEGER REFERENCES users(id), + partner_id INTEGER REFERENCES users(id), + status TEXT DEFAULT 'pending', + created_at TEXT DEFAULT (datetime('now')), + UNIQUE(requester_id, partner_id) + )""", + "CREATE INDEX IF NOT EXISTS idx_spr_requester ON study_partner_requests(requester_id, status)", + "CREATE INDEX IF NOT EXISTS idx_spr_partner ON study_partner_requests(partner_id, status)", + # Streak mechanics — weekly freeze replenishment tracking + "ALTER TABLE users ADD COLUMN streak_freeze_reset_at TEXT DEFAULT NULL", + # Grade transition gating — approval workflow + "ALTER TABLE users ADD COLUMN last_grade_approved_at TEXT DEFAULT NULL", + """CREATE TABLE IF NOT EXISTS grade_change_requests ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + current_grade TEXT NOT NULL, + requested_grade TEXT NOT NULL, + justification TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'pending' + CHECK(status IN ('pending','approved','rejected','expired')), + created_at TEXT DEFAULT (datetime('now')), + expires_at TEXT DEFAULT (datetime('now', '+30 days')), + resolved_at TEXT, + resolved_by TEXT, + admin_note TEXT, + credits_deducted INTEGER DEFAULT 5, + credits_refunded INTEGER DEFAULT 0 + )""", + "CREATE INDEX IF NOT EXISTS idx_gcr_user_status ON grade_change_requests (user_id, status)", + """CREATE TABLE IF NOT EXISTS user_devices ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + device_id TEXT NOT NULL, + device_label TEXT, + ip TEXT, + city TEXT, + province TEXT, + country TEXT, + country_code TEXT, + first_seen_at TEXT DEFAULT (datetime('now')), + last_seen_at TEXT DEFAULT (datetime('now')), + UNIQUE(user_id, device_id) + )""", + "CREATE INDEX IF NOT EXISTS user_devices_user_idx ON user_devices (user_id)", + # IP-based province suggestion — populated silently on device upsert, no user permission required + "ALTER TABLE user_devices ADD COLUMN ip_province TEXT DEFAULT NULL", + # GraphRAG P1 — typed node classification on wiki_units + "ALTER TABLE wiki_units ADD COLUMN node_type TEXT", + # CAT engine — IRT parameters on problems + "ALTER TABLE problems ADD COLUMN IF NOT EXISTS irt_a REAL DEFAULT 1.0", + "ALTER TABLE problems ADD COLUMN IF NOT EXISTS irt_b REAL DEFAULT 0.0", + "ALTER TABLE problems ADD COLUMN IF NOT EXISTS irt_c REAL DEFAULT 0.25", + """CREATE TABLE IF NOT EXISTS exam_sessions ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL, + question_ids TEXT NOT NULL, + item_params TEXT NOT NULL, + answered_indices TEXT NOT NULL DEFAULT '[]', + responses TEXT NOT NULL DEFAULT '[]', + ability REAL NOT NULL DEFAULT 0.0, + ability_se REAL NOT NULL DEFAULT 1.0, + status TEXT NOT NULL DEFAULT 'active', + created_at TEXT DEFAULT (datetime('now')), + updated_at TEXT DEFAULT (datetime('now')) + )""", +] + + +async def _hf_set_space_variable(key: str, value: str) -> None: + """Update a HF Space variable via the Hub API (no-op outside HF Spaces).""" + import os + space_id = os.environ.get("SPACE_ID") + hf_token = os.environ.get("HF_TOKEN") + if not space_id or not hf_token: + return + try: + from huggingface_hub import HfApi + HfApi(token=hf_token).add_space_variable(space_id, key, value) + logger.info("HF Space variable %s set to %r", key, value) + except Exception as exc: + logger.warning("Could not update HF Space variable %s: %s", key, exc) + + +async def _auto_seed_wiki(pool, client) -> None: + """Crawl and ingest wiki content on startup. + + Normal mode (CRAWL_AUTO_SEED_ENABLED=true): only runs when wiki_units is empty. + Force-reseed mode (CRAWL_FORCE_RESEED=true): truncates wiki_units, resets the + crawl-progress cache, then runs a full crawl regardless of existing data. + Gap-fill mode (CRAWL_GAP_FILL_ENABLED=true): crawls only topics with zero units. + After a successful forced reseed the app self-disables the flag via the HF API. + """ + from app.math_wiki.storage import pg_db + settings = get_settings() + force = settings.crawl_force_reseed + gap_fill = settings.crawl_gap_fill_enabled + + try: + from crawl.runner import crawl_and_ingest + from crawl.topic_map import AOPS_QUERIES + from crawl.progress import reset as reset_crawl_progress + except ImportError as exc: + logger.warning("auto-seed: crawl module not available (%s), skipping", exc) + return + + if force: + logger.info("auto-seed: CRAWL_FORCE_RESEED=true — wiping wiki_units for fresh crawl") + try: + async with pool.acquire() as conn: + await conn.execute("DELETE FROM wiki_units") + except Exception as exc: + logger.error("auto-seed: truncate failed: %s — aborting reseed", exc) + return + reset_crawl_progress() + topics = list(AOPS_QUERIES.keys()) + elif gap_fill: + try: + topic_counts = await pg_db.count_wiki_units_by_topic(pool) + except Exception as exc: + logger.warning("auto-seed: could not count wiki_units by topic: %s", exc) + return + topics = [t for t in AOPS_QUERIES.keys() if topic_counts.get(t, 0) == 0] + if not topics: + logger.info("auto-seed: gap-fill — no zero-unit topics found, skipping") + return + logger.info("auto-seed: gap-fill — crawling %d zero-unit topics: %s", len(topics), topics) + else: + try: + count = await pg_db.count_wiki_units(pool) + except Exception as exc: + logger.warning("auto-seed: could not count wiki_units: %s", exc) + return + if count > 0: + logger.info("auto-seed: wiki already has %d units, skipping", count) + return + topics = list(AOPS_QUERIES.keys()) + + logger.info("auto-seed: starting background crawl (%s)", + "force-reseed" if force else ("gap-fill" if gap_fill else "empty wiki")) + + crawl_ok = True + for topic in topics: + try: + stats = await crawl_and_ingest( + client, topics=[topic], sources=["aops", "pauls", "generic"], pool=pool + ) + logger.info( + "auto-seed [%s]: pages=%d units=%d errors=%d", + topic, stats["pages_fetched"], stats["wiki_units_added"], stats["errors"], + ) + except Exception as exc: + logger.error("auto-seed [%s] failed: %s", topic, exc) + crawl_ok = False + await asyncio.sleep(3) + + try: + final = await pg_db.count_wiki_units(pool) + logger.info("auto-seed complete: %d wiki units in DB", final) + except Exception: + pass + + if force and crawl_ok: + await _hf_set_space_variable("CRAWL_FORCE_RESEED", "false") + + +async def _sanitize_wiki(pool) -> None: + """Fix non-canonical labels and remove content duplicates; self-disables after success.""" + from app.math_wiki.storage.sanitizer import run_all + try: + report = await run_all(pool) + logger.info( + "wiki-sanitize complete: topic_remaps=%d topic_deletes=%d " + "type_remaps=%d duplicates_removed=%d", + report["topic_remaps"], report["topic_deletes"], + report["type_remaps"], report["duplicates_removed"], + ) + await _hf_set_space_variable("WIKI_SANITIZE_ENABLED", "false") + except Exception as exc: + logger.error("wiki-sanitize failed: %s", exc) + + +_VI_RE = __import__("re").compile( + r"[àáảãạăắặẳẵằâấầẩẫậđèéẻẽẹêếềểễệìíỉĩịòóỏõọôốồổỗộơớờởỡợùúủũụưứừửữựỳýỷỹỵ" + r"ÀÁẢÃẠĂẮẶẲẴẰÂẤẦẨẪẬĐÈÉẺẼẸÊẾỀỂỄỆÌÍỈĨỊÒÓỎÕỌÔỐỒỔỖỘƠỚỜỞỠỢÙÚỦŨỤƯỨỪỬỮỰỲÝỶỸỴ]" +) + +_TRANSLATE_SYSTEM = ( + "You are a Vietnamese math translator. Translate the given English math knowledge unit " + "into Vietnamese.\n\n" + "Rules:\n" + "- Output ONLY the translated content string — no JSON, no labels, no explanation.\n" + "- Preserve ALL math expressions exactly: keep $...$ and $$...$$ delimiters, LaTeX commands, " + "and variable names unchanged.\n" + "- Write all prose, procedure names, and explanations in Vietnamese.\n" + "- Keep the same structure and level of detail as the original.\n" + "- Do NOT add any introductory phrase like \"Dưới đây là...\" — start the content directly." +) + + +_RATE_LIMIT_RETRY_DELAY_S = 2 * 3600 # 2 hours — wait for quota window to reset + + +async def _fix_english_wiki_units(pool, client) -> None: + """Translate all English wiki units to Vietnamese; self-disables after success. + + Three-phase design to protect the live app: + Phase 1 — translate concurrently (API-bound, semaphore=3, no DB contact) + Phase 2 — batch-embed translated content (BGE-M3, batches of 50, single thread) + Phase 3 — write to DB sequentially with precomputed embeddings (no re-inference) + + Rate-limit recovery: if ALL phase-1 translations fail the function sleeps + _RATE_LIMIT_RETRY_DELAY_S then retries automatically, up to 12 times. + """ + import json as _json + from app.config import get_settings as _gs + from app.math_wiki.storage import pg_db + from app.math_wiki.storage.vectors import embed_texts + from app.math_wiki.schemas import WikiUnit + from app.agent.core import call_with_retry + + settings = _gs() + MAX_RATE_LIMIT_RETRIES = 12 + + for rate_attempt in range(MAX_RATE_LIMIT_RETRIES + 1): + try: + rows = await pool.fetch( + "SELECT id, type, topic, subtopic, content, problem_ids, source, source_url " + "FROM wiki_units WHERE deleted = false" + ) + english = [r for r in rows if not _VI_RE.search(r["content"])] + logger.warning("fix-english-wiki: %d total units, %d need translation", len(rows), len(english)) + if not english: + logger.warning("fix-english-wiki: nothing to do — all units already Vietnamese") + await _hf_set_space_variable("WIKI_FIX_ENGLISH_ENABLED", "false") + return + + # ── Phase 1: Translate (API-bound, concurrent) ──────────────── + sem = asyncio.Semaphore(3) + translated: list[tuple] = [] + failed_ids: list[str] = [] + + async def _translate_one(r): + async with sem: + try: + resp = await call_with_retry( + client, + model=settings.default_model, # Sonnet — 5× less quota than Opus + max_tokens=1024, + messages=[ + {"role": "system", "content": _TRANSLATE_SYSTEM}, + {"role": "user", "content": r["content"]}, + ], + ) + text = (resp.choices[0].message.content or "").strip() + if not text: + raise ValueError("empty response") + orig_dollar = r["content"].count("$") + if orig_dollar > 0 and text.count("$") % 2 != 0: + logger.warning("fix-english-wiki: %s — odd $ count after translation, skipping", r["id"]) + failed_ids.append(r["id"]) + return + translated.append((r, text)) + except Exception as exc: + logger.warning("fix-english-wiki: translate failed %s — %s", r["id"], exc) + failed_ids.append(r["id"]) + + await asyncio.gather(*(_translate_one(r) for r in english)) + logger.warning("fix-english-wiki phase1 done: %d translated, %d failed", len(translated), len(failed_ids)) + + # All units rate-limited → quota exhausted; sleep and retry + if len(translated) == 0 and len(failed_ids) == len(english): + if rate_attempt < MAX_RATE_LIMIT_RETRIES: + wait_h = _RATE_LIMIT_RETRY_DELAY_S // 3600 + logger.warning( + "fix-english-wiki: quota exhausted (attempt %d/%d) — sleeping %dh before retry", + rate_attempt + 1, MAX_RATE_LIMIT_RETRIES, wait_h, + ) + await asyncio.sleep(_RATE_LIMIT_RETRY_DELAY_S) + continue + logger.warning("fix-english-wiki: quota exhausted after %d retries — giving up until next boot", MAX_RATE_LIMIT_RETRIES) + return + + # ── Phase 2: Batch-embed (CPU-bound, batched for BGE-M3 efficiency) + loop = asyncio.get_event_loop() + EMBED_BATCH = 50 + embeddings: list[list[float]] = [] + for i in range(0, len(translated), EMBED_BATCH): + batch_texts = [t for _, t in translated[i:i + EMBED_BATCH]] + vecs = await loop.run_in_executor(None, embed_texts, batch_texts, "passage") + embeddings.extend(vecs) + logger.warning("fix-english-wiki phase2: embedded %d/%d", + min(i + EMBED_BATCH, len(translated)), len(translated)) + await asyncio.sleep(0) + + # ── Phase 3: Write to DB (sequential, precomputed embeddings) ─── + ok = 0 + for (r, text), emb in zip(translated, embeddings): + try: + unit = WikiUnit( + id=r["id"], type=r["type"], topic=r["topic"], + subtopic=r["subtopic"] or "", + content=text, + problem_ids=[] if r["problem_ids"] is None else _json.loads(r["problem_ids"]), + ) + await pg_db.upsert_wiki_unit( + pool, unit, + source=r["source"], source_url=r["source_url"], + editor="fix_english_wiki_units", + reason="Translated English content to Vietnamese (bulk migration)", + embedding=emb, + ) + ok += 1 + if ok % 100 == 0: + logger.warning("fix-english-wiki phase3: %d/%d written", ok, len(translated)) + except Exception as exc: + logger.warning("fix-english-wiki: write failed %s — %s", r["id"], exc) + failed_ids.append(r["id"]) + + logger.warning("fix-english-wiki complete: translated=%d failed=%d", ok, len(failed_ids)) + if not failed_ids: + await _hf_set_space_variable("WIKI_FIX_ENGLISH_ENABLED", "false") + else: + logger.warning("fix-english-wiki: %d failures — flag not auto-disabled (will retry on next boot)", + len(failed_ids)) + return # success — exit retry loop + + except Exception as exc: + logger.error("fix-english-wiki failed: %s", exc) + return + + +async def _apply_schema(pool) -> None: + """Run DDL idempotently on every startup — all statements are CREATE IF NOT EXISTS.""" + async with pool.acquire() as conn: + for stmt in _SCHEMA_DDL: + try: + await conn.execute(stmt) + except Exception as exc: + logger.warning("DDL skipped (%s): %.80s", exc, stmt) + logger.info("Schema applied (%d statements)", len(_SCHEMA_DDL)) + + +async def _seed_from_json(pool) -> None: + """Auto-seed exams and questions tables from bundled JSON files (runs once, INSERT OR IGNORE).""" + import pathlib + data_dir = pathlib.Path(__file__).parent.parent.parent / "exam-app" / "src" / "data" + try: + exams_path = data_dir / "exams.json" + questions_path = data_dir / "questions.json" + if not exams_path.exists() or not questions_path.exists(): + logger.warning("_seed_from_json: JSON files not found at %s — skipping seed", data_dir) + return + exams = json.loads(exams_path.read_text()) + questions = json.loads(questions_path.read_text()) + async with pool.acquire() as conn: + for e in exams: + await conn.execute( + "INSERT OR IGNORE INTO exams (id, year, title, duration, source, category, mode, total_questions) VALUES (?,?,?,?,?,?,?,?)", + e["id"], e.get("year"), e["title"], e.get("duration"), e.get("source"), + e["category"], e.get("mode"), e.get("totalQuestions"), + ) + for i, qid in enumerate(e.get("questionIds", [])): + await conn.execute( + "INSERT OR IGNORE INTO exam_questions (exam_id, question_id, position) VALUES (?,?,?)", + e["id"], qid, i, + ) + for q in questions: + await conn.execute( + "INSERT OR IGNORE INTO questions (id, source, year, topic, difficulty, question, choices, correct, explanation, concept_id) VALUES (?,?,?,?,?,?,?,?,?,?)", + q["id"], q.get("source"), q.get("year"), q.get("topic"), q.get("difficulty"), + q["question"], json.dumps(q.get("choices", []), ensure_ascii=False), + q["correct"], q.get("explanation"), q.get("concept_id"), + ) + logger.info("_seed_from_json: seeded %d exams, %d questions", len(exams), len(questions)) + except Exception as exc: + logger.warning("_seed_from_json failed: %s", exc) + + +async def _tag_question_concepts(pool) -> None: + """Assign concept_id to questions that have none, using topic + keyword matching (idempotent).""" + # Direct topic → concept_id + DIRECT: dict[str, str] = { + "hệ phương trình": "linear_systems", + "phương trình bậc hai": "quad_eq", + "căn thức": "radicals", + "hàm số bậc nhất": "linear_func", + "parabol": "quad_func", + "sequences": "sequences", + "Sequences and Series": "sequences", + "financial_math": "financial_math", + "Financial Mathematics": "financial_math", + "trigonometry": "trig_basic", + "Trigonometry": "trig_basic", + "lượng giác": "trig_basic", + "coordinate_geometry": "coord_geo", + "vectors": "vectors", + "probability": "prob_basic", + "xác suất thống kê": "prob_basic", + "xác suất": "prob_basic", + "Probability": "prob_basic", + "statistics": "stats_basic", + "Statistics": "stats_basic", + "number_theory": "number_theory", + "combinatorics": "combinatorics", + "sets": "sets", + "arithmetic": "linear_eq", + "đại số": "linear_eq", + "hình học": "basic_geo", + "hình học không gian": "basic_geo", + "Geometry": "basic_geo", + "Measurement": "basic_geo", + } + + def _classify(topic: str, question: str) -> str | None: + if topic in DIRECT: + return DIRECT[topic] + t, q = topic.lower(), question.lower() + if "algebra" in t or "đại số" in t: + if any(k in q for k in ["bậc hai", "delta", "quadratic", "phương trình bậc 2"]): + return "quad_eq" + if any(k in q for k in ["bất phương trình", "inequality"]): + return "inequalities" + if any(k in q for k in ["căn", "√", "radical"]): + return "radicals" + if any(k in q for k in ["hệ phương trình", "system of"]): + return "linear_systems" + return "linear_eq" + if "geometry" in t or "hình học" in t: + if any(k in q for k in ["tọa độ", "trục ox", "coordinate"]): + return "coord_geo" + if any(k in q for k in ["vectơ", "vector", "\\vec"]): + return "vectors" + if any(k in q for k in ["sin", "cos", "tan", "lượng giác"]): + return "trig_basic" + if any(k in q for k in ["đường tròn", "bán kính", "circle", "tiếp tuyến"]): + return "circles" + if any(k in q for k in ["tam giác", "triangle", "trung tuyến"]): + return "triangles" + return "basic_geo" + if "function" in t or "hàm số" in t: + if any(k in q for k in ["bậc hai", "parabol", "quadratic", "ax²", "ax^2"]): + return "quad_func" + return "linear_func" + if "statistic" in t or "thống kê" in t: + return "stats_basic" + if "probab" in t or "xác suất" in t: + return "prob_basic" + if "combinat" in t or "tổ hợp" in t: + return "combinatorics" + if "number" in t or "lý thuyết số" in t: + return "number_theory" + if "sequence" in t or "dãy số" in t: + return "sequences" + if "trig" in t or "lượng giác" in t: + return "trig_basic" + if "vector" in t: + return "vectors" + if "set" in t or "tập hợp" in t: + return "sets" + if "financial" in t or "tài chính" in t: + return "financial_math" + # Vietnamese long-tail topics + if any(k in topic for k in ["tam giác", "Diện tích tam giác", "Góc trong tam giác", "phân giác"]): + return "triangles" + if any(k in topic for k in ["đường tròn", "nội tiếp đường tròn", "tứ giác nội tiếp"]): + return "circles" + if any(k in topic for k in ["Hình vuông", "Hình chữ nhật", "Chu vi", "Diện tích"]): + return "basic_geo" + if any(k in topic for k in ["tổ hợp", "Tổ hợp", "hoán vị", "Tổ hợp —"]): + return "combinatorics" + if any(k in topic for k in ["xác suất", "Xác suất"]): + return "prob_basic" + if any(k in topic for k in ["lũy thừa", "ước số", "nguyên tố", "số chính phương", "Lý thuyết số"]): + return "number_theory" + if any(k in topic for k in ["Dãy số"]): + return "sequences" + if any(k in topic for k in ["đa thức", "hệ phương trình", "Đại số —", "tốc độ", "tỉ lệ"]): + return "linear_eq" + return None + + try: + async with pool.acquire() as conn: + rows = await conn.fetch("SELECT id, topic, question FROM questions WHERE concept_id IS NULL") + tagged = 0 + for row in rows: + cid = _classify(row["topic"] or "", row["question"] or "") + if cid: + await conn.execute( + "UPDATE questions SET concept_id=? WHERE id=? AND concept_id IS NULL", + cid, row["id"], + ) + tagged += 1 + logger.info("_tag_question_concepts: tagged %d questions with concept_id", tagged) + except Exception as exc: + logger.warning("_tag_question_concepts failed: %s", exc) + + +async def _seed_teacher_classes(pool) -> None: + """Seed one demo teacher class for Sprint 19 testing (INSERT OR IGNORE).""" + try: + async with pool.acquire() as conn: + await conn.execute( + """INSERT OR IGNORE INTO teacher_classes (class_code, teacher_name, subject) + VALUES (?, ?, ?)""", + "ZENITH", "Giáo viên Demo", "Toán", + ) + logger.info("_seed_teacher_classes: demo class seeded") + except Exception as exc: + logger.warning("_seed_teacher_classes failed: %s", exc) + + +async def _seed_concepts(pool) -> None: + """Seed concepts + concept_edges from the CONCEPTS taxonomy (INSERT OR IGNORE — idempotent).""" + import json as _json + from app.data.concepts import CONCEPTS + try: + async with pool.acquire() as conn: + for c in CONCEPTS: + await conn.execute( + """INSERT OR IGNORE INTO concepts + (id, name, name_vi, grade, topic, prerequisite_ids, exam_weight) + VALUES (?, ?, ?, ?, ?, ?, ?)""", + c["id"], c["name"], c["name_vi"], c["grade"], c["topic"], + _json.dumps(c.get("prerequisite_ids", [])), c.get("exam_weight", 1.0), + ) + # Seed concept_edges from prerequisite_ids (prerequisite edge type) + edge_count = 0 + for c in CONCEPTS: + for pre_id in c.get("prerequisite_ids", []): + await conn.execute( + """INSERT OR IGNORE INTO concept_edges (from_id, to_id, edge_type) + VALUES (?, ?, 'prerequisite')""", + pre_id, c["id"], + ) + edge_count += 1 + logger.info("_seed_concepts: seeded %d concepts, %d edges", len(CONCEPTS), edge_count) + except Exception as exc: + logger.warning("_seed_concepts failed: %s", exc) + + +@asynccontextmanager +async def lifespan(app: FastAPI): + from app.db import AsyncSQLitePool + from app.math_wiki.pipeline import _wiki_status, _ensure_bm25 + + settings = get_settings() + pool = AsyncSQLitePool(settings.sqlite_path) + await pool.initialize() + app.state.pool = pool + await _apply_schema(app.state.pool) + logger.info("SQLite pool ready at %s", settings.sqlite_path) + exam_count = (await app.state.pool.fetchrow("SELECT COUNT(*) AS cnt FROM exams")) + if exam_count and exam_count["cnt"] == 0: + await _seed_from_json(app.state.pool) + await _seed_concepts(app.state.pool) + await _tag_question_concepts(app.state.pool) + await _seed_teacher_classes(app.state.pool) + + _wiki_status.update({"phase": "starting", "progress": 0, "error": None}) + asyncio.ensure_future(_ensure_bm25(app.state.pool)) + + # Migrate embeddings to sqlite-vec HNSW index (one-time, idempotent) + try: + from app.math_wiki.vector_store import migrate_embeddings_to_vec + await migrate_embeddings_to_vec(app.state.pool) + except Exception as exc: + logger.warning("sqlite-vec migration failed (non-fatal): %s", exc) + from app.abuse_detector import _run_abuse_detector + asyncio.ensure_future(_run_abuse_detector(app.state.pool)) + if app.state.pool and (settings.crawl_auto_seed_enabled or settings.crawl_force_reseed or settings.crawl_gap_fill_enabled): + asyncio.ensure_future(_auto_seed_wiki(app.state.pool, get_ai_client())) + elif app.state.pool: + logger.info("auto-seed disabled (set CRAWL_AUTO_SEED_ENABLED, CRAWL_FORCE_RESEED, or CRAWL_GAP_FILL_ENABLED to enable)") + if app.state.pool and settings.wiki_sanitize_enabled: + asyncio.ensure_future(_sanitize_wiki(app.state.pool)) + print(f"[startup] wiki_fix_english_enabled={settings.wiki_fix_english_enabled}", flush=True) + logger.warning("startup: wiki_fix_english_enabled=%s", settings.wiki_fix_english_enabled) + if app.state.pool and settings.wiki_fix_english_enabled: + logger.warning("startup: launching fix-english-wiki background task") + asyncio.ensure_future(_fix_english_wiki_units(app.state.pool, get_ai_client())) + yield + + if app.state.pool: + await app.state.pool.close() + + +def get_pool(request: Request): + return getattr(request.app.state, "pool", None) + + +app = FastAPI(title="AI Agent App", lifespan=lifespan) +app.include_router(admin_router) + +settings = get_settings() +app.add_middleware(RateLimitMiddleware) +app.add_middleware( + CORSMiddleware, + allow_origins=settings.allowed_origins_list, + allow_methods=["*"], + allow_headers=["*"], +) + +# ── Models ─────────────────────────────────────────────────────────────────── + +# ── Review / Learning Graph models ─────────────────────────────────────────── + +class ReviewItemIn(BaseModel): + question_id: str + stability: float = 1.0 + difficulty: float = 5.0 + elapsed: int = 1 + interval: int = 1 + next_review_date: str # YYYY-MM-DD + +class ReviewItemsBulkRequest(BaseModel): + items: list[ReviewItemIn] + +class ReviewAnswerRequest(BaseModel): + quality: int # 1 (forgot) | 3 (good) | 5 (easy) + response_time_seconds: int | None = None # collected for future velocity signal + +# ── Exam AI models ─────────────────────────────────────────────────────────── + +class ExamAnalyzeRequest(BaseModel): + result: dict + history: list[dict] = [] + student_name: str = "" + wrong_questions: list[dict] = [] + school_recommendations: list[dict] = [] + exam_category: str = "" + user_profile: dict = {} + learner_archetype: str | None = None + + +class ExamAnalyzeResponse(BaseModel): + insights: str + weak_topics: list[str] + recommendations: list[str] + question_analysis: str = "" + school_insight: str = "" + + +class HintRequest(BaseModel): + question: dict + attempt_count: int = 1 + previous_hints: list[str] = [] + hint_style: str = "socratic" + ai_preferences: dict = {} + encouragement_level: str = "moderate" # 'minimal' | 'moderate' | 'high' + + +class HintResponse(BaseModel): + hint: str + difficulty_note: str = "" + + +class ExplainRequest(BaseModel): + question: dict + chosen_index: int + explanation_depth: str = "detailed" + ai_preferences: dict = {} + encouragement_level: str = "moderate" # 'minimal' | 'moderate' | 'high' + + +class ExplainResponse(BaseModel): + correct_index: int + explanation: str + + +class StudyPlanRequest(BaseModel): + result: dict + history: list[dict] = [] + wrong_questions: list[dict] = [] + topic_miss_counts: dict = {} + student_name: str = "" + learner_archetype: str | None = None + province: str = "" + ai_preferences: dict = {} + + +class StudyPlanResponse(BaseModel): + score_gap: str = "" + focus_areas: list[dict] = [] + retake_note: str = "" + + +class MathIngestRequest(BaseModel): + text: str + + +class MathSolveRequest(BaseModel): + question: str + image_base64: str | None = None + image_mime: str | None = None + + +class MathSolveResponse(BaseModel): + label: str | None = None + answer: dict | None = None + validation: dict | None = None + retrieved_ids: list[str] = [] + error: str | None = None + wiki_assisted: bool = True + tutor_recommendations: list[dict] = [] + + +class MathOcrResponse(BaseModel): + text: str + + +class MathReviewRequest(BaseModel): + problem: str + solution: str + + +class MathReviewResponse(BaseModel): + verdict: str + score: str + correct_steps: list[str] + errors: list[str] + feedback: str + correct_approach: str = "" + retrieved_ids: list[str] = [] + + + +class ChartInsightsRequest(BaseModel): + spark_data: list[dict] = [] + radar_data: list[dict] = [] + heatmap_summary: dict = {} + + +class ChartInsightsResponse(BaseModel): + spark_insight: str + radar_insight: str + heatmap_insight: str + + +# ── Existing routes ────────────────────────────────────────────────────────── + +@app.api_route("/health", methods=["GET", "HEAD"]) +async def health(): + return {"status": "ok"} + + +@app.get("/wiki/status") +async def wiki_status(): + from app.math_wiki.pipeline import get_wiki_status + return get_wiki_status() + + +# ── Exam AI routes ─────────────────────────────────────────────────────────── + +@app.post("/analyze", response_model=ExamAnalyzeResponse) +async def analyze( + req: ExamAnalyzeRequest, + client: AsyncOpenAI = Depends(get_ai_client), + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + tier_row = await pool.fetchrow("SELECT subscription_tier FROM users WHERE id = ?", current_user.user_id) + if not tier_row or tier_row["subscription_tier"] not in _PAID_TIERS: + await _spend_credits(pool, current_user.user_id, 3, "analyze") + dev_row = await pool.fetchrow( + "SELECT province FROM user_devices WHERE user_id = ? AND province IS NOT NULL " + "ORDER BY last_seen_at DESC LIMIT 1", + current_user.user_id, + ) + device_province = dev_row["province"] if dev_row else None + from app.agent.exam_analyzer import analyze_exam_result + try: + data = await analyze_exam_result( + client, req.result, req.history, req.student_name, + wrong_questions=req.wrong_questions, + school_recommendations=req.school_recommendations, + exam_category=req.exam_category, + user_profile=req.user_profile, + learner_archetype=req.learner_archetype, + device_province=device_province, + ) + return ExamAnalyzeResponse( + insights=data.get("insights", ""), + weak_topics=data.get("weak_topics", []), + recommendations=data.get("recommendations", []), + question_analysis=data.get("question_analysis", ""), + school_insight=data.get("school_insight", ""), + ) + except (ValueError, KeyError): + raise HTTPException(status_code=502, detail="AI response parse error") + + +def _ndjson_find_field(buf: str, field: str, ftype: str): + """Return (content: str | None, is_complete: bool). + For string fields: content is the raw JSON-escaped string body (without surrounding quotes). + For array fields: content is the complete [...] JSON array string. + """ + import re as _re + m = _re.search(r'"' + _re.escape(field) + r'"\s*:\s*', buf) + if not m or m.end() >= len(buf): + return None, False + open_char = '"' if ftype == "string" else '[' + if buf[m.end()] != open_char: + return None, False + content_start = m.end() + 1 # skip opening char + i = content_start + esc = False + if ftype == "string": + while i < len(buf): + c = buf[i] + if esc: + esc = False + elif c == '\\': + esc = True + elif c == '"': + return buf[content_start:i], True + i += 1 + return buf[content_start:], False + else: # array — return complete [...] including brackets + array_start = m.end() + depth = 0 + in_str = False + while i > array_start: + i = array_start + while i < len(buf): + c = buf[i] + if esc: + esc = False + elif c == '\\' and in_str: + esc = True + elif c == '"': + in_str = not in_str + elif not in_str: + if c == '[': + depth += 1 + elif c == ']': + depth -= 1 + if depth == 0: + return buf[array_start:i + 1], True + i += 1 + return buf[array_start:], False + + +_NDJSON_FIELDS = [ + ("insights", "string"), + ("question_analysis", "string"), + ("weak_topics", "array"), + ("recommendations", "array"), + ("school_insight", "string"), + ("schools", "array"), +] + + +@app.post("/analyze/stream") +async def analyze_stream( + req: ExamAnalyzeRequest, + client: AsyncOpenAI = Depends(get_ai_client), + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + """Stream AI analysis as NDJSON — one JSON line per field chunk. + Each line: {"field": "insights", "chunk": "text", "done": false} + Final line per field: {"field": "insights", "chunk": "", "done": true} + Credits are deducted upfront before the stream starts. + """ + from fastapi.responses import StreamingResponse + from app.agent.exam_analyzer import build_analyze_prompt, STATIC_EXAM_ANALYSIS_INSTRUCTIONS + tier_row_s = await pool.fetchrow("SELECT subscription_tier FROM users WHERE id = ?", current_user.user_id) + if not tier_row_s or tier_row_s["subscription_tier"] not in _PAID_TIERS: + await _spend_credits(pool, current_user.user_id, 3, "analyze") + + dev_row_s = await pool.fetchrow( + "SELECT province FROM user_devices WHERE user_id = ? AND province IS NOT NULL " + "ORDER BY last_seen_at DESC LIMIT 1", + current_user.user_id, + ) + device_province_s = dev_row_s["province"] if dev_row_s else None + + prompt = build_analyze_prompt( + req.result, req.history, req.student_name, + wrong_questions=req.wrong_questions, + school_recommendations=req.school_recommendations, + exam_category=req.exam_category, + user_profile=req.user_profile, + learner_archetype=req.learner_archetype, + device_province=device_province_s, + ) + settings = get_settings() + + async def ndjson_stream(): + accumulated = '' + cursors: dict[str, int] = {} + done_fields: set[str] = set() + try: + stream = await client.chat.completions.create( + model=settings.default_model, + max_tokens=1200, + messages=[ + {"role": "system", "content": STATIC_EXAM_ANALYSIS_INSTRUCTIONS}, + {"role": "user", "content": prompt}, + ], + stream=True, + ) + async for chunk in stream: + token = (chunk.choices[0].delta.content if chunk.choices else None) or '' + if not token: + continue + accumulated += token + for fname, ftype in _NDJSON_FIELDS: + if fname in done_fields: + continue + content, is_complete = _ndjson_find_field(accumulated, fname, ftype) + if content is None: + continue + # Arrays only emit when complete to avoid partial JSON + if ftype == "array" and not is_complete: + continue + prev = cursors.get(fname, 0) + if len(content) > prev: + delta = content[prev:] + cursors[fname] = len(content) + yield json.dumps({"field": fname, "chunk": delta, "done": False}, ensure_ascii=False) + "\n" + if is_complete: + done_fields.add(fname) + yield json.dumps({"field": fname, "chunk": "", "done": True}, ensure_ascii=False) + "\n" + # Guard: if insights field never emitted, response is empty — refund credits + if "insights" not in done_fields: + await _refund_credits(pool, current_user.user_id, 3, "analyze_refund_empty") + yield json.dumps({"error": "empty_response", "done": True}) + "\n" + return + except Exception as exc: + await _refund_credits(pool, current_user.user_id, 3, "analyze_refund_error") + yield json.dumps({"error": str(exc)}) + "\n" + finally: + for fname, _ in _NDJSON_FIELDS: + if fname in cursors and fname not in done_fields: + yield json.dumps({"field": fname, "chunk": "", "done": True}) + "\n" + + return StreamingResponse(ndjson_stream(), media_type="application/x-ndjson", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}) + + +@app.post("/hint", response_model=HintResponse) +async def hint( + req: HintRequest, + client: AsyncOpenAI = Depends(get_ai_client), + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + await _spend_credits(pool, current_user.user_id, 1, "hint") + from app.agent.hint_generator import generate_hint + try: + merged_prefs = {"hint_style": req.hint_style, "encouragement_level": req.encouragement_level, **req.ai_preferences} + data = await generate_hint(client, req.question, req.attempt_count, req.previous_hints, merged_prefs) + return HintResponse( + hint=data.get("hint", ""), + difficulty_note=data.get("difficulty_note", ""), + ) + except HTTPException: + raise + except Exception as exc: + raise HTTPException(status_code=502, detail=f"Không thể tạo gợi ý: {exc}") + + +class GenerateExamRequest(BaseModel): + topic_focus: list[str] | None = None + difficulty: str = "medium" + count: int = 10 + + +@app.post("/generate-exam") +async def generate_exam( + req: GenerateExamRequest, + client: AsyncOpenAI = Depends(get_ai_client), + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + tier_row_ge = await pool.fetchrow( + "SELECT subscription_tier FROM users WHERE id = ?", current_user.user_id + ) + if not tier_row_ge or tier_row_ge["subscription_tier"] != "complete": + raise HTTPException( + status_code=403, + detail={"code": "tier_required", "required": "complete", "message": "Tạo đề AI riêng yêu cầu gói Toàn diện"}, + ) + count = max(5, min(15, req.count)) + await _spend_credits(pool, current_user.user_id, 5, "generate_exam") + settings = get_settings() + topics_hint = f"Chủ đề ưu tiên: {', '.join(req.topic_focus)}" if req.topic_focus else "Tất cả chủ đề toán lớp 10" + prompt = ( + f"Tạo {count} câu trắc nghiệm toán lớp 10 theo chuẩn đề thi tuyển sinh Việt Nam.\n" + f"{topics_hint}. Độ khó: {req.difficulty}.\n" + "Trả về JSON array, mỗi phần tử gồm: question (string), choices (array 4 string), correct (int 0-3), topic (string), explanation (string ngắn).\n" + "Chỉ trả lời JSON, không giải thích thêm." + ) + try: + from app.agent.core import call_with_retry + resp = await call_with_retry( + client.chat.completions.create, + model=settings.default_model, max_tokens=3000, + messages=[{"role": "user", "content": prompt}], + response_format={"type": "json_object"}, + ) + raw = resp.choices[0].message.content or "{}" + parsed = json.loads(raw) + questions = parsed if isinstance(parsed, list) else parsed.get("questions", []) + exam_id = f"generated-{current_user.user_id}-{int(datetime.utcnow().timestamp())}" + return {"exam_id": exam_id, "questions": questions[:count]} + except Exception as exc: + raise HTTPException(status_code=502, detail=f"Không thể tạo đề: {exc}") + + +class GenerateQuestionsRequest(BaseModel): + topic: str + count: int = 5 + difficulty: str = "medium" + + +@app.post("/admin/generate-questions") +async def admin_generate_questions( + req: GenerateQuestionsRequest, + background_tasks: BackgroundTasks, + request: Request, + client: AsyncOpenAI = Depends(get_ai_client), + pool=Depends(get_pool), +): + """Generate new questions using MATH² + AgenticMath pipeline. Admin only.""" + _require_admin(request) + from app.math_wiki.question_generator import run_generation_job + import uuid as _uuid + job_id = str(_uuid.uuid4())[:8] + + async def _run(): + try: + result = await run_generation_job(pool, client, req.topic, req.count, req.difficulty) + logger.info("generate-questions job %s done: %s", job_id, result) + except Exception as exc: + logger.error("generate-questions job %s failed: %s", job_id, exc) + + background_tasks.add_task(_run) + return {"job_id": job_id, "status": "queued", "topic": req.topic, "count": req.count} + + +@app.post("/admin/calibrate-difficulty") +async def admin_calibrate_difficulty( + batch_size: int = 50, + request: Request = None, + client: AsyncOpenAI = Depends(get_ai_client), + pool=Depends(get_pool), + background_tasks: BackgroundTasks = None, +): + """Calibrate irt_b for uncalibrated questions using LLM confidence. Admin only.""" + _require_admin(request) + from app.math_wiki.difficulty_estimator import run_calibration_job + job_id = f"calib_{int(__import__('time').time())}" + + async def _run(): + result = await run_calibration_job(pool, client, batch_size) + logger.info("calibrate-difficulty job %s: %s", job_id, result) + + background_tasks.add_task(_run) + return {"job_id": job_id, "status": "queued", "batch_size": batch_size} + + +@app.post("/admin/recalibrate-irt") +async def admin_recalibrate_irt( + request: Request = None, + pool=Depends(get_pool), + background_tasks: BackgroundTasks = None, +): + """Recalibrate IRT parameters from accumulated response data. Admin only.""" + _require_admin(request) + from app.math_wiki.autoirt import run_recalibration + job_id = f"irt_{int(__import__('time').time())}" + + async def _run(): + result = await run_recalibration(pool) + logger.info("recalibrate-irt job %s: %s", job_id, result) + + background_tasks.add_task(_run) + return {"job_id": job_id, "status": "queued"} + + +@app.get("/predict-score") +async def predict_score( + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + tier_row_ps = await pool.fetchrow( + "SELECT subscription_tier FROM users WHERE id = ?", current_user.user_id + ) + if not tier_row_ps or tier_row_ps["subscription_tier"] != "complete": + raise HTTPException( + status_code=403, + detail={"code": "tier_required", "required": "complete"}, + ) + results = await pool.fetch( + "SELECT score, exam_id FROM exam_results WHERE user_id = ? AND score IS NOT NULL ORDER BY created_at DESC LIMIT 10", + current_user.user_id, + ) + if not results: + return {"predicted": None, "confidence": "low", "sample_size": 0, "low": None, "high": None} + scores = [r["score"] for r in results if r["score"] is not None and 0 <= r["score"] <= 10] + if not scores: + return {"predicted": None, "confidence": "low", "sample_size": 0, "low": None, "high": None} + + # Derive difficulty from exam_id prefix for normalization + _DIFF_WEIGHT = {"easy": 0.85, "medium": 1.0, "hard": 1.2, "olympiad": 1.4} + _HARD_PREFIXES = ("intl_amc10", "intl_amc12", "intl_jee", "intl_ksat", "intl_ib", "intl_ukmt_smc", "intl_hsc") + _EASY_PREFIXES = ("intl_bece", "intl_amc8", "intl_cemc_gauss8") + _OLYMPIAD_PREFIXES = ("olymp",) + + def _exam_difficulty(exam_id: str) -> str: + if not exam_id: + return "medium" + if any(exam_id.startswith(p) for p in _OLYMPIAD_PREFIXES): + return "olympiad" + if any(exam_id.startswith(p) for p in _HARD_PREFIXES): + return "hard" + if any(exam_id.startswith(p) for p in _EASY_PREFIXES): + return "easy" + return "medium" + + valid = [(r["score"], r["exam_id"]) for r in results if r["score"] is not None and 0 <= r["score"] <= 10] + norm_scores = [s * _DIFF_WEIGHT[_exam_difficulty(eid)] for s, eid in valid] + scores = [s for s, _ in valid] + + import numpy as np, random as _random + n = len(norm_scores) + weights = [1.3 ** i for i in range(n)] + w_sum = sum(weights) + predicted = sum(s * w for s, w in zip(norm_scores, weights)) / w_sum + predicted = max(0.0, min(10.0, predicted)) + + # Bootstrap CI (1000 resamples, 80% interval) + bootstrap = [] + for _ in range(1000): + sample = [_random.choices(scores, weights=weights, k=1)[0] for _ in range(n)] + bootstrap.append(sum(sample) / n) + bootstrap.sort() + low = max(0.0, bootstrap[100]) + high = min(10.0, bootstrap[899]) + + width = high - low + confidence = "high" if width < 0.8 else "medium" if width < 1.5 else "low" + + # Stage 2: IRT upgrade — use CAT session ability if available + try: + from app.math_wiki.score_predictor import irt_predict + irt_result = await irt_predict(pool, current_user.user_id) + if irt_result: + return {**irt_result, "sample_size": n, "stage": 2} + except Exception: + pass + + # Stage 3: DKVMN upgrade — use concept mastery if model loaded + try: + from app.math_wiki.score_predictor import dkvmn_predict + dkvmn_result = await dkvmn_predict(pool, current_user.user_id) + if dkvmn_result: + return {**dkvmn_result, "sample_size": n, "stage": 3} + except Exception: + pass + + # Stage 1 fallback: bootstrap CI (always available) + return { + "predicted": round(predicted, 1), + "confidence": confidence, + "sample_size": n, + "low": round(low, 1), + "high": round(high, 1), + "stage": 1, + } + + +@app.post("/exam/adaptive/start") +async def adaptive_exam_start( + topic: str | None = None, + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + """Start a new CAT adaptive exam session.""" + from app.math_wiki.exam_sessions import create_session + try: + result = await create_session(pool, current_user.user_id, topic=topic) + return result + except RuntimeError as e: + raise HTTPException(status_code=503, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=422, detail=str(e)) + + +class AdaptiveAnswerRequest(BaseModel): + session_id: str + correct: bool + + +@app.post("/exam/adaptive/answer") +async def adaptive_exam_answer( + req: AdaptiveAnswerRequest, + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + """Submit an answer and get the next question (or final score if done).""" + from app.math_wiki.exam_sessions import submit_answer + try: + result = await submit_answer(pool, req.session_id, current_user.user_id, req.correct) + return result + except RuntimeError as e: + raise HTTPException(status_code=503, detail=str(e)) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +class StrategyRequest(BaseModel): + pass + + +@app.post("/strategy") +async def exam_strategy( + req: StrategyRequest, + client: AsyncOpenAI = Depends(get_ai_client), + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + tier_row_st = await pool.fetchrow( + "SELECT subscription_tier, strategy_used_at FROM users WHERE id = ?", current_user.user_id + ) + if not tier_row_st or tier_row_st["subscription_tier"] != "complete": + raise HTTPException( + status_code=403, + detail={"code": "tier_required", "required": "complete"}, + ) + if tier_row_st["strategy_used_at"]: + last = datetime.fromisoformat(tier_row_st["strategy_used_at"]) + if (datetime.utcnow() - last).days < 30: + next_available = (last + timedelta(days=30)).strftime("%Y-%m-%d") + raise HTTPException( + status_code=429, + detail={"code": "strategy_cooldown", "next_available": next_available}, + ) + await pool.execute( + "UPDATE users SET strategy_used_at = datetime('now') WHERE id = ?", current_user.user_id + ) + # Gather topic performance from recent results + results = await pool.fetch( + "SELECT score, exam_id, created_at FROM exam_results WHERE user_id = ? ORDER BY created_at DESC LIMIT 20", + current_user.user_id, + ) + scores_summary = ", ".join(str(r["score"]) for r in results if r["score"] is not None) or "chưa có" + settings = get_settings() + prompt = ( + "Bạn là chuyên gia tư vấn chiến lược ôn thi tuyển sinh lớp 10 môn Toán Việt Nam.\n" + f"Điểm số gần đây của học sinh (0-10): {scores_summary}\n\n" + "Hãy viết chiến lược ôn thi cá nhân hóa gồm:\n" + "1. Đánh giá tổng quan\n2. Các chủ đề cần ưu tiên\n3. Phân bổ thời gian gợi ý\n4. Kế hoạch hành động cụ thể\n" + "Viết ngắn gọn, thực tế, bằng tiếng Việt." + ) + try: + resp = await client.chat.completions.create( + model=settings.default_model, max_tokens=800, + messages=[{"role": "user", "content": prompt}], + ) + strategy_text = (resp.choices[0].message.content or "").strip() + return {"strategy": strategy_text} + except Exception as exc: + raise HTTPException(status_code=502, detail=f"Không thể tạo chiến lược: {exc}") + + +@app.get("/compare/province") +async def compare_province( + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + tier_row_cp = await pool.fetchrow( + "SELECT subscription_tier, province FROM users WHERE id = ?", current_user.user_id + ) + if not tier_row_cp or tier_row_cp["subscription_tier"] != "complete": + raise HTTPException( + status_code=403, + detail={"code": "tier_required", "required": "complete"}, + ) + province = tier_row_cp["province"] + if not province: + raise HTTPException(status_code=422, detail="Chưa cài tỉnh thành trong hồ sơ") + user_avg_row = await pool.fetchrow( + "SELECT AVG(score) AS avg FROM exam_results WHERE user_id = ? AND score IS NOT NULL AND created_at > datetime('now', '-30 days')", + current_user.user_id, + ) + province_stats = await pool.fetchrow( + """SELECT AVG(er.score) AS avg, COUNT(DISTINCT er.user_id) AS user_count + FROM exam_results er JOIN users u ON u.id = er.user_id + WHERE u.province = ? AND er.score IS NOT NULL AND er.created_at > datetime('now', '-30 days')""", + province, + ) + user_avg = user_avg_row["avg"] if user_avg_row else None + prov_avg = province_stats["avg"] if province_stats else None + prov_count = province_stats["user_count"] if province_stats else 0 + percentile = None + if user_avg is not None and prov_count > 1: + rank_row = await pool.fetchrow( + """SELECT COUNT(DISTINCT er.user_id) AS better_count + FROM exam_results er JOIN users u ON u.id = er.user_id + WHERE u.province = ? AND er.score IS NOT NULL AND er.created_at > datetime('now', '-30 days') + GROUP BY er.user_id + HAVING AVG(er.score) > ?""", + province, user_avg, + ) + better = rank_row["better_count"] if rank_row else 0 + percentile = round((1 - better / prov_count) * 100) + return { + "province": province, + "your_avg": round(user_avg, 1) if user_avg is not None else None, + "province_avg": round(prov_avg, 1) if prov_avg is not None else None, + "province_user_count": prov_count, + "percentile": percentile, + } + + +@app.post("/explain", response_model=ExplainResponse) +async def explain( + req: ExplainRequest, + client: AsyncOpenAI = Depends(get_ai_client), + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + await _spend_credits(pool, current_user.user_id, 1, "explain") + from app.agent.exam_explainer import generate_explanation + try: + merged_prefs = {"explanation_depth": req.explanation_depth, "encouragement_level": req.encouragement_level, **req.ai_preferences} + data = await generate_explanation(client, req.question, req.chosen_index, merged_prefs) + return ExplainResponse( + correct_index=data.get("correct_index", 0), + explanation=data.get("explanation", ""), + ) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"Không thể tạo giải thích: {exc}") + + +_PAID_TIERS = {"student", "complete"} + + +@app.post("/study-plan", response_model=StudyPlanResponse) +async def study_plan( + req: StudyPlanRequest, + client: AsyncOpenAI = Depends(get_ai_client), + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + user_row = await pool.fetchrow( + "SELECT subscription_tier FROM users WHERE id = ?", current_user.user_id + ) + if not user_row or user_row["subscription_tier"] not in _PAID_TIERS: + raise HTTPException( + status_code=403, + detail={"code": "tier_required", "required": "student", "message": "Cần gói Học sinh hoặc Toàn diện để tạo kế hoạch học tập"}, + ) + await _spend_credits(pool, current_user.user_id, 5, "study-plan") + from app.agent.study_planner import generate_study_plan + data = await generate_study_plan(client, req.result, req.history, req.wrong_questions, req.topic_miss_counts, req.student_name, learner_archetype=req.learner_archetype, province=req.province) + return StudyPlanResponse( + score_gap=data.get("score_gap", ""), + focus_areas=data.get("focus_areas", []), + retake_note=data.get("retake_note", ""), + ) + + + +_CHART_INSIGHTS_FALLBACKS = { + "spark_insight": "Chưa đủ dữ liệu điểm số. Hoàn thành thêm bài thi để xem xu hướng.", + "radar_insight": "Chưa đủ dữ liệu chủ đề. Làm thêm bài thi đa dạng chủ đề để xem phân tích.", + "heatmap_insight": "Chưa có dữ liệu hoạt động. Bắt đầu học đều đặn mỗi ngày để xây chuỗi.", +} + + +@app.post("/insights/charts", response_model=ChartInsightsResponse) +async def chart_insights( + req: ChartInsightsRequest, + current_user: CurrentUser = Depends(get_current_user), +): + has_spark = bool(req.spark_data) + has_radar = bool(req.radar_data) + has_heatmap = bool(req.heatmap_summary.get("total_sessions") or req.heatmap_summary.get("active_days")) + + if not has_spark and not has_radar and not has_heatmap: + return ChartInsightsResponse(**_CHART_INSIGHTS_FALLBACKS) + + spark_insight = _CHART_INSIGHTS_FALLBACKS["spark_insight"] + if has_spark: + scores = [s.get("score", 0) for s in req.spark_data[-5:]] + if len(scores) >= 2: + delta = scores[-1] - scores[0] + avg = sum(scores) / len(scores) + if delta > 0: + spark_insight = f"Điểm số tăng {delta:.1f} điểm trong {len(scores)} bài gần nhất. Tiếp tục duy trì phong độ này!" + elif delta < 0: + spark_insight = f"Điểm số giảm nhẹ. Điểm TB {avg:.1f} — ôn lại chủ đề yếu để lấy lại phong độ." + else: + spark_insight = f"Điểm ổn định ở mức {avg:.1f}. Tập trung vào chủ đề yếu để bứt phá." + + radar_insight = _CHART_INSIGHTS_FALLBACKS["radar_insight"] + if has_radar: + sorted_topics = sorted(req.radar_data, key=lambda t: t.get("score", 100)) + if sorted_topics: + weakest = sorted_topics[0] + radar_insight = f"Chủ đề yếu nhất: {weakest.get('topic', 'Không rõ')} ({weakest.get('score', 0):.0f}%). Ưu tiên ôn chủ đề này trước." + + heatmap_insight = _CHART_INSIGHTS_FALLBACKS["heatmap_insight"] + if has_heatmap: + active_days = req.heatmap_summary.get("active_days", 0) + if active_days >= 5: + heatmap_insight = f"Bạn học {active_days} ngày tuần này — thói quen tuyệt vời! Duy trì đều đặn mỗi ngày." + elif active_days >= 2: + heatmap_insight = f"Bạn học {active_days} ngày tuần này. Thêm vài buổi ngắn để xây chuỗi học đều." + else: + heatmap_insight = "Chưa học đều. Bắt đầu bằng 10 phút mỗi ngày để tạo thói quen." + + return ChartInsightsResponse( + spark_insight=spark_insight, + radar_insight=radar_insight, + heatmap_insight=heatmap_insight, + ) + + +class WeeklyInsightRequest(BaseModel): + exam_count: int + avg_score: float + score_delta: float + top_weak_topic: str | None = None + streak: int + days_studied: int + + +class WeeklyInsightResponse(BaseModel): + summary: str + + +@app.post("/insights/weekly", response_model=WeeklyInsightResponse) +async def weekly_insight( + req: WeeklyInsightRequest, + current_user: CurrentUser = Depends(get_current_user), +): + if req.exam_count == 0: + text = "Tuần này chưa có bài thi nào. Làm một bài thi để bắt đầu theo dõi tiến độ của bạn." + return WeeklyInsightResponse(summary=text) + + delta_str = f"tăng {req.score_delta:.1f}" if req.score_delta > 0 else (f"giảm {abs(req.score_delta):.1f}" if req.score_delta < 0 else "giữ nguyên") + text = f"Tuần này bạn làm {req.exam_count} bài thi, điểm TB {req.avg_score:.1f} ({delta_str} so với tuần trước)." + if req.top_weak_topic: + text += f" Tuần tới ưu tiên ôn {req.top_weak_topic} để tăng điểm nhanh nhất." + elif req.days_studied >= 5: + text += " Bạn học rất đều — tiếp tục duy trì phong độ này!" + return WeeklyInsightResponse(summary=text) + + +@app.get("/insights/peer-stats") +async def peer_stats( + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + """Return anonymized peer performance stats for students in the same grade. + Only includes paying users (student/complete tier) for meaningful data. + FREE — no credit deduction. + """ + # Get current user's grade + user_row = await pool.fetchrow( + "SELECT grade FROM users WHERE id = ?", current_user.user_id + ) + if not user_row or not user_row["grade"]: + return {"sample_size": 0, "message": None} + + grade = user_row["grade"] + + # Fetch last 20 exam results for each peer (same grade, paying tier, excluding self) + peer_rows = await pool.fetch( + """SELECT er.user_id, er.score, er.created_at + FROM exam_results er + JOIN users u ON u.id = er.user_id + WHERE u.grade = ? + AND u.subscription_tier IN ('student', 'complete') + AND er.user_id != ? + AND er.score IS NOT NULL + ORDER BY er.user_id, er.created_at DESC""", + grade, current_user.user_id, + ) + + # Group into per-user result lists (take last 20 per user) + from collections import defaultdict + user_results: dict[int, list[float]] = defaultdict(list) + for row in peer_rows: + uid = row["user_id"] + if len(user_results[uid]) < 20: + user_results[uid].append(row["score"]) + + sample_size = len(user_results) + if sample_size < 5: + return {"sample_size": 0, "message": None} + + # avg_improvement: compare first 5 vs last 5 for users with >=10 exams + # Note: rows are DESC by created_at, so index 0 = most recent (last 5), last indices = oldest (first 5) + improvements = [] + for scores in user_results.values(): + if len(scores) >= 10: + # scores[0..4] = last 5 (most recent), scores[-5:] = first 5 (oldest) + recent_avg = sum(scores[:5]) / 5 + early_avg = sum(scores[-5:]) / 5 + improvements.append(recent_avg - early_avg) + + avg_improvement = round(sum(improvements) / len(improvements), 1) if improvements else 0.0 + + # avg_weekly_exams: total results / (weeks spanned), approximate via total count / users / ~4 weeks + total_exams = sum(len(s) for s in user_results.values()) + avg_weekly_exams = round((total_exams / sample_size) / 4, 1) + + # top_percentile_threshold: 80th percentile of peer avg scores + peer_avgs = sorted( + sum(scores) / len(scores) for scores in user_results.values() + ) + p80_idx = max(0, int(len(peer_avgs) * 0.8) - 1) + top_percentile_threshold = round(peer_avgs[p80_idx], 1) + + # Build message + grade_label = f"lớp {grade}" + if avg_improvement > 0: + message = ( + f"Học sinh {grade_label} cải thiện trung bình {avg_improvement} điểm " + f"sau 4-6 tuần luyện tập đều đặn." + ) + else: + message = ( + f"Học sinh {grade_label} luyện tập trung bình {avg_weekly_exams} bài/tuần." + ) + + return { + "sample_size": sample_size, + "avg_improvement": avg_improvement, + "avg_weekly_exams": avg_weekly_exams, + "top_percentile_threshold": top_percentile_threshold, + "message": message, + } + + +@app.post("/math-ingest") +async def math_ingest( + req: MathIngestRequest, + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + client = get_ai_client() + from app.math_wiki.agents.ingest import ingest_exam + try: + output = await ingest_exam(client, req.text, pool=pool) + return {"problems": len(output.problems), "wiki_units": len(output.wiki_units)} + except (json.JSONDecodeError, ValueError) as exc: + raise HTTPException(status_code=502, detail=str(exc)) + + +_ACCEPTED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/webp"} +_IMAGE_MAGIC = { + b"\xff\xd8\xff": "image/jpeg", + b"\x89PNG": "image/png", + b"RIFF": "image/webp", # RIFF....WEBP — checked further below +} + +def _validate_image_magic(data: bytes) -> str: + """Return detected MIME type or raise 415.""" + for magic, mime in _IMAGE_MAGIC.items(): + if data[:len(magic)] == magic: + if mime == "image/webp" and data[8:12] != b"WEBP": + continue + return mime + raise HTTPException(status_code=415, detail="File does not match an accepted image format (JPEG, PNG, or WebP).") + + +@app.post("/math-ocr", response_model=MathOcrResponse) +async def math_ocr( + file: UploadFile = File(...), + current_user: CurrentUser = Depends(get_current_user), +): + content_type = file.content_type or "" + if content_type not in _ACCEPTED_IMAGE_TYPES: + raise HTTPException( + status_code=415, + detail=f"Unsupported media type: {content_type!r}. Accepted: image/jpeg, image/png, image/webp", + ) + + MAX_SIZE = 5 * 1024 * 1024 # 5 MB + content = await file.read(MAX_SIZE + 1) + if len(content) > MAX_SIZE: + raise HTTPException(status_code=413, detail="Image too large (max 5 MB)") + + client = get_ai_client() + from app.math_wiki.agents.ocr import extract_math_from_image + try: + text = await extract_math_from_image(client, content, content_type) + except ValueError as exc: + raise HTTPException(status_code=502, detail=str(exc)) + except Exception as exc: + logger.error("math-ocr error: %s", exc) + raise HTTPException(status_code=502, detail=f"OCR failed: {exc}") + + return MathOcrResponse(text=text) + + +@app.post("/ocr/exam") +async def ocr_exam( + file: UploadFile = File(...), + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + """Extract structured exam questions from an image using vision AI. + Credits: 3 per call. Max 5 MB. Validates magic bytes; rejects non-images.""" + MAX_SIZE = 5 * 1024 * 1024 + content = await file.read(MAX_SIZE + 1) + if len(content) > MAX_SIZE: + raise HTTPException(status_code=413, detail="Image too large (max 5 MB)") + + # Magic bytes validation — never trust client MIME type + detected_mime = _validate_image_magic(content) + await _spend_credits(pool, current_user.user_id, 3, "ocr_exam") + + client = get_ai_client() + settings = get_settings() + import base64 + b64 = base64.standard_b64encode(content).decode() + prompt = ( + "Extract all math exam questions from this image. " + "Return a JSON array of objects: " + '{"question": "question text", "choices": ["A","B","C","D"], "correct": 0, "topic": "algebra", "difficulty": "medium"}. ' + "If choices are not present, use empty array. correct is the 0-based index if determinable, else null. " + "Use LaTeX for math. Return ONLY the JSON array." + ) + try: + response = await client.chat.completions.create( + model=settings.default_model, + messages=[{ + "role": "user", + "content": [ + {"type": "image_url", "image_url": {"url": f"data:{detected_mime};base64,{b64}"}}, + {"type": "text", "text": prompt}, + ], + }], + max_tokens=4096, + ) + raw = (response.choices[0].message.content or "").strip() + if raw.startswith("```"): + raw = raw.split("\n", 1)[-1].rsplit("```", 1)[0].strip() + questions = json.loads(raw) + if not isinstance(questions, list): + raise ValueError("Expected a JSON array") + except Exception as exc: + logger.error("ocr/exam failed: %s", exc) + raise HTTPException(status_code=502, detail=f"OCR extraction failed: {exc}") + + return {"questions": questions} + + +@app.post("/math-solve", response_model=MathSolveResponse) +async def math_solve( + req: MathSolveRequest, + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + tier_row_ms = await pool.fetchrow("SELECT subscription_tier, tos_accepted_at FROM users WHERE id = ?", current_user.user_id) + if not tier_row_ms or not tier_row_ms["tos_accepted_at"]: + raise HTTPException(status_code=403, detail={"code": "tos_not_accepted"}) + if tier_row_ms["subscription_tier"] == "basic": + today_uses = await pool.fetchrow( + "SELECT COUNT(*) AS cnt FROM ai_credits_log WHERE user_id = ? AND reason = 'math_solve' AND created_at >= date('now')", + current_user.user_id, + ) + if (today_uses["cnt"] or 0) >= 5: + raise HTTPException(403, detail={"code": "tier_required", "message": "Đã dùng hết 5 lượt Oracle hôm nay — nâng cấp để dùng không giới hạn"}) + await pool.execute("INSERT INTO ai_credits_log (user_id, delta, reason) VALUES (?, 0, 'math_solve')", current_user.user_id) + image_bytes: bytes | None = None + if req.image_base64: + try: + import base64 as _b64 + raw = req.image_base64 + # Strip data URI prefix if present + if ',' in raw: + raw = raw.split(',', 1)[1] + decoded = _b64.b64decode(raw) + if len(decoded) > 4 * 1024 * 1024: + raise HTTPException(status_code=400, detail="Image too large (max 4 MB)") + image_bytes = decoded + except Exception as exc: + if isinstance(exc, HTTPException): + raise + raise HTTPException(status_code=400, detail=f"Invalid image_base64: {exc}") + + client = get_ai_client() + from app.math_wiki.pipeline import run_pipeline + pipeline_result: MathSolveResponse | None = None + for attempt in range(2): + try: + pipeline_result = await asyncio.wait_for( + run_pipeline(pool, client, req.question, image_bytes=image_bytes, image_mime=req.image_mime or "image/jpeg"), + timeout=55, + ) + break + except asyncio.TimeoutError: + if attempt == 0: + logger.warning("math-solve attempt 1 timed out, retrying") + continue + raise HTTPException(status_code=504, detail="Pipeline timed out — try again") + except HTTPException: + raise + except (json.JSONDecodeError, ValueError) as exc: + raise HTTPException(status_code=502, detail=str(exc)) + except RateLimitError as exc: + raise HTTPException(status_code=429, detail=f"AI service rate limit: {exc}") + except (APIStatusError, APIConnectionError) as exc: + logger.error("math-solve AI client error: %s", exc) + raise HTTPException(status_code=502, detail=f"AI service error: {exc}") + except Exception as exc: + logger.exception("math-solve unexpected error: %s", exc) + raise HTTPException(status_code=502, detail=f"Pipeline error: {exc}") + + # Tutor recommendations: suggest follow-up problems based on concept mastery + if pipeline_result is not None and current_user and pool: + try: + from app.math_wiki.tutor_agent import get_tutor_recommendations + tutor_recs = await get_tutor_recommendations( + pool, client, + current_user.user_id, + pipeline_result.label or "", + was_correct=bool((pipeline_result.validation or {}).get("valid", False)), + ) + pipeline_result.tutor_recommendations = tutor_recs + except Exception: + pass + + return pipeline_result + + +@app.get("/math-wiki/calibration-report") +async def math_wiki_calibration_report( + days: int = 30, + pool=Depends(get_pool), + current_user: CurrentUser = Depends(get_current_user), +): + from app.math_wiki.storage.analytics import get_calibration_report + return await get_calibration_report(pool, days=days) + + +@app.post("/math-wiki/calibration-feedback") +async def math_wiki_calibration_feedback( + log_id: int, + actual_correct: bool, + pool=Depends(get_pool), + current_user: CurrentUser = Depends(get_current_user), +): + from app.math_wiki.storage.analytics import log_solution_feedback + await log_solution_feedback(pool, log_id, actual_correct) + # Active learning: flag wiki units that contributed to wrong answers + if not actual_correct: + asyncio.create_task(_flag_low_quality_units(pool, log_id)) + return {"ok": True} + + +@app.post("/math-review", response_model=MathReviewResponse) +async def math_review( + req: MathReviewRequest, + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + tier_row_mr = await pool.fetchrow("SELECT subscription_tier FROM users WHERE id = ?", current_user.user_id) + if not tier_row_mr or tier_row_mr["subscription_tier"] not in _PAID_TIERS: + raise HTTPException(403, detail={"code": "tier_required", "message": "Chế độ Chấm bài yêu cầu gói Học sinh trở lên"}) + client = get_ai_client() + from app.math_wiki.agents.reviewer import review_solution + from app.math_wiki.pipeline import _retrieve_rerank_context + retrieved_ids, context = await _retrieve_rerank_context(pool, client, req.problem) + try: + result = await review_solution(client, req.problem, req.solution, context) + except ValueError as exc: + raise HTTPException(status_code=502, detail=str(exc)) + except Exception as exc: + logger.error("math-review error: %s", exc) + raise HTTPException(status_code=502, detail=f"Review failed: {exc}") + return MathReviewResponse( + verdict=result.verdict, + score=result.score, + correct_steps=result.correct_steps, + errors=result.errors, + feedback=result.feedback, + correct_approach=result.correct_approach, + retrieved_ids=retrieved_ids, + ) + + +@app.post("/math-upload") +async def math_upload( + file: UploadFile = File(...), + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + MAX_SIZE = 10 * 1024 * 1024 # 10MB + content = await file.read(MAX_SIZE + 1) + if len(content) > MAX_SIZE: + raise HTTPException(status_code=413, detail="File too large (max 10MB)") + + filename = file.filename or "" + content_type = file.content_type or "" + + if filename.lower().endswith(".pdf") or content_type == "application/pdf": + try: + from pypdf import PdfReader + except ImportError: + raise HTTPException(status_code=501, detail="pypdf not installed") + reader = PdfReader(io.BytesIO(content)) + pages = [p.extract_text() or "" for p in reader.pages] + raw_text = "\n\n".join(p.strip() for p in pages if p.strip()) + elif content_type in _ACCEPTED_IMAGE_TYPES: + upload_client = get_ai_client() + from app.math_wiki.agents.ocr import extract_math_from_image + try: + raw_text = await extract_math_from_image(upload_client, content, content_type) + except ValueError as exc: + raise HTTPException(status_code=502, detail=str(exc)) + elif content_type.startswith("text/") or not content_type: + raw_text = content.decode("utf-8", errors="replace") + else: + raise HTTPException(status_code=415, detail=f"Unsupported file type: {content_type!r}") + + chunk_size = 3000 + chunks = [raw_text[i:i + chunk_size] for i in range(0, len(raw_text), chunk_size)] if raw_text else [] + + client = get_ai_client() + from app.math_wiki.agents.ingest import ingest_exam + total_problems = total_wiki = 0 + try: + for chunk in chunks: + output = await ingest_exam(client, chunk, pool=pool) + total_problems += len(output.problems) + total_wiki += len(output.wiki_units) + except (ValueError, json.JSONDecodeError) as exc: + raise HTTPException(status_code=502, detail=str(exc)) + + return {"chunks_ingested": len(chunks), "problems": total_problems, "wiki_units": total_wiki} + + +@app.get("/metrics") +async def metrics(request: Request, pool=Depends(get_pool)): + from app.metrics import get_metrics + from app.math_wiki.storage.analytics import get_unit_usage_stats + _require_admin(request) + data = get_metrics() + if pool: + try: + import os + from app.config import get_settings as _gs + db_path = _gs().sqlite_path + data["sqlite_size_bytes"] = os.path.getsize(db_path) if os.path.exists(db_path) else 0 + except Exception: + pass + try: + data["top_units"] = await get_unit_usage_stats(pool, days=30) + except Exception: + pass + return data + + +@app.get("/math-gaps") +async def math_gaps(threshold: int = 5, pool=Depends(get_pool)): + from app.math_wiki.storage import pg_db + from app.math_wiki.taxonomy import CANONICAL_TOPICS + topic_counts = await pg_db.count_wiki_units_by_topic(pool) + gaps = [ + {"topic": t, "count": topic_counts.get(t, 0)} + for t in CANONICAL_TOPICS + if topic_counts.get(t, 0) < threshold + ] + existing_response = sorted(gaps, key=lambda x: x["count"]) + + # Unit quality: find units flagged in >20% of their appearances + flagged_units: list = [] + try: + flagged_units = await pool.fetch(""" + SELECT uf.unit_id, + COUNT(*) as flag_count, + COUNT(*) * 1.0 / ( + SELECT COUNT(*) FROM solution_logs sl + WHERE sl.retrieved_ids LIKE '%' || uf.unit_id || '%' + ) as flag_rate, + wu.topic, wu.type + FROM unit_feedback uf + LEFT JOIN wiki_units wu ON wu.id = uf.unit_id + WHERE uf.feedback_type = 'low_confidence' + GROUP BY uf.unit_id + HAVING flag_count >= 3 AND flag_rate > 0.20 + ORDER BY flag_rate DESC + LIMIT 20 + """) + except Exception: + pass + + return { + "gaps": existing_response, + "low_quality_units": [ + {"id": r["unit_id"], "topic": r["topic"], "type": r["type"], + "flag_rate": round(r["flag_rate"], 2), "flag_count": r["flag_count"]} + for r in flagged_units + ], + } + + +@app.get("/math-stats") +async def math_stats(pool=Depends(get_pool)): + from app.math_wiki.storage import pg_db + return { + "problems": await pg_db.count_problems(pool), + "wiki_units": await pg_db.count_wiki_units(pool), + "topics": await pg_db.count_wiki_units_by_topic(pool), + } + + +# ── Auth endpoints ──────────────────────────────────────────────────────────── + +class GoogleAuthRequest(BaseModel): + id_token: str + ref: str | None = Field(default=None, max_length=20) + + +def _normalize_google_avatar(url: str | None) -> str | None: + if not url: + return url + normalized = re.sub(r'=s\d+(-c)?$', '=s200-c', url) + if normalized == url and 'googleusercontent.com' in url: + normalized = url + '=s200-c' + return normalized + + +@app.post("/auth/google") +async def auth_google(body: GoogleAuthRequest, pool=Depends(get_pool)): + import secrets as _secrets + try: + google_payload = await verify_google_token(body.id_token) + except ValueError as exc: + raise HTTPException(status_code=401, detail="Invalid or expired Google token") from exc + + google_sub = google_payload["sub"] + email = google_payload.get("email", "") + display_name = google_payload.get("name") + avatar_url = _normalize_google_avatar(google_payload.get("picture")) + + # Check if this google_sub previously hard-deleted their account to preserve trial_used + deleted_sub = await pool.fetchrow( + "SELECT trial_used FROM deleted_google_subs WHERE google_sub = $1", + google_sub, + ) + preserved_trial_used = deleted_sub["trial_used"] if deleted_sub else 0 + + # Determine new vs existing before upsert (xmax is PostgreSQL-only, not available in SQLite) + existing = await pool.fetchrow( + "SELECT id FROM users WHERE google_sub = $1", google_sub + ) + is_new_user = existing is None + + # Generate a unique referral code for new users + new_ref_code = _secrets.token_urlsafe(8) + + row = await pool.fetchrow( + """ + INSERT INTO users (google_sub, email, display_name, avatar_url, trial_used, referral_code, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, NOW(), NOW()) + ON CONFLICT (google_sub) DO UPDATE + SET display_name = EXCLUDED.display_name, + avatar_url = EXCLUDED.avatar_url, + updated_at = NOW() + RETURNING id, email, display_name, avatar_url, custom_display_name + """, + google_sub, email, display_name, avatar_url, preserved_trial_used, new_ref_code, + ) + + # Process referral — only on new account creation + if is_new_user and body.ref and len(body.ref) <= 20: + referrer = await pool.fetchrow( + "SELECT id, google_sub FROM users WHERE referral_code = $1", body.ref + ) + if referrer and referrer["id"] != row["id"] and referrer["google_sub"] != google_sub: + # Check referral cap (max 20 per referrer) + referral_count = await pool.fetchrow( + "SELECT COUNT(*) AS cnt FROM referral_grants WHERE referrer_id = $1", referrer["id"] + ) + if (referral_count["cnt"] or 0) < 20: + try: + await pool.execute( + "INSERT INTO referral_grants (referrer_id, referred_user_id) VALUES ($1, $2)", + referrer["id"], row["id"], + ) + # Grant 50 credits to both parties + await pool.execute( + "UPDATE users SET credits_balance = credits_balance + 50 WHERE id IN ($1, $2)", + referrer["id"], row["id"], + ) + await pool.execute( + "INSERT INTO ai_credits_log (user_id, delta, reason) VALUES ($1, 50, 'referral_grant'), ($2, 50, 'referral_grant')", + referrer["id"], row["id"], + ) + except Exception: + pass # UNIQUE constraint violation = already processed + + token = create_jwt(row["id"]) + return { + "access_token": token, + "user": { + "id": row["id"], + "email": row["email"], + "display_name": row["display_name"], + "avatar_url": row["avatar_url"], + "custom_display_name": row["custom_display_name"], + }, + } + + +# ── User endpoints ──────────────────────────────────────────────────────────── + +# Weekly streak freeze quota by tier +_FREEZE_QUOTA = {"basic": 1, "student": 1, "complete": 3} # basic gets 1 silent auto-grace/week + + +async def _replenish_streak_freeze(pool, user_id: int, tier: str, current_reset_at) -> int | None: + """Top up streak_freeze_count to the weekly quota if 7+ days have elapsed. + + Returns the new freeze count if a replenishment occurred, else None. + """ + quota = _FREEZE_QUOTA.get(tier, 0) + if quota == 0: + return None + + now = datetime.utcnow() + should_replenish = (current_reset_at is None) + if not should_replenish and current_reset_at: + try: + last_reset = datetime.fromisoformat(str(current_reset_at)) + should_replenish = (now - last_reset).days >= 7 + except (ValueError, TypeError): + should_replenish = True + + if not should_replenish: + return None + + await pool.execute( + """UPDATE users + SET streak_freeze_count = $1, + streak_freeze_reset_at = $2 + WHERE id = $3""", + quota, + now.strftime("%Y-%m-%dT%H:%M:%S"), + user_id, + ) + return quota + + +@app.get("/users/me") +async def get_me(current_user: CurrentUser = Depends(get_current_user), pool=Depends(get_pool)): + row = await pool.fetchrow( + """SELECT id, email, display_name, avatar_url, custom_display_name, + grade, school_type, province, + subscription_tier, subscription_period, subscription_expires_at, + credits_balance, credits_reset_at, + is_suspended, suspension_reason, tos_accepted_at, + trial_used, trial_expires_at, + is_deactivated, is_locked, lock_reason, + target_school, exam_date, weekly_study_hours, extended_onboarding_done, + streak_freeze_count, streak_freeze_reset_at + FROM users WHERE id = $1""", + current_user.user_id, + ) + if not row: + raise HTTPException(status_code=404, detail="User not found") + row = dict(row) + # Weekly streak freeze replenishment + new_freeze_count = await _replenish_streak_freeze( + pool, + current_user.user_id, + row.get("subscription_tier", "basic"), + row.get("streak_freeze_reset_at"), + ) + if new_freeze_count is not None: + row["streak_freeze_count"] = new_freeze_count + # Enforce trial expiry: downgrade to basic if 7-day trial has elapsed + if row.get("trial_used") and row.get("subscription_tier") == "student" and row.get("trial_expires_at"): + from datetime import datetime, timezone + expires = datetime.fromisoformat(row["trial_expires_at"]) + if expires.replace(tzinfo=timezone.utc) < datetime.now(timezone.utc): + await pool.execute( + "UPDATE users SET subscription_tier = 'basic', updated_at = NOW() WHERE id = $1", + current_user.user_id, + ) + row["subscription_tier"] = "basic" + # Compute mastery rank from solid concept count + solid_row = await pool.fetchrow( + "SELECT COUNT(*) AS cnt FROM concept_mastery WHERE user_id=$1 AND stage >= 4", + current_user.user_id, + ) + solid_count = solid_row["cnt"] if solid_row else 0 + if solid_count >= 56: + mastery_rank = "Chuyên gia" + elif solid_count >= 36: + mastery_rank = "Sinh viên" + elif solid_count >= 16: + mastery_rank = "Học sinh" + else: + mastery_rank = "Pemula" + row["mastery_rank"] = mastery_rank + row["solid_concept_count"] = solid_count + + # Hard questions answered correctly in the last 30 days (for rank-up identity message) + hard_row = await pool.fetchrow( + """SELECT COUNT(*) AS cnt FROM review_items + WHERE user_id=$1 AND quality_last >= 3 AND difficulty >= 0.6 + AND updated_at >= datetime('now', '-30 days')""", + current_user.user_id, + ) + row["hard_correct_30d"] = hard_row["cnt"] if hard_row else 0 + return row + + +@app.post("/users/me/streak-freeze") +async def use_streak_freeze(current_user: CurrentUser = Depends(get_current_user), pool=Depends(get_pool)): + """Spend one streak freeze charge. Returns updated balance.""" + row = await pool.fetchrow( + "SELECT subscription_tier, streak_freeze_count FROM users WHERE id = $1", + current_user.user_id, + ) + if not row: + raise HTTPException(status_code=404, detail="User not found") + + tier = row["subscription_tier"] + if tier == "basic": + raise HTTPException(status_code=400, detail="streak_freeze_not_available") + + current_balance = row["streak_freeze_count"] or 0 + if current_balance <= 0: + raise HTTPException(status_code=400, detail="no_freezes_remaining") + + await pool.execute( + "UPDATE users SET streak_freeze_count = streak_freeze_count - 1 WHERE id = $1", + current_user.user_id, + ) + new_balance = current_balance - 1 + + # Record credit-style event for auditing + await pool.execute( + "INSERT INTO ai_credits_log (user_id, delta, reason) VALUES ($1, $2, $3)", + current_user.user_id, 0, "streak_freeze_used", + ) + + return {"streak_freeze_count": new_balance} + + +@app.get("/users/me/referral") +async def get_referral(current_user: CurrentUser = Depends(get_current_user), pool=Depends(get_pool)): + row = await pool.fetchrow( + "SELECT referral_code FROM users WHERE id = $1", current_user.user_id + ) + if not row: + raise HTTPException(status_code=404, detail="User not found") + count_row = await pool.fetchrow( + "SELECT COUNT(*) AS cnt FROM referral_grants WHERE referrer_id = $1", current_user.user_id + ) + return { + "referral_code": row["referral_code"], + "successful_referrals": count_row["cnt"] if count_row else 0, + } + + +_VALID_GRADES = {"9", "10", "11", "12"} +_VALID_SCHOOL_TYPES = {"chuyên", "công lập", "quốc tế"} +_USERNAME_RE = re.compile(r'^[\w\s\-]{2,30}$', re.UNICODE) + + +class UsernameUpdateRequest(BaseModel): + username: str + + +@app.patch("/users/me/username") +async def update_username( + body: UsernameUpdateRequest, + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + name = body.username.strip() + name = ' '.join(name.split()) + if not _USERNAME_RE.match(name): + raise HTTPException(422, detail="Tên phải từ 2–30 ký tự, chỉ gồm chữ, số, dấu gạch và khoảng trắng") + try: + await pool.execute( + "UPDATE users SET custom_display_name = $1, updated_at = NOW() WHERE id = $2", + name, current_user.user_id, + ) + except Exception as e: + if "UNIQUE" in str(e).upper(): + raise HTTPException(409, detail="Tên này đã được người khác sử dụng") + raise + row = await pool.fetchrow("SELECT custom_display_name FROM users WHERE id = $1", current_user.user_id) + return {"custom_display_name": row["custom_display_name"]} + + +class ProfileUpdateRequest(BaseModel): + grade: str | None = None + school_type: str | None = None + province: str | None = None + + +@app.post("/users/me/profile") +@app.patch("/users/me/profile") +async def update_profile( + body: ProfileUpdateRequest, + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + if body.grade is not None and body.grade not in _VALID_GRADES: + raise HTTPException(status_code=422, detail=f"grade must be one of {sorted(_VALID_GRADES)}") + if body.school_type is not None and body.school_type not in _VALID_SCHOOL_TYPES: + raise HTTPException(status_code=422, detail=f"school_type must be one of {sorted(_VALID_SCHOOL_TYPES)}") + if body.province is not None and len(body.province.strip()) == 0: + raise HTTPException(status_code=422, detail="province cannot be blank") + + # Block grade changes on existing accounts — only first-time setup (grade IS NULL) is allowed + if body.grade is not None: + current_grade = await pool.fetchval("SELECT grade FROM users WHERE id = ?", current_user.user_id) + if current_grade is not None: + raise HTTPException( + status_code=422, + detail="Thay đổi lớp học cần yêu cầu qua hệ thống. Vui lòng dùng tính năng 'Đổi lớp' trong trang Tài khoản." + ) + + updates = {} + if body.grade is not None: + updates["grade"] = body.grade + if body.school_type is not None: + updates["school_type"] = body.school_type + if body.province is not None: + updates["province"] = body.province.strip() + updates["updated_at"] = "datetime('now')" + + if not updates: + raise HTTPException(status_code=422, detail="No fields to update") + + # Build SET clause — datetime('now') must not be quoted as a string + set_parts = [] + params = [] + for k, v in updates.items(): + if v == "datetime('now')": + set_parts.append(f"{k} = datetime('now')") + else: + set_parts.append(f"{k} = ?") + params.append(v) + params.append(current_user.user_id) + + await pool.execute( + f"UPDATE users SET {', '.join(set_parts)} WHERE id = ?", # noqa: S608 + *params, + ) + + row = await pool.fetchrow( + """SELECT id, email, display_name, avatar_url, + grade, school_type, province, + subscription_tier, subscription_period, subscription_expires_at, + credits_balance, credits_reset_at, + is_suspended, suspension_reason, tos_accepted_at + FROM users WHERE id = ?""", + current_user.user_id, + ) + return dict(row) + + +class GradeChangeRequestBody(BaseModel): + requested_grade: str + justification: str + +class GradeChangeDecision(BaseModel): + approved: bool + admin_note: str | None = None + +_GRADE_CHANGE_CREDIT_COST = 5 +_GRADE_CHANGE_COOLDOWN_DAYS = 90 +_GRADE_CHANGE_REJECTION_REFUND = 3 + +@app.post("/users/me/grade-change-request", status_code=201) +async def submit_grade_change_request( + body: GradeChangeRequestBody, + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + if body.requested_grade not in _VALID_GRADES: + raise HTTPException(status_code=422, detail=f"grade must be one of {sorted(_VALID_GRADES)}") + if len(body.justification.strip()) < 30: + raise HTTPException(status_code=422, detail="Vui lòng mô tả lý do ít nhất 30 ký tự.") + + user = await pool.fetchrow( + "SELECT grade, credits_balance, last_grade_approved_at FROM users WHERE id = ?", + current_user.user_id, + ) + if not user or user["grade"] is None: + raise HTTPException(status_code=422, detail="Vui lòng thiết lập lớp học trước khi yêu cầu thay đổi.") + if user["grade"] == body.requested_grade: + raise HTTPException(status_code=422, detail="Bạn đang ở lớp này rồi.") + + if user["last_grade_approved_at"]: + last = datetime.fromisoformat(user["last_grade_approved_at"]) + days_elapsed = (datetime.utcnow() - last).days + if days_elapsed < _GRADE_CHANGE_COOLDOWN_DAYS: + days_remaining = _GRADE_CHANGE_COOLDOWN_DAYS - days_elapsed + raise HTTPException(status_code=429, detail={ + "code": "grade_change_cooldown", + "days_remaining": days_remaining, + }) + + pending_id = await pool.fetchval( + "SELECT id FROM grade_change_requests WHERE user_id = ? AND status = 'pending' LIMIT 1", + current_user.user_id, + ) + if pending_id: + raise HTTPException(status_code=409, detail="Bạn đang có một yêu cầu đổi lớp chờ duyệt.") + + await _spend_credits(pool, current_user.user_id, _GRADE_CHANGE_CREDIT_COST, "grade_change_request") + + req_id = await pool.fetchval( + """INSERT INTO grade_change_requests + (user_id, current_grade, requested_grade, justification, credits_deducted) + VALUES (?, ?, ?, ?, ?) + RETURNING id""", + current_user.user_id, + user["grade"], + body.requested_grade, + body.justification.strip(), + _GRADE_CHANGE_CREDIT_COST, + ) + return {"request_id": req_id, "status": "pending", "credits_spent": _GRADE_CHANGE_CREDIT_COST} + + +@app.get("/users/me/grade-change-request") +async def get_my_grade_change_request( + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + row = await pool.fetchrow( + """SELECT id, current_grade, requested_grade, status, created_at, expires_at, + admin_note, credits_deducted, credits_refunded + FROM grade_change_requests + WHERE user_id = ? + ORDER BY created_at DESC LIMIT 1""", + current_user.user_id, + ) + return dict(row) if row else {"status": "none"} + + +@app.post("/admin/users/{user_id}/grade-change", status_code=204) +async def admin_decide_grade_change( + user_id: int, + body: GradeChangeDecision, + request: Request, + pool=Depends(get_pool), +): + _require_admin(request) + row = await pool.fetchrow( + """SELECT id, requested_grade, credits_deducted + FROM grade_change_requests + WHERE user_id = ? AND status = 'pending' + ORDER BY created_at DESC LIMIT 1""", + user_id, + ) + if not row: + raise HTTPException(status_code=404, detail="No pending grade change request for this user.") + + if body.approved: + await pool.execute( + "UPDATE users SET grade = ?, last_grade_approved_at = datetime('now'), updated_at = datetime('now') WHERE id = ?", + row["requested_grade"], user_id, + ) + await pool.execute( + "UPDATE grade_change_requests SET status = 'approved', resolved_at = datetime('now'), admin_note = ? WHERE id = ?", + body.admin_note, row["id"], + ) + else: + await pool.execute( + "UPDATE users SET credits_balance = credits_balance + ? WHERE id = ?", + _GRADE_CHANGE_REJECTION_REFUND, user_id, + ) + await pool.execute( + "INSERT INTO ai_credits_log (user_id, delta, reason) VALUES (?, ?, ?)", + user_id, _GRADE_CHANGE_REJECTION_REFUND, "grade_change_rejection_refund", + ) + await pool.execute( + """UPDATE grade_change_requests + SET status = 'rejected', resolved_at = datetime('now'), admin_note = ?, credits_refunded = ? + WHERE id = ?""", + body.admin_note, _GRADE_CHANGE_REJECTION_REFUND, row["id"], + ) + + +@app.get("/admin/grade-change-requests") +async def admin_list_grade_requests( + request: Request, + pool=Depends(get_pool), + status: str = "pending", +): + _require_admin(request) + rows = await pool.fetch( + """SELECT r.id, r.user_id, u.email, u.display_name, r.current_grade, + r.requested_grade, r.justification, r.status, r.created_at, r.expires_at + FROM grade_change_requests r + JOIN users u ON u.id = r.user_id + WHERE r.status = ? + ORDER BY r.created_at ASC""", + status, + ) + return [dict(r) for r in rows] + + +class ExtendedProfileRequest(BaseModel): + target_school: str | None = None + exam_date: str | None = None # YYYY-MM-DD + weekly_study_hours: int | None = None + + +@app.post("/users/me/profile/extended", status_code=204) +async def update_extended_profile( + body: ExtendedProfileRequest, + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + """Save optional post-onboarding fields. Always marks extended_onboarding_done=1.""" + sets, params = ["extended_onboarding_done = 1"], [] + if body.target_school is not None: + sets.append("target_school = ?"); params.append(body.target_school[:200].strip()) + if body.exam_date is not None: + # Validate YYYY-MM-DD format + import re as _re + if not _re.match(r"^\d{4}-\d{2}-\d{2}$", body.exam_date): + raise HTTPException(status_code=422, detail="exam_date must be YYYY-MM-DD") + sets.append("exam_date = ?"); params.append(body.exam_date) + if body.weekly_study_hours is not None: + hours = max(1, min(168, body.weekly_study_hours)) + sets.append("weekly_study_hours = ?"); params.append(hours) + params.append(current_user.user_id) + await pool.execute( + f"UPDATE users SET {', '.join(sets)} WHERE id = ?", # noqa: S608 + *params, + ) + + +@app.post("/users/me/tos-accept", status_code=204) +async def accept_tos( + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + await pool.execute( + "UPDATE users SET tos_accepted_at = datetime('now') WHERE id = ? AND tos_accepted_at IS NULL", + current_user.user_id, + ) + + +# ── Review / Learning Graph endpoints ──────────────────────────────────────── + +@app.post("/users/me/review-items", status_code=201) +async def bulk_create_review_items( + body: ReviewItemsBulkRequest, + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + """Migrate localStorage review queue to server. INSERT OR IGNORE (idempotent).""" + # Resolve concept_id for each question in one query + q_ids = [item.question_id for item in body.items] + concept_map: dict[str, str | None] = {} + if q_ids: + placeholders = ",".join(f"${i+1}" for i in range(len(q_ids))) + q_rows = await pool.fetch( + f"SELECT id, concept_id FROM questions WHERE id IN ({placeholders})", *q_ids + ) + concept_map = {r["id"]: r["concept_id"] for r in q_rows} + + inserted = 0 + for item in body.items: + cid = concept_map.get(item.question_id) + result = await pool.execute( + """INSERT OR IGNORE INTO review_items + (user_id, question_id, concept_id, stability, difficulty, interval, next_review_date) + VALUES ($1, $2, $3, $4, $5, $6, $7)""", + current_user.user_id, + item.question_id, + cid, + item.stability, + item.difficulty, + item.interval, + item.next_review_date, + ) + if result != "INSERT OR IGNORE 0": + inserted += 1 + return {"inserted": inserted, "total": len(body.items)} + + +@app.get("/users/me/review-items/due") +async def get_due_review_items( + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + """Return all review items due today or overdue. + Ordering: most overdue first, then province-weighted topics promoted within same-date groups.""" + from datetime import datetime, timezone + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + uid = current_user.user_id + + rows = await pool.fetch( + """SELECT ri.id, ri.question_id, ri.stability, ri.difficulty, + ri.interval, ri.repetitions, ri.next_review_date, ri.concept_id, + q.topic + FROM review_items ri + LEFT JOIN questions q ON q.id = ri.question_id + WHERE ri.user_id = $1 AND ri.next_review_date <= $2 + ORDER BY ri.next_review_date ASC + LIMIT 50""", + uid, + today, + ) + + # Fetch user province for topic-weight boosting + province_weights: dict = {} + try: + user_row = await pool.fetchrow("SELECT province FROM users WHERE id=$1", uid) + user_province = user_row["province"] if user_row else None + if user_province and user_province in _PROVINCE_DATA: + province_weights = _PROVINCE_DATA[user_province].get("topic_weights", {}) + except Exception: + pass + + items = [dict(r) for r in rows] + if province_weights: + # Secondary sort: within the same next_review_date, promote high-weight topics + items.sort(key=lambda r: ( + r["next_review_date"], + -(province_weights.get(r.get("topic") or "", 0)), + )) + + return {"items": items, "due_count": len(items)} + + +@app.post("/users/me/review-items/{item_id}/answer") +async def answer_review_item( + item_id: int, + body: ReviewAnswerRequest, + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + """Apply FSRS update to a review item and update concept mastery.""" + from datetime import datetime, timezone, timedelta + from app.agent.fsrs import fsrs_update + + if body.quality not in (1, 3, 5): + raise HTTPException(status_code=422, detail="quality must be 1, 3, or 5") + + row = await pool.fetchrow( + "SELECT * FROM review_items WHERE id = $1 AND user_id = $2", + item_id, current_user.user_id, + ) + if not row: + raise HTTPException(status_code=404, detail="Review item not found") + + today = datetime.now(timezone.utc).date() + last_reviewed = datetime.fromisoformat(row["next_review_date"]).date() if row["next_review_date"] else today + elapsed = max(1, (today - last_reviewed).days + row["interval"]) + + # Apply response-time signal to quality + effective_quality = body.quality + t = body.response_time_seconds + if t is not None and body.quality >= 3: + if t > 90: # correct but slow — struggling; don't over-reward + effective_quality = 3 + elif t < 5: # correct but suspiciously fast — lucky guess + effective_quality = 3 + + new_stability, new_difficulty, interval = fsrs_update( + row["stability"], row["difficulty"], elapsed, effective_quality + ) + next_date = (today + timedelta(days=interval)).isoformat() + + await pool.execute( + """UPDATE review_items + SET stability=$1, difficulty=$2, interval=$3, repetitions=repetitions+1, + next_review_date=$4, quality_last=$5, updated_at=NOW() + WHERE id=$6""", + new_stability, new_difficulty, interval, next_date, effective_quality, item_id, + ) + + # Update concept mastery if concept_id is set; track stage advance for mastery moment + concept_id = row["concept_id"] + stage_advanced = False + new_stage = None + concept_name_vi = None + + if concept_id: + mastery_row = await pool.fetchrow( + "SELECT mastery_score, stage, review_count FROM concept_mastery WHERE user_id=$1 AND concept_id=$2", + current_user.user_id, concept_id, + ) + if mastery_row: + old_stage = mastery_row["stage"] + delta = 5 if body.quality >= 3 else -8 + new_mastery = max(0, min(100, mastery_row["mastery_score"] + delta)) + new_stage = _mastery_to_stage(new_mastery) + stage_advanced = new_stage > old_stage + await pool.execute( + """UPDATE concept_mastery + SET mastery_score=$1, stage=$2, review_count=review_count+1, + last_practiced=NOW(), updated_at=NOW() + WHERE user_id=$3 AND concept_id=$4""", + new_mastery, new_stage, current_user.user_id, concept_id, + ) + else: + initial_mastery = 20 if body.quality >= 3 else 5 + new_stage = _mastery_to_stage(initial_mastery) + stage_advanced = new_stage > 0 + await pool.execute( + """INSERT INTO concept_mastery (user_id, concept_id, mastery_score, stage, review_count) + VALUES ($1, $2, $3, $4, 1)""", + current_user.user_id, concept_id, initial_mastery, new_stage, + ) + + if stage_advanced: + c_row = await pool.fetchrow("SELECT name_vi FROM concepts WHERE id=$1", concept_id) + concept_name_vi = c_row["name_vi"] if c_row else concept_id + + # Update concept ELO: K=16, baseline difficulty=1000 + elo_row = await pool.fetchrow( + "SELECT rating FROM concept_elo WHERE user_id=$1 AND concept_id=$2", + current_user.user_id, concept_id, + ) + current_elo = elo_row["rating"] if elo_row else 1000.0 + K = 16.0 + expected = 1.0 / (1.0 + 10.0 ** ((1000.0 - current_elo) / 400.0)) + actual = 1.0 if effective_quality >= 3 else 0.0 + new_elo = round(current_elo + K * (actual - expected), 2) + + if elo_row: + await pool.execute( + "UPDATE concept_elo SET rating=$1, updated_at=NOW() WHERE user_id=$2 AND concept_id=$3", + new_elo, current_user.user_id, concept_id, + ) + else: + await pool.execute( + "INSERT INTO concept_elo (user_id, concept_id, rating) VALUES ($1, $2, $3)", + current_user.user_id, concept_id, new_elo, + ) + + return { + "item_id": item_id, + "new_stability": round(new_stability, 3), + "new_difficulty": round(new_difficulty, 3), + "interval": interval, + "next_review_date": next_date, + "stage_advanced": stage_advanced, + "new_stage": new_stage, + "concept_name_vi": concept_name_vi, + } + + +def _mastery_to_stage(score: int) -> int: + if score <= 0: return 0 + if score <= 20: return 1 + if score <= 40: return 2 + if score <= 60: return 3 + if score <= 80: return 4 + return 5 + + +@app.get("/users/me/concept-mastery") +async def get_concept_mastery( + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + """Return all concepts with user's current mastery (0 if not started).""" + rows = await pool.fetch( + """SELECT c.id, c.name, c.name_vi, c.grade, c.topic, c.exam_weight, + c.prerequisite_ids, + COALESCE(cm.mastery_score, 0) AS mastery_score, + COALESCE(cm.stage, 0) AS stage, + cm.last_practiced, cm.review_count + FROM concepts c + LEFT JOIN concept_mastery cm ON cm.concept_id = c.id AND cm.user_id = $1 + ORDER BY c.grade, c.topic, c.id""", + current_user.user_id, + ) + import json + concepts = [] + for r in rows: + d = dict(r) + if isinstance(d.get("prerequisite_ids"), str): + try: + d["prerequisite_ids"] = json.loads(d["prerequisite_ids"]) + except Exception: + d["prerequisite_ids"] = [] + concepts.append(d) + return {"concepts": concepts} + + +# ── Session / Daily Engine endpoints ───────────────────────────────────────── + +@app.get("/users/me/session/today") +async def get_session_today( + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + """Compose today's learning session: SM-2 due count, advance concept, remediation, challenge.""" + from datetime import datetime, timezone + import json as _json + + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + uid = current_user.user_id + + # SM-2 due count + due_row = await pool.fetchrow( + "SELECT COUNT(*) AS cnt FROM review_items WHERE user_id=$1 AND next_review_date<=$2", + uid, today, + ) + due_count = due_row["cnt"] if due_row else 0 + + # Check if session already completed today + session_row = await pool.fetchrow( + "SELECT id FROM learning_sessions WHERE user_id=$1 AND session_date=$2", + uid, today, + ) + is_complete = session_row is not None + + # Advance concept: highest mastery SOLID (stage 4) concept whose successors are unstarted + mastery_rows = await pool.fetch( + """SELECT cm.concept_id, cm.mastery_score, cm.stage, c.prerequisite_ids, c.name_vi, c.exam_weight + FROM concept_mastery cm JOIN concepts c ON c.id = cm.concept_id + WHERE cm.user_id=$1 AND cm.stage >= 3 + ORDER BY cm.mastery_score DESC""", + uid, + ) + all_concepts = await pool.fetch("SELECT id, prerequisite_ids, name_vi, grade, topic FROM concepts") + mastered_ids = {r["concept_id"] for r in mastery_rows} + + advance_concept = None + for concept in all_concepts: + prereqs = _json.loads(concept["prerequisite_ids"]) if isinstance(concept["prerequisite_ids"], str) else (concept["prerequisite_ids"] or []) + if concept["id"] in mastered_ids: + continue + if prereqs and all(p in mastered_ids for p in prereqs): + advance_concept = {"id": concept["id"], "name_vi": concept["name_vi"], + "grade": concept["grade"], "topic": concept["topic"]} + break + + # Remediation: prefer concepts with high error counts (>=3) at stage <=3, + # then fall back to stage-2 concepts with lowest mastery + remediaton_row = await pool.fetchrow( + """SELECT cm.concept_id, c.name_vi, cm.mastery_score, cm.stage, + COALESCE(ep.total_errors, 0) AS error_count, + ep.top_error_type + FROM concept_mastery cm + JOIN concepts c ON c.id = cm.concept_id + LEFT JOIN ( + SELECT concept_id, + SUM(count) AS total_errors, + error_type AS top_error_type + FROM error_patterns + WHERE user_id=$1 + GROUP BY concept_id, error_type + ORDER BY total_errors DESC + LIMIT 1 + ) ep ON ep.concept_id = cm.concept_id + WHERE cm.user_id=$1 AND cm.stage <= 3 AND cm.stage >= 1 + ORDER BY + CASE WHEN COALESCE(ep.total_errors, 0) >= 3 THEN 0 ELSE 1 END, + COALESCE(ep.total_errors, 0) DESC, + cm.mastery_score ASC + LIMIT 1""", + uid, uid, + ) + remediation_concept = dict(remediaton_row) if remediaton_row else None + + # Compute learning streak (consecutive days with sessions) + streak_rows = await pool.fetch( + "SELECT session_date FROM learning_sessions WHERE user_id=$1 ORDER BY session_date DESC LIMIT 60", + uid, + ) + streak = 0 + from datetime import date, timedelta + check = date.today() + session_dates = {r["session_date"] for r in streak_rows} + while str(check) in session_dates or (streak == 0 and str(check - timedelta(days=1)) in session_dates): + if str(check) in session_dates: + streak += 1 + check -= timedelta(days=1) + + # Trajectory: predict exam score from mastery velocity + exam_date + user_row = await pool.fetchrow( + "SELECT exam_date, weekly_study_hours FROM users WHERE id=$1", uid + ) + days_remaining = None + predicted_score = None + on_track = None + if user_row and user_row["exam_date"]: + try: + from datetime import date as _date + exam_dt = _date.fromisoformat(user_row["exam_date"]) + days_remaining = (exam_dt - _date.today()).days + if days_remaining > 0: + solid_row = await pool.fetchrow( + "SELECT COUNT(*) AS cnt FROM concept_mastery WHERE user_id=$1 AND stage>=4", uid + ) + total_row = await pool.fetchrow("SELECT COUNT(*) AS cnt FROM concepts") + solid_count = solid_row["cnt"] if solid_row else 0 + total_concepts = total_row["cnt"] if total_row else 20 + weekly_hours = (user_row["weekly_study_hours"] or 5) + concepts_per_week = max(0.3, weekly_hours / 3.5) + weeks_remaining = max(1, days_remaining // 7) + predicted_solid = min(total_concepts, solid_count + concepts_per_week * weeks_remaining) + if total_concepts > 0: + predicted_score = round(5 + (predicted_solid / total_concepts) * 5, 1) + predicted_score = min(10.0, max(5.0, predicted_score)) + on_track = (predicted_score or 0) >= 8.0 + except Exception: + pass + + # Unresolved mistakes count (for coaching prompt on home screen) + pending_count = 0 + try: + cnt_row = await pool.fetchrow( + "SELECT COUNT(*) AS cnt FROM review_items WHERE user_id=$1 AND (quality_last IS NULL OR quality_last < 3)", + uid, + ) + pending_count = cnt_row["cnt"] if cnt_row else 0 + except Exception: + pass + + # Placement needed: true when concept_mastery is empty + mastery_cnt_row = await pool.fetchrow( + "SELECT COUNT(*) AS cnt FROM concept_mastery WHERE user_id=$1", uid + ) + placement_needed = (mastery_cnt_row["cnt"] if mastery_cnt_row else 0) == 0 + + return { + "due_count": due_count, + "is_complete": is_complete, + "advance_concept": advance_concept, + "remediation_concept": remediation_concept, + "learning_streak": streak, + "session_date": today, + "days_remaining": days_remaining, + "predicted_score": predicted_score, + "on_track": on_track, + "pending_count": pending_count, + "placement_needed": placement_needed, + } + + +@app.post("/users/me/session/complete", status_code=201) +async def complete_session( + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + """Mark today's learning session as complete (idempotent).""" + from datetime import datetime, timezone + today = datetime.now(timezone.utc).strftime("%Y-%m-%d") + uid = current_user.user_id + + existing = await pool.fetchrow( + "SELECT id FROM learning_sessions WHERE user_id=$1 AND session_date=$2", + uid, today, + ) + if existing: + return {"already_complete": True, "session_date": today} + + # Count how many SM-2 items were reviewed today + reviewed_row = await pool.fetchrow( + "SELECT COUNT(*) AS cnt FROM review_items WHERE user_id=$1 AND updated_at >= $2", + uid, today, + ) + sm2_count = reviewed_row["cnt"] if reviewed_row else 0 + + await pool.execute( + """INSERT INTO learning_sessions (user_id, session_date, sm2_reviewed) + VALUES ($1, $2, $3)""", + uid, today, sm2_count, + ) + return {"already_complete": False, "session_date": today, "sm2_reviewed": sm2_count} + + +class PlacementAnswer(BaseModel): + question_id: str + correct: bool + + +class PlacementRequest(BaseModel): + answers: list[PlacementAnswer] + + +@app.post("/users/me/placement", status_code=201) +async def submit_placement( + body: PlacementRequest, + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + """Create concept_mastery rows from 10 placement answers. + + Maps each answered question's concept_id to a concept_mastery row. + Correct answer → mastery_score=60 (stage 3), wrong → mastery_score=20 (stage 1). + Never overwrites existing progress. + """ + uid = current_user.user_id + if not body.answers: + return {"seeded": 0} + + # Build question→concept_id map from DB + q_ids = [a.question_id for a in body.answers] + placeholders = ",".join(f"${i+1}" for i in range(len(q_ids))) + q_rows = await pool.fetch( + f"SELECT id, concept_id FROM questions WHERE id IN ({placeholders})", *q_ids + ) + concept_map = {r["id"]: r["concept_id"] for r in q_rows if r["concept_id"]} + + seeded = 0 + for answer in body.answers: + cid = concept_map.get(answer.question_id) + if not cid: + continue + + existing = await pool.fetchrow( + "SELECT stage FROM concept_mastery WHERE user_id=$1 AND concept_id=$2", uid, cid + ) + if existing and existing["stage"] > 0: + continue # never overwrite real progress + + mastery_score = 60 if answer.correct else 20 + stage = _mastery_to_stage(mastery_score) + + if existing: + await pool.execute( + """UPDATE concept_mastery SET mastery_score=$1, stage=$2, updated_at=NOW() + WHERE user_id=$3 AND concept_id=$4""", + mastery_score, stage, uid, cid, + ) + else: + await pool.execute( + """INSERT INTO concept_mastery (user_id, concept_id, mastery_score, stage) + VALUES ($1, $2, $3, $4)""", + uid, cid, mastery_score, stage, + ) + seeded += 1 + + return {"seeded": seeded} + + +@app.get("/users/me/adaptive-study-plan") +async def get_adaptive_study_plan( + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + """Return a data-driven adaptive study plan computed from the Learning Graph.""" + from datetime import date as _date + import json as _json + + uid = current_user.user_id + + user_row = await pool.fetchrow( + "SELECT exam_date, weekly_study_hours FROM users WHERE id=$1", uid + ) + exam_date_str = user_row["exam_date"] if user_row else None + weekly_hours = (user_row["weekly_study_hours"] if user_row else None) or 5 + + days_remaining = None + weeks_remaining = 4 + if exam_date_str: + try: + exam_dt = _date.fromisoformat(exam_date_str) + days_remaining = (exam_dt - _date.today()).days + weeks_remaining = max(1, days_remaining // 7) + except Exception: + pass + + mastery_rows = await pool.fetch( + """SELECT cm.concept_id, cm.mastery_score, cm.stage, cm.review_count, + c.name_vi, c.grade, c.topic, c.exam_weight, c.prerequisite_ids + FROM concept_mastery cm + JOIN concepts c ON c.id = cm.concept_id + WHERE cm.user_id=$1""", + uid, + ) + all_concept_rows = await pool.fetch( + "SELECT id, name_vi, grade, topic, exam_weight, prerequisite_ids FROM concepts" + ) + error_rows = await pool.fetch( + "SELECT concept_id, error_type, count FROM error_patterns WHERE user_id=$1 ORDER BY count DESC", + uid, + ) + + error_by_concept: dict[str, list] = {} + for er in error_rows: + cid = er["concept_id"] + if cid not in error_by_concept: + error_by_concept[cid] = [] + error_by_concept[cid].append({"type": er["error_type"], "count": er["count"]}) + + mastery_dict = {r["concept_id"]: dict(r) for r in mastery_rows} + mastered_ids = {cid for cid, m in mastery_dict.items() if m["stage"] >= 4} + solid_count = len(mastered_ids) + total_concepts = len(all_concept_rows) + in_progress_count = sum(1 for m in mastery_dict.values() if 1 <= m["stage"] <= 3) + + # Trajectory + concepts_per_week = max(0.3, weekly_hours / 3.5) + predicted_solid = solid_count + if days_remaining is not None and days_remaining > 0: + predicted_solid = min(total_concepts, solid_count + concepts_per_week * weeks_remaining) + + if total_concepts > 0: + predicted_score = round(5 + (predicted_solid / total_concepts) * 5, 1) + predicted_score = min(10.0, max(5.0, predicted_score)) + else: + predicted_score = 5.0 + on_track = predicted_score >= 8.0 + + if days_remaining is not None and days_remaining > 0: + if on_track: + trajectory_message = ( + f"Với tốc độ hiện tại, bạn dự kiến đạt {predicted_score:.1f} vào kỳ thi. Đang đúng hướng!" + ) + else: + needed = max(0, round((8.0 - 5) / 5 * (total_concepts or 1)) - solid_count) + hours_needed = max(weekly_hours + 2, 7) + trajectory_message = ( + f"Cần thêm {needed} khái niệm vững để đạt 8.0. " + f"Hãy tăng thời gian luyện tập lên {hours_needed} giờ/tuần." + ) + else: + trajectory_message = "Nhập ngày thi để xem dự đoán điểm số của bạn." + + # Build priority-ranked focus pool + focus_pool = [] + started_ids = set(mastery_dict.keys()) + + for r in mastery_rows: + m = dict(r) + if m["stage"] == 0 or m["stage"] >= 5: + continue + priority = (100 - m["mastery_score"]) * m["exam_weight"] / max(1, m["stage"]) + focus_pool.append({ + "concept_id": m["concept_id"], + "name_vi": m["name_vi"], + "grade": m["grade"], + "topic": m["topic"], + "mastery_score": round(m["mastery_score"]), + "stage": m["stage"], + "exam_weight": m["exam_weight"], + "priority": round(priority, 2), + "error_types": [e["type"] for e in error_by_concept.get(m["concept_id"], [])[:2]], + }) + + # Also include unlocked but unstarted concepts + for r in all_concept_rows: + if r["id"] in started_ids: + continue + prereqs = _json.loads(r["prerequisite_ids"]) if isinstance(r["prerequisite_ids"], str) else (r["prerequisite_ids"] or []) + if prereqs and not all(p in mastered_ids for p in prereqs): + continue + focus_pool.append({ + "concept_id": r["id"], + "name_vi": r["name_vi"], + "grade": r["grade"], + "topic": r["topic"], + "mastery_score": 0, + "stage": 0, + "exam_weight": r["exam_weight"], + "priority": 0.5 * r["exam_weight"], + "error_types": [], + }) + + focus_pool.sort(key=lambda x: -x["priority"]) + + # Build interleaved weekly schedule (up to 4 weeks, 3 concepts/week) + DAYS = ["Thứ 2", "Thứ 3", "Thứ 4", "Thứ 5", "Thứ 6", "Thứ 7"] + concepts_per_week_slot = 3 + weekly_schedule = [] + + for week_idx in range(min(weeks_remaining, 4)): + week_concepts = focus_pool[ + week_idx * concepts_per_week_slot: (week_idx + 1) * concepts_per_week_slot + ] + if not week_concepts: + break + + daily_plan = [] + for day_idx, day_name in enumerate(DAYS): + day_items = [] + if day_idx in (0, 2, 4): # Mon/Wed/Fri: include SM-2 review + day_items.append({"type": "sm2", "label": "Ôn lại (FSRS)"}) + concept_today = week_concepts[day_idx % len(week_concepts)] + day_items.append({ + "type": "concept", + "concept_id": concept_today["concept_id"], + "name_vi": concept_today["name_vi"], + }) + if day_idx == 5: # Saturday: add challenge + day_items.append({"type": "challenge", "label": "Bài khó"}) + daily_plan.append({"day": day_name, "items": day_items}) + + weekly_schedule.append({ + "week": week_idx + 1, + "focus_concepts": week_concepts, + "daily_plan": daily_plan, + }) + + return { + "solid_count": solid_count, + "total_concepts": total_concepts, + "in_progress_count": in_progress_count, + "days_remaining": days_remaining, + "weeks_remaining": weeks_remaining if days_remaining else None, + "predicted_score": predicted_score, + "on_track": on_track, + "trajectory_message": trajectory_message, + "weekly_schedule": weekly_schedule, + "focus_concepts": focus_pool[:6], + } + + +class DiagnosticSeedRequest(BaseModel): + weights: dict # {topic: weight} where weight = 1 - accuracy (high = weak) + + +@app.post("/users/me/diagnostic-seed", status_code=201) +async def diagnostic_seed( + body: DiagnosticSeedRequest, + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + """Seed the Learning Graph from diagnostic test results. + + For each concept matching a topic, create an initial concept_mastery row + calibrated from the diagnostic accuracy. Only seeds concepts that haven't + been started (stage = 0 or no row). Never overwrites existing progress. + """ + if not body.weights: + return {"seeded": 0} + + uid = current_user.user_id + concept_rows = await pool.fetch( + "SELECT id, topic FROM concepts" + ) + + seeded = 0 + for c in concept_rows: + topic = c["topic"] + weight = body.weights.get(topic) + if weight is None: + continue + # weight is (1 - accuracy): 0.1 = perfect, 1.0 = all wrong + # mastery_score = accuracy * 50 = (1 - weight) * 50, range 5-45 + accuracy = max(0.0, min(1.0, 1.0 - weight)) + mastery_score = round(accuracy * 50) + + existing = await pool.fetchrow( + "SELECT stage FROM concept_mastery WHERE user_id=$1 AND concept_id=$2", + uid, c["id"], + ) + if existing and existing["stage"] > 0: + continue # never overwrite real progress + + stage = _mastery_to_stage(mastery_score) + if existing: + await pool.execute( + """UPDATE concept_mastery + SET mastery_score=$1, stage=$2, updated_at=NOW() + WHERE user_id=$3 AND concept_id=$4""", + mastery_score, stage, uid, c["id"], + ) + else: + await pool.execute( + """INSERT INTO concept_mastery (user_id, concept_id, mastery_score, stage) + VALUES ($1, $2, $3, $4)""", + uid, c["id"], mastery_score, stage, + ) + seeded += 1 + + return {"seeded": seeded} + + +async def _flag_low_quality_units(pool, log_id: int) -> None: + """Mark wiki units that contributed to a wrong answer.""" + try: + row = await pool.fetchrow( + "SELECT retrieved_ids FROM solution_logs WHERE id = ?", log_id + ) + if not row or not row["retrieved_ids"]: + return + import json as _json + unit_ids = _json.loads(row["retrieved_ids"]) if isinstance(row["retrieved_ids"], str) else row["retrieved_ids"] + for uid in unit_ids[:10]: + await pool.execute( + """INSERT INTO unit_feedback (unit_id, feedback_type, created_at) + VALUES (?, 'low_confidence', datetime('now')) + ON CONFLICT DO NOTHING""", + uid, + ) + except Exception as exc: + import logging + logging.getLogger(__name__).debug("_flag_low_quality_units failed: %s", exc) + + +async def _spend_credits(pool, user_id: int, amount: int, reason: str) -> None: + """Atomically deduct `amount` credits. Raises 402 if insufficient, 403 if TOS not accepted.""" + row = await pool.fetchrow( + "SELECT credits_balance, tos_accepted_at FROM users WHERE id = ?", user_id + ) + if not row: + raise HTTPException(status_code=404, detail="User not found") + if not row["tos_accepted_at"]: + raise HTTPException(status_code=403, detail="tos_not_accepted") + result = await pool.execute( + "UPDATE users SET credits_balance = credits_balance - ? WHERE id = ? AND credits_balance >= ?", + amount, user_id, amount, + ) + if result == "UPDATE 0": + balance_row = await pool.fetchrow("SELECT credits_balance FROM users WHERE id = ?", user_id) + balance = balance_row["credits_balance"] if balance_row else 0 + raise HTTPException( + status_code=402, + detail={"code": "insufficient_credits", "balance": balance, "required": amount}, + ) + await pool.execute( + "INSERT INTO ai_credits_log (user_id, delta, reason) VALUES (?, ?, ?)", + user_id, -amount, reason, + ) + + +async def _refund_credits(pool, user_id: int, amount: int, reason: str) -> None: + """Return `amount` credits to the user (e.g. on empty AI response).""" + await pool.execute( + "UPDATE users SET credits_balance = credits_balance + ? WHERE id = ?", + amount, user_id, + ) + await pool.execute( + "INSERT INTO ai_credits_log (user_id, delta, reason) VALUES (?, ?, ?)", + user_id, amount, reason, + ) + + +@app.get("/payment/config") +async def get_payment_config(current_user: CurrentUser = Depends(get_current_user)): + """Return bank transfer details for the top-up modal. Requires authentication.""" + return { + "bank_name": settings.payment_bank_name, + "account_number": settings.payment_account_number, + "account_name": settings.payment_account_name, + } + + +# --------------------------------------------------------------------------- +# Daily challenge endpoints +# --------------------------------------------------------------------------- + +@app.get("/daily-challenge") +async def get_daily_challenge( + request: Request, + pool=Depends(get_pool), +): + """Return one personalized daily question. + Authenticated users get their unresolved mistake or due SR card first. + Unauthenticated users get a deterministic daily pick. + """ + from datetime import datetime, timezone + date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d") + + # Try to identify user from JWT (optional — endpoint is public) + user_id: int | None = None + try: + token = request.headers.get("authorization", "").removeprefix("Bearer ").strip() + if token: + import jwt as _jwt + settings_inner = get_settings() + payload = _jwt.decode(token, settings_inner.jwt_secret, algorithms=["HS256"]) + user_id = int(payload.get("sub", 0)) or None + except Exception: + pass + + source = "new" + days_since_wrong: int | None = None + pending_count = 0 + question_id: str | None = None + + if user_id: + # Priority 1 — unresolved mistake (quality_last < 3 or null = never reviewed correctly) + row = await pool.fetchrow( + """SELECT ri.question_id, ri.updated_at, ri.quality_last + FROM review_items ri + WHERE ri.user_id = ? + AND (ri.quality_last IS NULL OR ri.quality_last < 3) + AND date(ri.updated_at) < ? + ORDER BY ri.updated_at DESC + LIMIT 1""", + user_id, date_str, + ) + if row: + question_id = row["question_id"] + source = "mistake_retry" + try: + then = datetime.fromisoformat(row["updated_at"].replace("Z", "+00:00")).date() + days_since_wrong = (datetime.now(timezone.utc).date() - then).days + except Exception: + days_since_wrong = 1 + # Count remaining unresolved mistakes + cnt_row = await pool.fetchrow( + "SELECT COUNT(*) AS cnt FROM review_items WHERE user_id = ? AND (quality_last IS NULL OR quality_last < 3)", + user_id, + ) + pending_count = max(0, (cnt_row["cnt"] if cnt_row else 0) - 1) + + # Priority 2 — due SR card (not already selected as mistake) + if not question_id: + row = await pool.fetchrow( + """SELECT question_id FROM review_items + WHERE user_id = ? AND next_review_date <= ? AND (quality_last IS NULL OR quality_last >= 3) + ORDER BY next_review_date ASC LIMIT 1""", + user_id, date_str, + ) + if row: + question_id = row["question_id"] + source = "sr_due" + + # Priority 3 — weak topic question (user has history) + weak_topic: str | None = None + if not question_id and user_id: + try: + topic_row = await pool.fetchrow( + """SELECT q.topic, COUNT(*) AS miss_count + FROM review_items ri JOIN questions q ON q.id = ri.question_id + WHERE ri.user_id = ? AND (ri.quality_last IS NULL OR ri.quality_last < 3) + GROUP BY q.topic ORDER BY miss_count DESC LIMIT 1""", + user_id, + ) + if topic_row and topic_row["topic"]: + weak_topic = topic_row["topic"] + topic_rows = await pool.fetch( + "SELECT id FROM questions WHERE topic = ? ORDER BY id", weak_topic + ) + if topic_rows: + topic_ids = [r["id"] for r in topic_rows] + seed = (str(user_id) + date_str + weak_topic) + h = 0 + for c in seed: + h = (h * 31 + ord(c)) & 0xFFFFFFFF + question_id = topic_ids[h % len(topic_ids)] + source = "weak_topic" + except Exception: + pass + + # Priority 4 — deterministic daily pick from question bank + if not question_id: + try: + rows = await pool.fetch("SELECT id FROM questions ORDER BY id") + all_ids = [r["id"] for r in rows] + except Exception: + all_ids = list(_load_answer_key().keys()) + if not all_ids: + raise HTTPException(status_code=503, detail="question_data_unavailable") + seed = (str(user_id) if user_id else "guest") + date_str + h = 0 + for c in seed: + h = (h * 31 + ord(c)) & 0xFFFFFFFF + question_id = all_ids[h % len(all_ids)] + + # Province context label — shown when topic is high-weight in user's province + province_context: str | None = None + if weak_topic and user_id: + try: + user_row = await pool.fetchrow("SELECT province FROM users WHERE id = ?", user_id) + user_province = user_row["province"] if user_row else None + if user_province and user_province in _PROVINCE_DATA: + tw = _PROVINCE_DATA[user_province].get("topic_weights", {}) + if tw.get(weak_topic, 0) >= 10: + province_context = f"Đây là dạng bài thường xuất hiện trong đề thi ở {user_province}" + except Exception: + pass + + return { + "date": date_str, + "question_id": question_id, + "source": source, + "days_since_wrong": days_since_wrong, + "pending_count": pending_count, + "province_context": province_context, + } + + +async def _compute_daily_streak(pool, user_id: int, today: str) -> int: + from datetime import datetime, timedelta + rows = await pool.fetch( + "SELECT date FROM daily_challenge_leaderboard WHERE user_id = ? ORDER BY date DESC", + str(user_id), + ) + dates = {r["date"] for r in rows} + count = 0 + check = datetime.fromisoformat(today).date() + while str(check) in dates: + count += 1 + check -= timedelta(days=1) + return count + + +class DailyChallengeScoreRequest(BaseModel): + question_id: str + correct: bool + + +@app.post("/daily-challenge/score") +async def submit_daily_challenge_score( + req: DailyChallengeScoreRequest, + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + """Record that the student showed up today and attempted the question. + Tia is granted on first submission regardless of correctness. + """ + from datetime import datetime, timezone + date_str = datetime.now(timezone.utc).strftime("%Y-%m-%d") + + existing = await pool.fetchrow( + "SELECT id FROM daily_challenge_leaderboard WHERE user_id = ? AND date = ?", + str(current_user.user_id), date_str, + ) + first_submission = existing is None + + # Silent grace day: if the user missed exactly 1 day AND has a freeze available, + # silently fill the gap so the streak computation sees no break. + if first_submission: + from datetime import timedelta + yesterday = (datetime.now(timezone.utc) - timedelta(days=1)).strftime("%Y-%m-%d") + day_before = (datetime.now(timezone.utc) - timedelta(days=2)).strftime("%Y-%m-%d") + has_yesterday = await pool.fetchrow( + "SELECT id FROM daily_challenge_leaderboard WHERE user_id = ? AND date = ?", + str(current_user.user_id), yesterday, + ) + if not has_yesterday: + # Missed yesterday — check if streak was alive the day before + had_prior = await pool.fetchrow( + "SELECT id FROM daily_challenge_leaderboard WHERE user_id = ? AND date = ?", + str(current_user.user_id), day_before, + ) + if had_prior: + freeze_row = await pool.fetchrow( + "SELECT streak_freeze_count, streak_freeze_reset_at, subscription_tier FROM users WHERE id = ?", + current_user.user_id, + ) + if freeze_row: + # Replenish first if due + await _replenish_streak_freeze( + pool, current_user.user_id, + freeze_row["subscription_tier"] or "basic", + freeze_row["streak_freeze_reset_at"], + ) + fresh = await pool.fetchrow( + "SELECT streak_freeze_count FROM users WHERE id = ?", current_user.user_id + ) + if fresh and (fresh["streak_freeze_count"] or 0) > 0: + await pool.execute( + """INSERT OR IGNORE INTO daily_challenge_leaderboard + (user_id, display_name, date, score, total, time_seconds) + VALUES (?, ?, ?, 0, 1, 0)""", + str(current_user.user_id), current_user.display_name or "", yesterday, + ) + await pool.execute( + "UPDATE users SET streak_freeze_count = streak_freeze_count - 1 WHERE id = ?", + current_user.user_id, + ) + + if first_submission: + await pool.execute( + """INSERT INTO daily_challenge_leaderboard + (user_id, display_name, date, score, total, time_seconds) + VALUES (?, ?, ?, ?, 1, 0)""", + str(current_user.user_id), + current_user.display_name or "", + date_str, + 1 if req.correct else 0, + ) + + tia_earned = 0 + streak = 0 + if first_submission: + await pool.execute( + "UPDATE users SET credits_balance = credits_balance + 1 WHERE id = ?", + current_user.user_id, + ) + await pool.execute( + "INSERT INTO ai_credits_log (user_id, delta, reason) VALUES (?, 1, 'daily_challenge')", + current_user.user_id, + ) + tia_earned = 1 + streak = await _compute_daily_streak(pool, current_user.user_id, date_str) + + # Streak milestone bonuses for paid tiers + tier_row_dc = await pool.fetchrow("SELECT subscription_tier FROM users WHERE id = ?", current_user.user_id) + if tier_row_dc and tier_row_dc["subscription_tier"] in _PAID_TIERS: + bonus_map = {7: 20, 30: 100, 100: 300} + bonus = bonus_map.get(streak, 0) + if bonus: + await pool.execute( + "UPDATE users SET credits_balance = credits_balance + ? WHERE id = ?", + bonus, current_user.user_id, + ) + await pool.execute( + "INSERT INTO ai_credits_log (user_id, delta, reason) VALUES (?, ?, ?)", + current_user.user_id, bonus, f"streak_bonus_{streak}", + ) + tia_earned += bonus + + # Count remaining unresolved mistakes for the forward-looking message + cnt_row = await pool.fetchrow( + "SELECT COUNT(*) AS cnt FROM review_items WHERE user_id = ? AND (quality_last IS NULL OR quality_last < 3)", + current_user.user_id, + ) + pending_count = max(0, (cnt_row["cnt"] if cnt_row else 0) - (1 if not req.correct else 0)) + + return {"tia_earned": tia_earned, "streak": streak, "pending_count": pending_count, "first_submission": first_submission} + + +@app.get("/users/me/credits/log") +async def get_credits_log( + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + rows = await pool.fetch( + "SELECT delta, reason, created_at FROM ai_credits_log WHERE user_id = ? ORDER BY created_at DESC LIMIT 20", + current_user.user_id, + ) + return [dict(r) for r in rows] + + +class DeviceUpsertRequest(BaseModel): + device_id: str = Field(max_length=64) + device_label: str = Field(max_length=100) + city: str | None = Field(default=None, max_length=100) + province: str | None = Field(default=None, max_length=100) + country: str | None = Field(default=None, max_length=100) + country_code: str | None = Field(default=None, max_length=2) + + +async def _lookup_ip_province(ip: str | None) -> str | None: + """Resolve an IP address to a Vietnamese province name via ip-api.com. + Returns None on any failure — must never raise.""" + if not ip or ip in ("127.0.0.1", "::1", "localhost"): + return None + try: + async with httpx.AsyncClient(timeout=2.0) as client: + r = await client.get( + f"http://ip-api.com/json/{ip}", + params={"lang": "vi", "fields": "status,regionName"}, + ) + if r.status_code == 200: + d = r.json() + if d.get("status") == "success": + return d.get("regionName") + except Exception: + pass + return None + + +@app.post("/users/me/device", status_code=204) +async def upsert_user_device( + request: Request, + body: DeviceUpsertRequest, + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + xff = request.headers.get("X-Forwarded-For") + ip = xff.split(",")[0].strip() if xff else ( + request.headers.get("X-Real-IP") or (request.client.host if request.client else None) + ) + existing = await pool.fetchrow( + "SELECT id, ip_province FROM user_devices WHERE user_id = ? AND device_id = ?", + current_user.user_id, body.device_id, + ) + if not existing: + await pool.execute( + "INSERT INTO security_events (user_id, ip, event_type, confidence, detail) VALUES (?, ?, 'new_device', 'low', ?)", + current_user.user_id, ip, + json.dumps({"device": body.device_label, "city": body.city, "country": body.country_code}), + ) + # Only look up IP province if not already stored for this device + ip_province = existing["ip_province"] if existing else None + if not ip_province: + ip_province = await _lookup_ip_province(ip) + await pool.execute( + """INSERT INTO user_devices + (user_id, device_id, device_label, ip, city, province, ip_province, country, country_code) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(user_id, device_id) DO UPDATE SET + device_label = excluded.device_label, + ip = excluded.ip, + city = COALESCE(excluded.city, city), + province = COALESCE(excluded.province, province), + ip_province = COALESCE(excluded.ip_province, ip_province), + country = COALESCE(excluded.country, country), + country_code = COALESCE(excluded.country_code, country_code), + last_seen_at = datetime('now')""", + current_user.user_id, body.device_id, body.device_label, + ip, body.city, body.province, ip_province, body.country, body.country_code, + ) + + +class HistoryEntry(BaseModel): + result_id: str + exam_id: str | None = None + score: float | None = None + payload: dict | None = None + created_at: str | None = None + + +@app.post("/users/me/history") +async def post_history( + entries: list[HistoryEntry], + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + if not entries: + return {"streak_recovered": False} + for entry in entries: + if entry.score is not None and not (0 <= entry.score <= 10): + raise HTTPException(status_code=422, detail=f"score must be between 0 and 10, got {entry.score}") + if entry.payload: + acc = entry.payload.get("accuracy") + if acc is not None and not (0 <= float(acc) <= 1): + raise HTTPException(status_code=422, detail=f"accuracy must be between 0 and 1, got {acc}") + + # Streak recovery: check BEFORE inserting new results + user_streak_row = await pool.fetchrow( + "SELECT streak_freeze_count FROM users WHERE id = $1", + current_user.user_id, + ) + # Get the last exam date before the new entries + last_exam_row = await pool.fetchrow( + "SELECT created_at FROM exam_results WHERE user_id = $1 ORDER BY created_at DESC LIMIT 1", + current_user.user_id, + ) + today_str = datetime.utcnow().strftime("%Y-%m-%d") + + async with pool.acquire() as conn: + for entry in entries: + await conn.execute( + """ + INSERT INTO exam_results (result_id, user_id, exam_id, score, payload, created_at) + VALUES ($1, $2, $3, $4, $5::jsonb, COALESCE($6::timestamptz, NOW())) + ON CONFLICT (result_id) DO NOTHING + """, + entry.result_id, + current_user.user_id, + entry.exam_id, + entry.score, + json.dumps(entry.payload) if entry.payload is not None else None, + entry.created_at, + ) + # Timing anomaly detection + if entry.payload: + duration = entry.payload.get("durationSeconds") + answered = entry.payload.get("answeredCount", 0) + if duration is not None and answered > 5 and duration < answered * 3: + await conn.execute( + "INSERT INTO security_events (user_id, event_type, confidence, detail) VALUES ($1, $2, $3, $4)", + current_user.user_id, + "exam_anomaly", + "HIGH", + json.dumps({"reason": "impossible_speed", "durationSeconds": duration, "answeredCount": answered, "exam_id": entry.exam_id}), + ) + # Leaderboard — anonymous score insert (no user_id) + if entry.exam_id and entry.score is not None: + await conn.execute( + "INSERT INTO exam_leaderboard (exam_id, score) VALUES ($1, $2)", + entry.exam_id, + entry.score, + ) + + # Streak recovery check: missed exactly 1 day + 2+ exams today after insert + streak_recovered = False + new_streak = None + if last_exam_row and last_exam_row["created_at"]: + last_date_str = str(last_exam_row["created_at"])[:10] + try: + last_date = datetime.strptime(last_date_str, "%Y-%m-%d") + today_date = datetime.strptime(today_str, "%Y-%m-%d") + gap_days = (today_date - last_date).days + except (ValueError, TypeError): + gap_days = 0 + + if gap_days == 2: + # Count today's results after the insert + today_count_row = await pool.fetchrow( + """SELECT COUNT(*) AS cnt FROM exam_results + WHERE user_id = $1 AND created_at >= $2""", + current_user.user_id, + today_str + "T00:00:00", + ) + today_count = today_count_row["cnt"] if today_count_row else 0 + if today_count >= 2: + # Fetch current streak from learning_sessions (best proxy) + # Use exam_results count of consecutive days as an approximation + streak_count_row = await pool.fetchrow( + """SELECT COUNT(DISTINCT substr(created_at, 1, 10)) AS cnt + FROM exam_results WHERE user_id = $1 + AND created_at >= datetime('now', '-30 days')""", + current_user.user_id, + ) + base_streak = streak_count_row["cnt"] if streak_count_row else 0 + # Restore: +1 for missed day +1 for today (both count) + new_streak = base_streak + streak_recovered = True + + return {"streak_recovered": streak_recovered, "streak": new_streak} + + +@app.get("/results/{exam_id}/percentile") +async def get_percentile( + exam_id: str, + score: float, + pool=Depends(get_pool), +): + """Returns the percentile rank (0–100) for a given score on an exam.""" + if not (0 <= score <= 10): + raise HTTPException(status_code=422, detail="score must be between 0 and 10") + row = await pool.fetchrow( + """ + SELECT + COUNT(*) FILTER (WHERE score <= $2) AS at_or_below, + COUNT(*) AS total + FROM exam_leaderboard + WHERE exam_id = $1 + """, + exam_id, + score, + ) + if not row or (row["total"] or 0) < 5: + return {"percentile": None, "total": row["total"] if row else 0} + percentile = round(100 * (row["at_or_below"] or 0) / row["total"]) + return {"percentile": percentile, "total": row["total"]} + + +@app.get("/users/me/history") +async def get_history( + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + rows = await pool.fetch( + """ + SELECT result_id, exam_id, score, payload, created_at + FROM exam_results + WHERE user_id = $1 + ORDER BY created_at DESC + """, + current_user.user_id, + ) + return [ + { + "result_id": r["result_id"], + "exam_id": r["exam_id"], + "score": r["score"], + "payload": r["payload"], + "created_at": r["created_at"] if isinstance(r["created_at"], str) else (r["created_at"].isoformat() if r["created_at"] else None), + } + for r in rows + ] + + +class QuestionReportRequest(BaseModel): + reason: str + + +@app.get("/exams") +async def list_exams(mode: str | None = None, pool=Depends(get_pool)): + if mode: + rows = await pool.fetch( + "SELECT id,year,title,duration,source,category,mode,total_questions FROM exams WHERE mode=? ORDER BY year DESC", + mode, + ) + else: + rows = await pool.fetch( + "SELECT id,year,title,duration,source,category,mode,total_questions FROM exams WHERE mode!='retired' ORDER BY year DESC" + ) + return [dict(r) for r in rows] + + +@app.get("/exams/{exam_id}") +async def get_exam(exam_id: str, pool=Depends(get_pool)): + exam = await pool.fetchrow("SELECT * FROM exams WHERE id=?", exam_id) + if not exam: + raise HTTPException(status_code=404, detail="Exam not found") + q_ids = await pool.fetch( + "SELECT question_id FROM exam_questions WHERE exam_id=? ORDER BY position", exam_id + ) + return {**dict(exam), "questionIds": [r["question_id"] for r in q_ids]} + + +@app.post("/questions/batch") +async def batch_questions( + body: dict, + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + ids = body.get("ids", [])[:200] + if not ids: + return [] + placeholders = ",".join("?" * len(ids)) + rows = await pool.fetch(f"SELECT * FROM questions WHERE id IN ({placeholders})", *ids) + return [{**dict(r), "choices": json.loads(r["choices"])} for r in rows] + + +@app.get("/questions") +async def all_questions( + topic: str | None = None, + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + if topic: + rows = await pool.fetch("SELECT * FROM questions WHERE topic=?", topic) + else: + rows = await pool.fetch("SELECT * FROM questions") + return [{**dict(r), "choices": json.loads(r["choices"])} for r in rows] + + +@app.post("/questions/{question_id}/report", status_code=204) +async def report_question( + question_id: str, + body: QuestionReportRequest, + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + if not body.reason.strip(): + raise HTTPException(status_code=422, detail="reason cannot be blank") + await pool.execute( + "INSERT INTO question_reports (question_id, user_id, reason) VALUES (?, ?, ?)", + question_id, current_user.user_id, body.reason[:500], + ) + + +@app.post("/users/me/trial", status_code=204) +async def activate_trial( + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + row = await pool.fetchrow( + "SELECT subscription_tier, trial_used FROM users WHERE id = ?", current_user.user_id + ) + if not row: + raise HTTPException(status_code=404, detail="User not found") + if row["trial_used"]: + raise HTTPException(status_code=409, detail="Trial already used") + if row["subscription_tier"] != "basic": + raise HTTPException(status_code=409, detail="Trial only available on Basic tier") + await pool.execute( + """UPDATE users SET + subscription_tier = 'student', + trial_used = 1, + trial_expires_at = datetime('now', '+7 days'), + credits_balance = credits_balance + 500, + credits_reset_at = datetime('now', '+7 days'), + updated_at = datetime('now') + WHERE id = ?""", + current_user.user_id, + ) + + +class DeleteAccountRequest(BaseModel): + confirm_email: str + + +@app.delete("/users/me", status_code=204) +async def delete_account( + body: DeleteAccountRequest, + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + row = await pool.fetchrow( + "SELECT email, google_sub, trial_used FROM users WHERE id = ?", + current_user.user_id, + ) + if not row: + raise HTTPException(status_code=404, detail="User not found") + if row["email"] != body.confirm_email: + raise HTTPException(status_code=400, detail="Email confirmation does not match") + # Preserve trial status so re-registration cannot claim another trial + await pool.execute( + "INSERT OR REPLACE INTO deleted_google_subs (google_sub, trial_used) VALUES (?, ?)", + row["google_sub"], row["trial_used"], + ) + await pool.execute("DELETE FROM users WHERE id = ?", current_user.user_id) + from app.dependencies import invalidate_account_cache + invalidate_account_cache(current_user.user_id) + + +@app.post("/users/me/deactivate", status_code=204) +async def deactivate_account( + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + await pool.execute( + "UPDATE users SET is_deactivated = 1, deactivated_at = datetime('now'), updated_at = datetime('now') WHERE id = ?", + current_user.user_id, + ) + from app.dependencies import invalidate_account_cache + invalidate_account_cache(current_user.user_id) + + +@app.post("/users/me/reactivate", status_code=204) +async def reactivate_account( + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + await pool.execute( + "UPDATE users SET is_deactivated = 0, deactivated_at = NULL, updated_at = datetime('now') WHERE id = ?", + current_user.user_id, + ) + from app.dependencies import invalidate_account_cache + invalidate_account_cache(current_user.user_id) + + +# ── Class / teacher mode ───────────────────────────────────────────────────── + +class CreateClassRequest(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + + +class JoinClassRequest(BaseModel): + code: str = Field(..., min_length=1, max_length=30) + + +@app.post("/classes", status_code=201) +async def create_class( + body: CreateClassRequest, + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + import secrets as _secrets + code = _secrets.token_urlsafe(8) + row = await pool.fetchrow( + "INSERT INTO classes (teacher_id, code, name) VALUES ($1, $2, $3) RETURNING id, code, name", + current_user.user_id, code, body.name, + ) + return {"id": row["id"], "code": row["code"], "name": row["name"]} + + +@app.post("/classes/join", status_code=204) +async def join_class( + body: JoinClassRequest, + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + # Always return 204 — do not distinguish "not found" from "full" to prevent enumeration + cls = await pool.fetchrow( + "SELECT id, teacher_id, max_students, active FROM classes WHERE code = $1", body.code + ) + if not cls or not cls["active"] or cls["teacher_id"] == current_user.user_id: + return # silently ignore invalid/expired/self-join + + member_count = await pool.fetchrow( + "SELECT COUNT(*) AS cnt FROM class_members WHERE class_id = $1", cls["id"] + ) + if (member_count["cnt"] or 0) >= cls["max_students"]: + return # silently ignore full class + + try: + await pool.execute( + "INSERT INTO class_members (class_id, student_id) VALUES ($1, $2) ON CONFLICT DO NOTHING", + cls["id"], current_user.user_id, + ) + except Exception: + pass + + +@app.get("/classes") +async def list_my_classes( + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + rows = await pool.fetch( + """SELECT c.id, c.code, c.name, c.created_at, + (SELECT COUNT(*) FROM class_members WHERE class_id = c.id) AS member_count + FROM classes c WHERE c.teacher_id = $1 AND c.active = 1 ORDER BY c.created_at DESC""", + current_user.user_id, + ) + return [dict(r) for r in rows] + + +@app.get("/classes/{class_id}/results") +async def class_results( + class_id: int, + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + # Ownership check — only the teacher may access full results + cls = await pool.fetchrow("SELECT teacher_id FROM classes WHERE id = $1", class_id) + if not cls: + raise HTTPException(status_code=404, detail="Class not found") + if cls["teacher_id"] != current_user.user_id: + raise HTTPException(status_code=403, detail="Forbidden") + + rows = await pool.fetch( + """SELECT u.display_name, u.email, + er.exam_id, er.score, er.created_at + FROM class_members cm + JOIN users u ON u.id = cm.student_id + LEFT JOIN exam_results er ON er.user_id = cm.student_id + WHERE cm.class_id = $1 + ORDER BY u.display_name, er.created_at DESC""", + class_id, + ) + # Group by student + students = {} + for r in rows: + key = r["email"] + if key not in students: + students[key] = {"display_name": r["display_name"], "email": r["email"], "results": []} + if r["exam_id"]: + students[key]["results"].append({ + "exam_id": r["exam_id"], "score": r["score"], "created_at": r["created_at"] + }) + return list(students.values()) + + +@app.post("/classes/{class_id}/deactivate", status_code=204) +async def deactivate_class( + class_id: int, + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + cls = await pool.fetchrow("SELECT teacher_id FROM classes WHERE id = $1", class_id) + if not cls: + raise HTTPException(status_code=404, detail="Class not found") + if cls["teacher_id"] != current_user.user_id: + raise HTTPException(status_code=403, detail="Forbidden") + await pool.execute("UPDATE classes SET active = 0 WHERE id = $1", class_id) + + +# ── Sprint 19: Teacher class join & rank endpoints ──────────────────────────── + +class TeacherClassJoinRequest(BaseModel): + class_code: str = Field(..., min_length=1, max_length=10) + + +@app.post("/teacher-classes/join") +async def teacher_class_join( + body: TeacherClassJoinRequest, + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + """Join a teacher class by its 6-char class_code.""" + cls = await pool.fetchrow( + "SELECT id, teacher_name, subject FROM teacher_classes WHERE class_code = ?", + body.class_code.upper(), + ) + if not cls: + raise HTTPException(status_code=404, detail="Class not found") + + await pool.execute( + "INSERT OR IGNORE INTO teacher_class_members (class_id, user_id) VALUES (?, ?)", + cls["id"], current_user.user_id, + ) + member_count_row = await pool.fetchrow( + "SELECT COUNT(*) AS cnt FROM teacher_class_members WHERE class_id = ?", cls["id"] + ) + member_count = member_count_row["cnt"] if member_count_row else 0 + return { + "class_id": cls["id"], + "teacher_name": cls["teacher_name"], + "subject": cls["subject"], + "member_count": member_count, + } + + +@app.get("/teacher-classes/me") +async def teacher_class_me( + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + """Return the class the current user belongs to plus their rank. 200 with class_id=null if not enrolled.""" + membership = await pool.fetchrow( + """SELECT tc.id, tc.class_code, tc.teacher_name, tc.subject + FROM teacher_class_members tcm + JOIN teacher_classes tc ON tc.id = tcm.class_id + WHERE tcm.user_id = ?""", + current_user.user_id, + ) + if not membership: + return {"class_id": None} + + class_id = membership["id"] + + # All members with their avg score + members = await pool.fetch( + """SELECT tcm.user_id, + COALESCE(AVG(er.score), 0.0) AS avg_score + FROM teacher_class_members tcm + LEFT JOIN exam_results er ON er.user_id = tcm.user_id + WHERE tcm.class_id = ? + GROUP BY tcm.user_id + ORDER BY avg_score DESC""", + class_id, + ) + + member_count = len(members) + your_rank = 1 + your_avg = 0.0 + class_total = 0.0 + + for i, m in enumerate(members): + if m["user_id"] == current_user.user_id: + your_rank = i + 1 + your_avg = m["avg_score"] + class_total += m["avg_score"] + + class_avg = class_total / member_count if member_count else 0.0 + + return { + "class_id": class_id, + "class_code": membership["class_code"], + "teacher_name": membership["teacher_name"], + "subject": membership["subject"], + "member_count": member_count, + "your_rank": your_rank, + "your_avg_score": round(your_avg, 2), + "class_avg_score": round(class_avg, 2), + } + + +# ── MOAT 5: Study Partner Matching ─────────────────────────────────────────── + +class ConnectPartnerRequest(BaseModel): + partner_id: int + +class RespondPartnerRequest(BaseModel): + request_id: int + action: str # 'accept' | 'decline' + + +def _partner_display_name(province: str | None) -> str: + return f"Học sinh {province}" if province else "Học sinh" + + +@app.get("/study-partners/candidates") +async def get_partner_candidates( + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + """Find up to 3 study partner candidates. Complete tier only. FREE.""" + user_row = await pool.fetchrow( + "SELECT grade, province, subscription_tier FROM users WHERE id = ?", + current_user.user_id, + ) + if not user_row or user_row["subscription_tier"] != "complete": + raise HTTPException(status_code=403, detail="complete_tier_required") + + grade = user_row["grade"] + province = user_row["province"] + + # Compute current user's avg score + self_avg_row = await pool.fetchrow( + "SELECT AVG(score) AS avg FROM exam_results WHERE user_id = ? AND score IS NOT NULL", + current_user.user_id, + ) + self_avg = self_avg_row["avg"] if self_avg_row and self_avg_row["avg"] is not None else None + + # Build province filter clause + if province: + province_clause = "AND u.province = ?" + province_params = [province] + else: + province_clause = "" + province_params = [] + + query = f""" + SELECT + u.id AS partner_id, + u.province, + u.grade, + AVG(er.score) AS avg_score, + COUNT(er.result_id) AS exam_count + FROM users u + LEFT JOIN exam_results er ON er.user_id = u.id AND er.score IS NOT NULL + WHERE u.grade = ? + {province_clause} + AND u.subscription_tier IN ('student', 'complete') + AND u.id != ? + AND u.id NOT IN ( + SELECT partner_id FROM study_partner_requests + WHERE requester_id = ? AND status = 'accepted' + UNION + SELECT requester_id FROM study_partner_requests + WHERE partner_id = ? AND status = 'accepted' + ) + GROUP BY u.id + LIMIT 20 + """ + params = [grade] + province_params + [current_user.user_id, current_user.user_id, current_user.user_id] + rows = await pool.fetch(query, *params) + + # Sort by closeness to self_avg; if no score data, keep original order + def _sort_key(r): + avg = r["avg_score"] + if self_avg is not None and avg is not None: + return abs(avg - self_avg) + return 0 + + sorted_rows = sorted(rows, key=_sort_key)[:3] + + candidates = [] + for r in sorted_rows: + avg = r["avg_score"] + score_diff = round(abs(avg - self_avg), 1) if (avg is not None and self_avg is not None) else None + candidates.append({ + "partner_id": r["partner_id"], + "display_name": _partner_display_name(r["province"]), + "grade": r["grade"], + "province": r["province"], + "avg_score": round(avg, 1) if avg is not None else None, + "exam_count": r["exam_count"] or 0, + "score_diff": score_diff, + }) + + return {"candidates": candidates} + + +@app.post("/study-partners/connect") +async def connect_partner( + req: ConnectPartnerRequest, + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + """Send a study partner connection request. Auth required.""" + partner = await pool.fetchrow("SELECT id FROM users WHERE id = ?", req.partner_id) + if not partner: + raise HTTPException(status_code=404, detail="partner_not_found") + + await pool.execute( + "INSERT OR IGNORE INTO study_partner_requests (requester_id, partner_id, status) VALUES (?, ?, 'pending')", + current_user.user_id, req.partner_id, + ) + return {"status": "pending", "partner_id": req.partner_id} + + +@app.get("/study-partners/me") +async def get_my_partners( + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + """Return accepted connections + pending requests for current user.""" + uid = current_user.user_id + + # Accepted: either requester or partner side + accepted_rows = await pool.fetch( + """SELECT spr.id AS request_id, + CASE WHEN spr.requester_id = ? THEN spr.partner_id ELSE spr.requester_id END AS partner_id, + u.grade, u.province, + AVG(er.score) AS avg_score + FROM study_partner_requests spr + JOIN users u ON u.id = CASE WHEN spr.requester_id = ? THEN spr.partner_id ELSE spr.requester_id END + LEFT JOIN exam_results er ON er.user_id = u.id AND er.score IS NOT NULL + WHERE (spr.requester_id = ? OR spr.partner_id = ?) AND spr.status = 'accepted' + GROUP BY spr.id, partner_id, u.grade, u.province""", + uid, uid, uid, uid, + ) + + # Pending sent + pending_sent_rows = await pool.fetch( + """SELECT spr.partner_id, u.grade, u.province + FROM study_partner_requests spr + JOIN users u ON u.id = spr.partner_id + WHERE spr.requester_id = ? AND spr.status = 'pending'""", + uid, + ) + + # Pending received + pending_received_rows = await pool.fetch( + """SELECT spr.id AS request_id, spr.requester_id AS partner_id, u.grade, u.province + FROM study_partner_requests spr + JOIN users u ON u.id = spr.requester_id + WHERE spr.partner_id = ? AND spr.status = 'pending'""", + uid, + ) + + def _fmt_accepted(r): + avg = r["avg_score"] + return { + "partner_id": r["partner_id"], + "display_name": _partner_display_name(r["province"]), + "grade": r["grade"], + "province": r["province"], + "avg_score": round(avg, 1) if avg is not None else None, + } + + def _fmt_pending(r): + return { + "partner_id": r["partner_id"], + "display_name": _partner_display_name(r["province"]), + } + + def _fmt_received(r): + return { + "partner_id": r["partner_id"], + "display_name": _partner_display_name(r["province"]), + "request_id": r["request_id"], + } + + return { + "accepted": [_fmt_accepted(r) for r in accepted_rows], + "pending_sent": [_fmt_pending(r) for r in pending_sent_rows], + "pending_received": [_fmt_received(r) for r in pending_received_rows], + } + + +@app.post("/study-partners/respond") +async def respond_to_partner( + req: RespondPartnerRequest, + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + """Accept or decline an incoming partner request.""" + if req.action not in ("accept", "decline"): + raise HTTPException(status_code=422, detail="action must be 'accept' or 'decline'") + + row = await pool.fetchrow( + "SELECT id FROM study_partner_requests WHERE id = ? AND partner_id = ?", + req.request_id, current_user.user_id, + ) + if not row: + raise HTTPException(status_code=404, detail="request_not_found") + + new_status = "accepted" if req.action == "accept" else "declined" + await pool.execute( + "UPDATE study_partner_requests SET status = ? WHERE id = ?", + new_status, req.request_id, + ) + return {"status": new_status, "request_id": req.request_id} + + +# ── Adaptive practice ───────────────────────────────────────────────────────── + +class AdaptivePracticeRequest(BaseModel): + weak_topics: list[str] = Field(default_factory=list, max_length=10) + grade: str = Field(..., pattern=r"^(9|10|11|12)$") + count: int = Field(default=5, ge=1, le=20) + + +@app.post("/adaptive-practice") +async def adaptive_practice( + req: AdaptivePracticeRequest, + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + from app.math_wiki.taxonomy import CANONICAL_TOPICS + # Allowlist weak_topics — reject any unknown topic slug + invalid = [t for t in req.weak_topics if t not in CANONICAL_TOPICS] + if invalid: + raise HTTPException(status_code=422, detail=f"Unknown topic(s): {invalid!r}. Must be one of the canonical topic slugs.") + + cost = req.count + await _spend_credits(pool, current_user.user_id, cost, "adaptive_practice") + + client = get_ai_client() + settings = get_settings() + topics_str = ", ".join(req.weak_topics) if req.weak_topics else "mixed" + prompt = ( + f"Generate {req.count} multiple-choice math practice questions for a grade {req.grade} Vietnamese student. " + f"Focus on these weak topics: {topics_str}. " + "Return a JSON array of objects with keys: " + '{"id": "ap_", "question": "question text (in Vietnamese)", "choices": ["A","B","C","D"], "correct": 0, "topic": "", "difficulty": "medium", "explanation": "step-by-step solution"}. ' + "Choices must be 4 strings. correct is the 0-based index of the correct choice. " + "Use LaTeX notation for math expressions. Return ONLY the JSON array, no markdown fences." + ) + try: + response = await client.chat.completions.create( + model=settings.default_model, + messages=[{"role": "user", "content": prompt}], + max_tokens=4096, + temperature=0.7, + ) + raw = (response.choices[0].message.content or "").strip() + if raw.startswith("```"): + raw = raw.split("\n", 1)[-1].rsplit("```", 1)[0].strip() + questions = json.loads(raw) + if not isinstance(questions, list): + raise ValueError("Expected a JSON array") + except Exception as exc: + logger.error("adaptive-practice generation failed: %s", exc) + raise HTTPException(status_code=502, detail=f"Question generation failed: {exc}") + + return {"questions": questions} + + +# ── Admin endpoints ─────────────────────────────────────────────────────────── + +def _require_admin(request: Request): + settings = get_settings() + key = request.headers.get("x-admin-key", "") + # Use HMAC-derived rotating key if admin_master_secret is set; fall back to static admin_key + if settings.admin_master_secret: + if not validate_admin_key(key, settings.admin_master_secret, settings.admin_key_rotation_period): + request.state.admin_key_failed = True + raise HTTPException(status_code=401, detail="Invalid or missing admin key") + else: + if not settings.admin_key or key != settings.admin_key: + request.state.admin_key_failed = True + raise HTTPException(status_code=401, detail="Invalid or missing admin key") + + +@app.post("/admin/generate-key-log", status_code=200) +async def generate_key_log(request: Request): + import datetime as _dt + import hmac as _hmac + settings = get_settings() + cron_secret = settings.cron_secret or "" + provided = request.headers.get("x-cron-secret", "") + if not cron_secret or not _hmac.compare_digest(provided.encode(), cron_secret.encode()): + raise HTTPException(status_code=401, detail="Invalid or missing cron secret") + if not settings.admin_master_secret or not settings.admin_key_log_enabled: + return {"status": "disabled"} + + period = settings.admin_key_rotation_period + current_label = get_window_label(period, offset=0) + next_label = get_window_label(period, offset=-1) + current_key = derive_key(settings.admin_master_secret, current_label) + next_key = derive_key(settings.admin_master_secret, next_label) + expiry = get_expiry_date(period) + + ict = _dt.timezone(_dt.timedelta(hours=7)) + ts = _dt.datetime.now(ict).strftime("%Y-%m-%d %H:%M:%S") + log_line = ( + f"{ts} | window={current_label} | key={current_key} | expires={expiry} " + f"| next_window={next_label} | next_key={next_key}\n" + ) + + log_path = Path(settings.admin_key_log_path) + try: + log_path.parent.mkdir(parents=True, exist_ok=True) + with open(log_path, "a") as f: + f.write(log_line) + except OSError as e: + logger.error("Failed to write admin key log: %s", e) + raise HTTPException(status_code=500, detail="Failed to write key log") + + if settings.admin_key_webhook_url: + try: + import urllib.request as _urlreq + payload = json.dumps({"window": current_label, "key": current_key, "expires": expiry}).encode() + req = _urlreq.Request(settings.admin_key_webhook_url, data=payload, + headers={"Content-Type": "application/json"}, method="POST") + _urlreq.urlopen(req, timeout=5) + except Exception as e: + logger.warning("Admin key webhook failed: %s", e) + + return {"status": "ok", "window": current_label, "expires": expiry} + + +class SubscriptionUpdate(BaseModel): + tier: str + period: str = "monthly" + expires_at: str | None = None + bonus_credits: int = 0 + + +class CreditGrant(BaseModel): + amount: int + reason: str = "admin_grant" + + +class ClassifyErrorRequest(BaseModel): + question: str + wrong_choice: str + correct_choice: str + concept_id: str | None = None # optional; when set, error is persisted to error_patterns + + +@app.post("/classify-error") +async def classify_error( + req: ClassifyErrorRequest, + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + """Classify the error type for a wrong answer using Haiku; persist to error_patterns.""" + client = get_ai_client() + settings = get_settings() + prompt = ( + f"Câu hỏi: {req.question[:300]}\n" + f"Học sinh chọn: {req.wrong_choice[:150]}\n" + f"Đáp án đúng: {req.correct_choice[:150]}\n\n" + "Phân loại lỗi sai này CHÍNH XÁC một trong các loại sau:\n" + "sign_error, formula_confusion, procedural_slip, conceptual_gap, calculation\n" + "Chỉ trả lời DUY NHẤT tên loại lỗi, không giải thích." + ) + try: + response = await client.chat.completions.create( + model=settings.haiku_model, + max_tokens=20, + messages=[{"role": "user", "content": prompt}], + ) + category = response.choices[0].message.content.strip().lower() + valid = {"sign_error", "formula_confusion", "procedural_slip", "conceptual_gap", "calculation"} + if category not in valid: + category = "procedural_slip" + except Exception: + return {"category": None, "confidence": 0.0} + + # Persist to error_patterns (upsert: increment count on conflict) + try: + await pool.execute( + """INSERT INTO error_patterns (user_id, concept_id, error_type, count, last_seen) + VALUES ($1, $2, $3, 1, datetime('now')) + ON CONFLICT(user_id, concept_id, error_type) + DO UPDATE SET count = count + 1, last_seen = datetime('now')""", + current_user.user_id, req.concept_id, category, + ) + except Exception: + pass # non-fatal — classification still returns + + return {"category": category, "confidence": 0.8} + + +class SuspendRequest(BaseModel): + reason: str + + +@app.post("/admin/users/{user_id}/subscription", status_code=204) +async def admin_set_subscription( + user_id: int, + body: SubscriptionUpdate, + request: Request, + pool=Depends(get_pool), +): + _require_admin(request) + valid_tiers = {"basic", "student", "complete"} + valid_periods = {"monthly", "annual"} + if body.tier not in valid_tiers: + raise HTTPException(status_code=422, detail=f"tier must be one of {sorted(valid_tiers)}") + if body.period not in valid_periods: + raise HTTPException(status_code=422, detail=f"period must be one of {sorted(valid_periods)}") + await pool.execute( + """UPDATE users SET subscription_tier = ?, subscription_period = ?, + subscription_expires_at = ?, + credits_balance = credits_balance + ?, + updated_at = datetime('now') + WHERE id = ?""", + body.tier, body.period, body.expires_at, body.bonus_credits, user_id, + ) + if body.bonus_credits: + await pool.execute( + "INSERT INTO ai_credits_log (user_id, delta, reason) VALUES (?, ?, ?)", + user_id, body.bonus_credits, f"subscription_bonus_{body.tier}", + ) + + +@app.post("/admin/users/{user_id}/credits", status_code=204) +async def admin_grant_credits( + user_id: int, + body: CreditGrant, + request: Request, + pool=Depends(get_pool), +): + _require_admin(request) + if body.amount <= 0: + raise HTTPException(status_code=422, detail="amount must be positive") + await pool.execute( + "UPDATE users SET credits_balance = credits_balance + ?, updated_at = datetime('now') WHERE id = ?", + body.amount, user_id, + ) + await pool.execute( + "INSERT INTO ai_credits_log (user_id, delta, reason) VALUES (?, ?, ?)", + user_id, body.amount, body.reason, + ) + + +@app.post("/admin/users/{user_id}/suspend", status_code=204) +async def admin_suspend_user( + user_id: int, + body: SuspendRequest, + request: Request, + pool=Depends(get_pool), +): + _require_admin(request) + await pool.execute( + "UPDATE users SET is_suspended = 1, suspension_reason = ?, updated_at = datetime('now') WHERE id = ?", + body.reason, user_id, + ) + await pool.execute( + "INSERT INTO security_events (user_id, event_type, confidence, detail) VALUES (?, ?, ?, ?)", + user_id, "manual_suspend", "high", body.reason, + ) + + +@app.post("/admin/users/{user_id}/unsuspend", status_code=204) +async def admin_unsuspend_user( + user_id: int, + request: Request, + pool=Depends(get_pool), +): + _require_admin(request) + await pool.execute( + "UPDATE users SET is_suspended = 0, suspension_reason = NULL, updated_at = datetime('now') WHERE id = ?", + user_id, + ) + await pool.execute( + "INSERT INTO security_events (user_id, event_type, confidence, detail) VALUES (?, ?, ?, ?)", + user_id, "manual_unsuspend", "low", "admin unsuspend", + ) + + +@app.get("/admin/security-events") +async def admin_security_events( + request: Request, + limit: int = 50, + pool=Depends(get_pool), +): + _require_admin(request) + limit = max(1, min(limit, 500)) + rows = await pool.fetch( + """SELECT se.id, se.user_id, se.ip, se.event_type, se.confidence, se.detail, se.created_at, + u.email, u.is_suspended + FROM security_events se + LEFT JOIN users u ON u.id = se.user_id + WHERE se.confidence IN ('high', 'medium') + ORDER BY se.created_at DESC + LIMIT ?""", + limit, + ) + return [dict(r) for r in rows] + + +@app.get("/admin/users") +async def admin_list_users( + request: Request, + search: str = "", + page: int = 1, + limit: int = 20, + pool=Depends(get_pool), +): + _require_admin(request) + limit = max(1, min(limit, 100)) + page = max(1, page) + offset = (page - 1) * limit + if search: + _pat = f"%{search}%" + total_row = await pool.fetchrow( + "SELECT COUNT(*) AS cnt FROM users WHERE email LIKE ? OR display_name LIKE ?", + _pat, _pat, + ) + else: + total_row = await pool.fetchrow("SELECT COUNT(*) AS cnt FROM users") + total = total_row["cnt"] if total_row else 0 + behavior_subq = """ + LEFT JOIN ( + SELECT user_id, + CAST(json_extract(payload, '$.tab_switches') AS INTEGER) AS last_tab_switches, + CAST(json_extract(payload, '$.devtools_detected') AS INTEGER) AS last_devtools + FROM exam_results + WHERE rowid IN (SELECT MAX(rowid) FROM exam_results GROUP BY user_id) + ) beh ON beh.user_id = u.id + """ + device_subq = """ + LEFT JOIN ( + SELECT user_id, ip, city, province, country_code, device_label + FROM user_devices + WHERE rowid IN (SELECT MAX(rowid) FROM user_devices GROUP BY user_id) + ) dev ON dev.user_id = u.id + """ + if search: + pattern = f"%{search}%" + rows = await pool.fetch( + f"""SELECT u.id, u.email, u.display_name, u.subscription_tier, u.credits_balance, + u.is_suspended, u.suspension_reason, u.is_locked, u.lock_reason, + u.is_deactivated, u.trial_used, u.created_at, u.grade, + u.last_seen_at, u.pending_deletion_at, + beh.last_tab_switches, beh.last_devtools, + dev.ip, dev.city, dev.province, dev.country_code, dev.device_label + FROM users u {behavior_subq} {device_subq} + WHERE u.email LIKE ? OR u.display_name LIKE ? + ORDER BY u.created_at DESC LIMIT ? OFFSET ?""", + pattern, pattern, limit, offset, + ) + else: + rows = await pool.fetch( + f"""SELECT u.id, u.email, u.display_name, u.subscription_tier, u.credits_balance, + u.is_suspended, u.suspension_reason, u.is_locked, u.lock_reason, + u.is_deactivated, u.trial_used, u.created_at, u.grade, + u.last_seen_at, u.pending_deletion_at, + beh.last_tab_switches, beh.last_devtools, + dev.ip, dev.city, dev.province, dev.country_code, dev.device_label + FROM users u {behavior_subq} {device_subq} + ORDER BY u.created_at DESC LIMIT ? OFFSET ?""", + limit, offset, + ) + return {"users": [dict(r) for r in rows], "total": total} + + +@app.get("/admin/users/{user_id}/devices") +async def admin_get_user_devices(user_id: int, request: Request, pool=Depends(get_pool)): + _require_admin(request) + rows = await pool.fetch( + """SELECT device_id, device_label, ip, city, province, country, country_code, + first_seen_at, last_seen_at + FROM user_devices WHERE user_id = ? ORDER BY last_seen_at DESC""", + user_id, + ) + return [dict(r) for r in rows] + + +@app.delete("/admin/users/{user_id}", status_code=204) +async def admin_delete_user( + user_id: int, + request: Request, + pool=Depends(get_pool), +): + _require_admin(request) + row = await pool.fetchrow("SELECT google_sub, trial_used FROM users WHERE id = ?", user_id) + if row: + await pool.execute( + "INSERT OR REPLACE INTO deleted_google_subs (google_sub, trial_used) VALUES (?, ?)", + row["google_sub"], row["trial_used"], + ) + await pool.execute("DELETE FROM users WHERE id = ?", user_id) + from app.dependencies import invalidate_account_cache + invalidate_account_cache(user_id) + + +@app.post("/admin/users/{user_id}/unlock", status_code=204) +async def admin_unlock_user( + user_id: int, + request: Request, + pool=Depends(get_pool), +): + _require_admin(request) + await pool.execute( + "UPDATE users SET is_locked = 0, lock_reason = NULL, updated_at = datetime('now') WHERE id = ?", + user_id, + ) + await pool.execute( + "INSERT INTO security_events (user_id, event_type, confidence, detail) VALUES (?, ?, ?, ?)", + user_id, "manual_unlock", "low", "admin unlocked account", + ) + from app.dependencies import invalidate_account_cache + invalidate_account_cache(user_id) + + +@app.post("/admin/users/{user_id}/reset", status_code=204) +async def admin_reset_user( + user_id: int, + request: Request, + pool=Depends(get_pool), +): + _require_admin(request) + row = await pool.fetchrow("SELECT google_sub FROM users WHERE id = ?", user_id) + if not row: + raise HTTPException(status_code=404, detail="User not found") + await pool.execute( + """UPDATE users SET + subscription_tier = 'basic', subscription_period = 'monthly', subscription_expires_at = NULL, + credits_balance = 50, credits_reset_at = NULL, + trial_used = 0, trial_expires_at = NULL, + grade = NULL, province = NULL, school_type = NULL, + tos_accepted_at = NULL, + is_suspended = 0, suspension_reason = NULL, + is_locked = 0, lock_reason = NULL, + is_deactivated = 0, deactivated_at = NULL, + updated_at = datetime('now') + WHERE id = ?""", + user_id, + ) + await pool.execute("DELETE FROM exam_results WHERE user_id = ?", user_id) + await pool.execute("DELETE FROM security_events WHERE user_id = ?", user_id) + await pool.execute("DELETE FROM ai_credits_log WHERE user_id = ?", user_id) + if row["google_sub"]: + await pool.execute( + "DELETE FROM deleted_google_subs WHERE google_sub = ?", row["google_sub"] + ) + await pool.execute( + "INSERT INTO security_events (user_id, event_type, confidence, detail) VALUES (?, ?, ?, ?)", + user_id, "admin_reset", "low", "full account reset by admin", + ) + from app.dependencies import invalidate_account_cache + invalidate_account_cache(user_id) + + +# ─── Exam-day simulation brief ─────────────────────────────────────────────── + +# Static prefix-cache-friendly system prompt +_SIMULATION_BRIEF_SYSTEM = ( + "You are a Vietnamese exam coach. The student is preparing for THPT. " + "Write a 2-sentence motivational briefing in Vietnamese that is specific to their situation. " + "Be direct and action-oriented. No markdown." +) + + +class SimulationBriefRequest(BaseModel): + days_until_exam: int + projected_score: float | None = None + target_score: float | None = None + weak_topics: list[str] = [] + exam_count: int = 0 + + +class SimulationBriefResponse(BaseModel): + briefing: str + + +@app.post("/insights/simulation-brief", response_model=SimulationBriefResponse) +async def simulation_brief( + req: SimulationBriefRequest, + client: AsyncOpenAI = Depends(get_ai_client), + current_user: CurrentUser = Depends(get_current_user), +): + """Generate a free Haiku-powered daily simulation briefing in Vietnamese. + Auth required. No credit deduction — this endpoint is FREE. + """ + from app.agent.core import call_with_retry + + settings = get_settings() + + weak_topics_str = ", ".join(req.weak_topics[:3]) if req.weak_topics else "chưa xác định" + score_info = ( + f"projected_score={req.projected_score:.1f}" if req.projected_score is not None else "no_score_data" + ) + target_info = ( + f"target_score={req.target_score:.1f}" if req.target_score is not None else "no_target" + ) + + user_content = ( + f"days_until_exam={req.days_until_exam}, " + f"{score_info}, {target_info}, " + f"weak_topics=[{weak_topics_str}], " + f"total_exams_completed={req.exam_count}" + ) + + def _fallback() -> SimulationBriefResponse: + first_topic = req.weak_topics[0] if req.weak_topics else "các chủ đề yếu" + text = ( + f"Còn {req.days_until_exam} ngày — mỗi buổi thi thử hôm nay đều quan trọng. " + f"Tập trung vào {first_topic} để tăng điểm nhanh nhất." + ) + return SimulationBriefResponse(briefing=text) + + try: + resp = await call_with_retry( + client, + model=settings.haiku_model, + max_tokens=150, + messages=[ + {"role": "system", "content": _SIMULATION_BRIEF_SYSTEM}, + {"role": "user", "content": user_content}, + ], + ) + text = (resp.choices[0].message.content or "").strip() + if not text: + return _fallback() + return SimulationBriefResponse(briefing=text) + except Exception as exc: + logger.warning("simulation_brief failed: %s", exc) + return _fallback() + + +@app.get("/student/concept-mastery") +async def student_concept_mastery( + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + """Return per-concept mastery scores for the current user (0.0 = no mastery, 1.0 = full mastery).""" + from app.math_wiki.dkvmn import predict_mastery + mastery = await predict_mastery(pool, current_user.user_id) + return {"user_id": current_user.user_id, "mastery": mastery, "concept_count": len(mastery)} + + +@app.get("/student/recommended-exercises") +async def student_recommended_exercises( + top_k: int = 5, + current_user: CurrentUser = Depends(get_current_user), + pool=Depends(get_pool), +): + """Return recommended exercises targeting the student's weakest concepts.""" + from app.math_wiki.kg_recommender import recommend_exercises + recs = await recommend_exercises(pool, current_user.user_id, top_k=top_k) + return {"user_id": current_user.user_id, "recommendations": recs} diff --git a/backend/app/math_wiki/__init__.py b/backend/app/math_wiki/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/app/math_wiki/admin_router.py b/backend/app/math_wiki/admin_router.py new file mode 100644 index 0000000000000000000000000000000000000000..6cea2433ed66795853850bef5de6695f1e149eb3 --- /dev/null +++ b/backend/app/math_wiki/admin_router.py @@ -0,0 +1,419 @@ +import json +import logging +from datetime import datetime, timezone +from fastapi import APIRouter, Depends, HTTPException, Header, Query, Request +from pydantic import BaseModel +from openai import AsyncOpenAI +from app.config import get_settings +from app.dependencies import get_ai_client +from app.math_wiki.storage import pg_db +from app.math_wiki.storage.analytics import get_retrieval_effectiveness, get_unit_usage_stats +from app.math_wiki.schemas import WikiUnit, StagedWikiUnit +import asyncio + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/admin", tags=["admin"]) + +# ── Crawl job state (in-process singleton, one crawl at a time) ─────────────── + +_crawl: dict = { + "running": False, + "started_at": None, + "finished_at": None, + "topics": [], + "sources": [], + "dry_run": False, + "stats": {}, + "current_topic": None, + "error": None, +} + + +def _check_admin_key(x_admin_key: str = Header(...)): + settings = get_settings() + if not settings.admin_key or x_admin_key != settings.admin_key: + raise HTTPException(status_code=401, detail="Invalid admin key") + + +def _get_pool(request: Request): + return request.app.state.pool + + +# ── Wiki Units ──────────────────────────────────────────────────────────────── + +@router.get("/units") +async def admin_list_units( + topic: str | None = Query(None), + source: str | None = Query(None), + include_deleted: bool = Query(False), + limit: int = Query(50, ge=1, le=200), + offset: int = Query(0, ge=0), + _: None = Depends(_check_admin_key), + pool=Depends(_get_pool), +): + units = await pg_db.list_wiki_units_admin( + pool, topic=topic, source=source, + include_deleted=include_deleted, + limit=limit, offset=offset, + ) + return {"units": units, "count": len(units)} + + +@router.get("/staged-units", response_model=list[StagedWikiUnit]) +async def admin_list_staged_units( + status: str = Query("pending"), + _: None = Depends(_check_admin_key), + pool=Depends(_get_pool), +): + return await pg_db.get_staged_wiki_units(pool, status=status) + + +@router.post("/staged-units/{unit_id}/approve") +async def admin_approve_staged_unit( + unit_id: str, + _: None = Depends(_check_admin_key), + pool=Depends(_get_pool), +): + try: + approved_unit = await pg_db.approve_staged_wiki_unit(pool, unit_id) + if approved_unit: + return {"status": "approved", "id": unit_id} + raise HTTPException(status_code=404, detail="Staged unit not found") + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.delete("/staged-units/{unit_id}") +async def admin_delete_staged_unit( + unit_id: str, + _: None = Depends(_check_admin_key), + pool=Depends(_get_pool), +): + try: + await pg_db.delete_staged_wiki_unit(pool, unit_id) + return {"status": "deleted", "id": unit_id} + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + +@router.get("/units/{unit_id}") +async def admin_get_unit( + unit_id: str, + include_history: bool = Query(False), + _: None = Depends(_check_admin_key), + pool=Depends(_get_pool), +): + result = await pg_db.get_wiki_unit_with_history(pool, unit_id) + if not result: + raise HTTPException(status_code=404, detail="Unit not found") + if not include_history: + result.pop("history", None) + return result + + +class UnitUpdateRequest(BaseModel): + content: str + editor: str = "admin" + reason: str | None = None + + +@router.put("/units/{unit_id}") +async def admin_update_unit( + unit_id: str, + req: UnitUpdateRequest, + _: None = Depends(_check_admin_key), + pool=Depends(_get_pool), +): + data = await pg_db.get_wiki_unit_with_history(pool, unit_id) + if not data: + raise HTTPException(status_code=404, detail="Unit not found") + row = data["unit"] + updated = WikiUnit( + id=row["id"], + type=row["type"], + topic=row["topic"], + subtopic=row["subtopic"], + content=req.content, + problem_ids=json.loads(row["problem_ids"]) if isinstance(row["problem_ids"], str) else row["problem_ids"], + ) + await pg_db.upsert_wiki_unit(pool, updated, source=row["source"], editor=req.editor, reason=req.reason) + return {"status": "updated", "id": unit_id} + + +@router.delete("/units/{unit_id}") +async def admin_delete_unit( + unit_id: str, + editor: str = Query("admin"), + _: None = Depends(_check_admin_key), + pool=Depends(_get_pool), +): + ok = await pg_db.soft_delete_wiki_unit(pool, unit_id, editor=editor) + if not ok: + raise HTTPException(status_code=404, detail="Unit not found") + return {"status": "deleted", "id": unit_id} + + +@router.post("/units/{unit_id}/restore") +async def admin_restore_unit( + unit_id: str, + version: int | None = Query(None), + editor: str = Query("admin"), + _: None = Depends(_check_admin_key), + pool=Depends(_get_pool), +): + ok = await pg_db.restore_wiki_unit(pool, unit_id, version=version, editor=editor) + if not ok: + raise HTTPException(status_code=404, detail="Unit or version not found") + return {"status": "restored", "id": unit_id} + + +# ── Feedback ────────────────────────────────────────────────────────────────── + +@router.get("/feedback") +async def admin_list_feedback( + unresolved_only: bool = Query(True), + _: None = Depends(_check_admin_key), + pool=Depends(_get_pool), +): + rows = await pg_db.list_feedback(pool, unresolved_only=unresolved_only) + return {"feedback": rows, "count": len(rows)} + + +@router.post("/feedback/{feedback_id}/resolve") +async def admin_resolve_feedback( + feedback_id: int, + _: None = Depends(_check_admin_key), + pool=Depends(_get_pool), +): + ok = await pg_db.resolve_feedback(pool, feedback_id) + if not ok: + raise HTTPException(status_code=404, detail="Feedback not found") + return {"status": "resolved", "id": feedback_id} + + +# ── Flagged solutions ───────────────────────────────────────────────────────── + +@router.get("/flagged") +async def admin_list_flagged( + unreviewed_only: bool = Query(True), + _: None = Depends(_check_admin_key), + pool=Depends(_get_pool), +): + rows = await pg_db.get_flagged_solutions(pool, unreviewed_only=unreviewed_only) + return {"flagged": rows, "count": len(rows)} + + +# ── Drafts ──────────────────────────────────────────────────────────────────── + +@router.get("/drafts") +async def admin_list_drafts( + status: str = Query("pending"), + _: None = Depends(_check_admin_key), + pool=Depends(_get_pool), +): + rows = await pg_db.list_drafts(pool, status=status) + return {"drafts": rows, "count": len(rows)} + + +class DraftReviewRequest(BaseModel): + decision: str # approve | reject | edit + reviewer: str = "admin" + edits: list[dict] | None = None + + +@router.post("/drafts/{draft_id}/review") +async def admin_review_draft( + draft_id: str, + req: DraftReviewRequest, + _: None = Depends(_check_admin_key), + pool=Depends(_get_pool), +): + try: + result = await pg_db.review_draft( + pool, + draft_id=draft_id, + decision=req.decision, + reviewer=req.reviewer, + edits=req.edits, + ) + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) + return result + + +# ── Source ingest → draft ───────────────────────────────────────────────────── + +class IngestSourceRequest(BaseModel): + text: str + source_url: str | None = None + topic_hint: str | None = None + + +@router.post("/ingest/source") +async def admin_ingest_source( + req: IngestSourceRequest, + client: AsyncOpenAI = Depends(get_ai_client), + _: None = Depends(_check_admin_key), + pool=Depends(_get_pool), +): + from app.math_wiki.agents.concept_ingest import concept_ingest + try: + output = await concept_ingest(client, req.text, pool=pool) + except Exception as exc: + raise HTTPException(status_code=502, detail=f"AI ingest failed: {exc}") + + draft_id = await pg_db.create_draft( + pool, + source_text=req.text, + source_url=req.source_url, + topic_hint=req.topic_hint, + proposed_units=output.wiki_units if hasattr(output, "wiki_units") else [], + ) + return { + "draft_id": draft_id, + "proposed_unit_count": len(output.wiki_units) if hasattr(output, "wiki_units") else 0, + } + + +# ── Analytics ───────────────────────────────────────────────────────────────── + +@router.get("/analytics") +async def admin_analytics( + days: int = Query(30, ge=1, le=365), + _: None = Depends(_check_admin_key), + pool=Depends(_get_pool), +): + return await get_retrieval_effectiveness(pool, days=days) + + +@router.get("/analytics/units/{unit_id}") +async def admin_unit_analytics( + unit_id: str, + days: int = Query(30, ge=1, le=365), + _: None = Depends(_check_admin_key), + pool=Depends(_get_pool), +): + all_stats = await get_unit_usage_stats(pool, days=days) + unit_stats = next((s for s in all_stats if s["unit_id"] == unit_id), None) + if not unit_stats: + return {"unit_id": unit_id, "times_used": 0, "message": "no data"} + return unit_stats + + +# ── Crawl trigger ───────────────────────────────────────────────────────────── + +class CrawlRequest(BaseModel): + gap_threshold: int = 50 # crawl topics with fewer units than this + sources: list[str] = ["aops", "pauls", "generic"] + dry_run: bool = False + + +async def _run_crawl(client, pool, topics: list[str], sources: list[str], dry_run: bool) -> None: + from crawl.runner import crawl_and_ingest + + _crawl["current_topic"] = None + combined: dict = { + "topics": len(topics), + "pages_fetched": 0, + "chunks_sent": 0, + "wiki_units_added": 0, + "skipped_seen": 0, + "errors": 0, + } + try: + for topic in topics: + _crawl["current_topic"] = topic + stats = await crawl_and_ingest( + client, topics=[topic], sources=sources, dry_run=dry_run, pool=pool + ) + for k in ("pages_fetched", "chunks_sent", "wiki_units_added", "skipped_seen", "errors"): + combined[k] = combined.get(k, 0) + stats.get(k, 0) + _crawl["stats"] = dict(combined) + logger.info("crawl [%s]: %s", topic, stats) + await asyncio.sleep(3) # inter-topic pause + except Exception as exc: + _crawl["error"] = str(exc) + logger.error("admin crawl failed: %s", exc) + finally: + _crawl["running"] = False + _crawl["finished_at"] = datetime.now(timezone.utc).isoformat() + _crawl["current_topic"] = None + _crawl["stats"] = dict(combined) + + +@router.post("/crawl") +async def admin_trigger_crawl( + req: CrawlRequest, + request: Request, + client: AsyncOpenAI = Depends(get_ai_client), + _: None = Depends(_check_admin_key), + pool=Depends(_get_pool), +): + if _crawl["running"]: + return { + "status": "already_running", + "started_at": _crawl["started_at"], + "current_topic": _crawl["current_topic"], + } + + from app.math_wiki.taxonomy import CANONICAL_TOPICS + + topic_counts = await pg_db.count_wiki_units_by_topic(pool) + gap_topics = [ + t for t in CANONICAL_TOPICS + if topic_counts.get(t, 0) < req.gap_threshold + ] + + if not gap_topics: + return {"status": "no_gaps", "message": f"All topics have ≥ {req.gap_threshold} units"} + + _crawl.update({ + "running": True, + "started_at": datetime.now(timezone.utc).isoformat(), + "finished_at": None, + "topics": gap_topics, + "sources": req.sources, + "dry_run": req.dry_run, + "stats": {}, + "current_topic": None, + "error": None, + }) + + asyncio.ensure_future(_run_crawl(client, pool, gap_topics, req.sources, req.dry_run)) + + return { + "status": "started", + "topics": gap_topics, + "gap_threshold": req.gap_threshold, + "sources": req.sources, + "dry_run": req.dry_run, + } + + +@router.get("/crawl/status") +async def admin_crawl_status(_: None = Depends(_check_admin_key)): + return { + "running": _crawl["running"], + "started_at": _crawl["started_at"], + "finished_at": _crawl["finished_at"], + "topics_queued": _crawl["topics"], + "current_topic": _crawl["current_topic"], + "sources": _crawl["sources"], + "dry_run": _crawl["dry_run"], + "stats": _crawl["stats"], + "error": _crawl["error"], + } + + +# ── Sanitize ────────────────────────────────────────────────────────────────── + +@router.post("/sanitize") +async def admin_sanitize( + dry_run: bool = Query(False, description="Report changes without applying them"), + _: None = Depends(_check_admin_key), + pool=Depends(_get_pool), +): + """Fix non-canonical topic/type labels and remove content-duplicate wiki units.""" + from app.math_wiki.storage.sanitizer import run_all + report = await run_all(pool, dry_run=dry_run) + return report diff --git a/backend/app/math_wiki/agents/__init__.py b/backend/app/math_wiki/agents/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/app/math_wiki/agents/classifier.py b/backend/app/math_wiki/agents/classifier.py new file mode 100644 index 0000000000000000000000000000000000000000..93c5ab196858f2876de24515d86c9af3fe07ab50 --- /dev/null +++ b/backend/app/math_wiki/agents/classifier.py @@ -0,0 +1,135 @@ +import json +from openai import AsyncOpenAI +from app.config import get_settings +from app.agent.core import call_with_retry +from app.math_wiki.prompts import MODE_PROMPTS +from app.math_wiki.utils import _extract_json, VALID_LABELS +from app.math_wiki.taxonomy import CANONICAL_TOPICS # noqa: F401 — imported for canonical reference + + +async def classify_problem(client: AsyncOpenAI, problem_text: str) -> str: + settings = get_settings() + user_msg = ( + f"{problem_text}\n\n" + "Respond ONLY with valid JSON in this exact format: " + '{"label": ""} ' + "where category is one of: algebra, geometry, statistics, probability, " + "calculus, trigonometry, combinatorics, number_theory, " + "complex_numbers, sequences, vectors, functions." + ) + response = await call_with_retry( + client, + model=settings.default_model, + messages=[ + {"role": "system", "content": MODE_PROMPTS["CLASSIFY"]}, + {"role": "user", "content": user_msg}, + ], + max_tokens=50, + ) + raw = response.choices[0].message.content or "" + content = _extract_json(raw) + try: + parsed = json.loads(content) + label = parsed.get("label", "") + except json.JSONDecodeError: + label = "" + + keyword_map = { + "calculus": [ + "calculus", "derivative", "integral", "integrate", "differentiat", + "limit", r"\int", "antiderivative", "indefinite", "definite", + "dy/dx", "d/dx", "partial", "gradient", "divergence", "curl", + "differential equation", "ode", "pde", "y''", "y'", + # Vietnamese + "đạo hàm", "tích phân", "nguyên hàm", "giới hạn", "vi phân", + "phương trình vi phân", "cực trị hàm", "tiếp tuyến", + ], + "trigonometry": [ + "trigonometry", "trigonometric", "sine", "cosine", "tangent", + r"\sin", r"\cos", r"\tan", r"\cot", r"\sec", r"\csc", + "sin(", "cos(", "tan(", "arcsin", "arccos", "arctan", + # Vietnamese + "sin ", "cos ", "tan ", "cot ", "sinx", "cosx", "tanx", + "công thức lượng giác", "hệ thức lượng", + ], + "algebra": [ + "algebra", "equation", "quadratic", "polynomial", "linear", "variable", + # Vietnamese + "phương trình", "hệ phương trình", "bất phương trình", + "đa thức", "nhân tử", "rút gọn", "hằng đẳng thức", + ], + "geometry": [ + "geometry", "geometric", "triangle", "circle", "area", "perimeter", "volume", "angle", + # Vietnamese + "tam giác", "hình vuông", "hình chữ nhật", "hình thang", "hình tròn", + "đường tròn", "hình hộp", "hình chóp", "hình trụ", "hình cầu", + "diện tích", "chu vi", "thể tích", "góc", "đường thẳng", "mặt phẳng", + ], + "statistics": [ + "statistic", "mean", "median", "mode", "variance", "deviation", "frequency", + # Vietnamese + "trung bình", "trung vị", "phương sai", "độ lệch chuẩn", "tần số", + "bảng số liệu", "biểu đồ", + ], + "probability": [ + "probability", "chance", "likelihood", "random", "event", + # Vietnamese + "xác suất", "biến cố", "ngẫu nhiên", "không gian mẫu", + ], + "combinatorics": [ + "combinatoric", "permutation", "combination", "factorial", "arrange", + # Vietnamese + "tổ hợp", "chỉnh hợp", "hoán vị", "giai thừa", "đếm", + ], + "number_theory": [ + "number theory", "prime", "divisor", "modular", "gcd", "lcm", + # Vietnamese + "số nguyên tố", "ước", "bội", "chia hết", "đồng dư", "ucln", "bcnn", + ], + "sequences": [ + # Vietnamese + "dãy số", "cấp số cộng", "cấp số nhân", "công sai", "công bội", + "số hạng", "tổng n số hạng", "giới hạn dãy", + # English + "sequence", "series", "arithmetic sequence", "geometric sequence", + ], + "vectors": [ + # Vietnamese + "vectơ", "tích vô hướng", "tích có hướng", "tọa độ vectơ", + # English + "vector", "dot product", "cross product", "magnitude", + ], + "functions": [ + # Vietnamese + "hàm số", "đồ thị hàm số", "tập xác định", "tập giá trị", + "đơn điệu", "đồng biến", "nghịch biến", "hàm bậc", "hàm mũ", "hàm logarit", + # English + "function", "domain", "range", "monoton", + ], + "complex_numbers": [ + # Vietnamese + "số phức", "phần thực", "phần ảo", "module", "argument", + # English + "complex number", "imaginary", "real part", "imaginary part", + ], + } + + # Score every label by counting keyword hits. Highest score wins. + # First-match (next(...)) was order-dependent: "phương trình vi phân" matched + # algebra ("phương trình") before calculus, mis-classifying ODEs. + question_lower = problem_text.lower() + scores: dict[str, int] = {} + for lbl, kws in keyword_map.items(): + count = sum(1 for kw in kws if kw in question_lower) + if count: + scores[lbl] = count + keyword_label = max(scores, key=scores.__getitem__) if scores else None + + if label not in VALID_LABELS: + label = keyword_label or "algebra" + elif keyword_label and keyword_label != label: + # Keyword score beats LLM label only when it has strictly more hits. + # Tie means ambiguous — keep the LLM's more contextual judgement. + if scores.get(keyword_label, 0) > scores.get(label, 0): + label = keyword_label + return label diff --git a/backend/app/math_wiki/agents/concept_ingest.py b/backend/app/math_wiki/agents/concept_ingest.py new file mode 100644 index 0000000000000000000000000000000000000000..3f73e75c97868856a5bfa14cb6d81ea6cc5e1d71 --- /dev/null +++ b/backend/app/math_wiki/agents/concept_ingest.py @@ -0,0 +1,83 @@ +import hashlib +import json +from openai import AsyncOpenAI +from app.config import get_settings +from app.agent.core import call_with_retry +from app.math_wiki.prompts import MODE_PROMPTS +from app.math_wiki.utils import _extract_json +from app.math_wiki.schemas import ConceptIngestOutput +from app.math_wiki.storage import pg_db +from app.math_wiki.storage.pg_vectors import is_near_duplicate_pg +from app.metrics import inc_wiki_units_added +from app.math_wiki.taxonomy import CANONICAL_TOPICS, TOPIC_MAP, CANONICAL_TYPES, TYPE_MAP + +_VALID_TOPICS = ", ".join(sorted(CANONICAL_TOPICS)) +_VALID_TYPES = ", ".join(sorted(CANONICAL_TYPES)) + +_JSON_REMINDER = ( + "\n\nExtract wiki knowledge units from the above math text. " + "Return ONLY valid JSON in this exact format: " + '{"wiki_units": [{"id": "slug", "type": "concept", "topic": "statistics", ' + '"subtopic": "...", "content": "...", "problem_ids": []}]}\n' + f"topic MUST be one of: {_VALID_TOPICS}\n" + f"type MUST be one of: {_VALID_TYPES}\n" + "IMPORTANT: Write math expressions in plain text (e.g. 'x^2 + bx + c = 0'), " + "NOT LaTeX backslash notation. Backslashes break JSON." +) + + +def _normalize_unit(unit, fallback_topic: str | None) -> None: + topic = unit.topic + if topic not in CANONICAL_TOPICS: + topic = TOPIC_MAP.get(topic) or TOPIC_MAP.get(topic.lower().replace(" ", "_")) + if topic not in CANONICAL_TOPICS: + topic = fallback_topic + if topic: + unit.topic = topic + + unit_type = unit.type + if unit_type not in CANONICAL_TYPES: + unit.type = TYPE_MAP.get(unit_type, "concept") + + +async def concept_ingest( + client: AsyncOpenAI, + raw_text: str, + pool=None, + source: str = "manual", + source_url: str | None = None, + fallback_topic: str | None = None, +) -> ConceptIngestOutput: + settings = get_settings() + response = await call_with_retry( + client, + model=settings.default_model, + messages=[ + {"role": "system", "content": MODE_PROMPTS["CONCEPT_INGEST"]}, + {"role": "user", "content": raw_text + _JSON_REMINDER}, + ], + max_tokens=4096, + ) + content = _extract_json(response.choices[0].message.content or "{}") + parsed = json.loads(content) + output = ConceptIngestOutput(**parsed) + + if pool: + existing_hashes = await pg_db.get_all_content_hashes(pool) + for unit in output.wiki_units: + _normalize_unit(unit, fallback_topic) + if unit.topic not in CANONICAL_TOPICS: + logger.warning("concept_ingest: skipped %s — invalid topic %r (not in CANONICAL_TOPICS)", unit.id, unit.topic) + continue + content_hash = hashlib.md5(unit.content.encode()).hexdigest() + if content_hash in existing_hashes: + logger.info("concept_ingest: skipped %s — duplicate content hash", unit.id) + continue + if await is_near_duplicate_pg(pool, unit.content): + logger.info("concept_ingest: skipped %s — near-duplicate by embedding (similarity>0.92)", unit.id) + continue + await pg_db.upsert_wiki_unit(pool, unit, source=source, source_url=source_url) + existing_hashes.add(content_hash) + inc_wiki_units_added() + + return output diff --git a/backend/app/math_wiki/agents/decomposer.py b/backend/app/math_wiki/agents/decomposer.py new file mode 100644 index 0000000000000000000000000000000000000000..b20b3a82de9062226d03b7c6e01c6738d0f1fb47 --- /dev/null +++ b/backend/app/math_wiki/agents/decomposer.py @@ -0,0 +1,63 @@ +"""Multi-domain query decomposer. + +Detects when a problem spans two THPT math topics and splits it into +focused sub-questions for independent retrieval. +Returns quickly (Haiku) and is non-fatal — callers catch all exceptions. +""" +import json +import logging +from openai import AsyncOpenAI +from app.config import get_settings +from app.agent.core import call_with_retry +from app.math_wiki.utils import _extract_json +from app.math_wiki.schemas import DecomposedQuery + +logger = logging.getLogger(__name__) + +_PROMPT = """Bạn là hệ thống phân tích lĩnh vực toán học cho kỳ thi THPT Việt Nam. + +Cho một bài toán, xác định xem bài toán có THỰC SỰ cần kiến thức từ hai chủ đề THPT riêng biệt không. +Trả lời bằng tiếng Việt. Chỉ xuất JSON theo đúng schema sau: + +{ + "primary_topic": "algebra", + "secondary_topics": ["calculus"], + "sub_questions": ["Tìm f'(x)", "Giải f'(x)=0"], + "requires_multi_domain": true +} + +Chủ đề THPT: algebra, calculus, geometry, trigonometry, combinatorics, probability, statistics, logarithm, functions, spatial_geometry + +Quy tắc: +- requires_multi_domain = true CHỈ KHI bài toán KHÔNG THỂ giải được chỉ với kiến thức từ một chủ đề duy nhất. +- sub_questions: 2-3 câu hỏi phụ bằng tiếng Việt, phân tách bài toán theo từng chủ đề. +- Nếu bài toán chỉ thuộc một chủ đề, đặt requires_multi_domain=false và sub_questions=[]. +- Chỉ xuất JSON hợp lệ. Không viết thêm bất kỳ văn bản nào.""" + + +async def decompose_query(client: AsyncOpenAI, question: str) -> DecomposedQuery: + settings = get_settings() + response = await call_with_retry( + client, + model=settings.haiku_model, + messages=[ + {"role": "system", "content": _PROMPT}, + {"role": "user", "content": question}, + ], + max_tokens=256, + ) + content = _extract_json(response.choices[0].message.content or "{}") + try: + parsed = json.loads(content) + except json.JSONDecodeError: + return DecomposedQuery( + primary_topic="algebra", secondary_topics=[], + sub_questions=[], requires_multi_domain=False, + ) + + return DecomposedQuery( + primary_topic=parsed.get("primary_topic", "algebra"), + secondary_topics=parsed.get("secondary_topics", []), + sub_questions=parsed.get("sub_questions", []), + requires_multi_domain=bool(parsed.get("requires_multi_domain", False)), + ) diff --git a/backend/app/math_wiki/agents/ingest.py b/backend/app/math_wiki/agents/ingest.py new file mode 100644 index 0000000000000000000000000000000000000000..aeadd542bdf5328cf52a3562bcf9820f811c05f5 --- /dev/null +++ b/backend/app/math_wiki/agents/ingest.py @@ -0,0 +1,64 @@ +import hashlib +import json +from openai import AsyncOpenAI +from app.config import get_settings +from app.agent.core import call_with_retry +from app.math_wiki.prompts import MODE_PROMPTS +from app.math_wiki.utils import _extract_json +from app.math_wiki.schemas import IngestOutput +from app.math_wiki.storage import pg_db +from app.math_wiki.storage.pg_vectors import is_near_duplicate_pg +from app.metrics import inc_wiki_units_added + + +async def ingest_exam( + client: AsyncOpenAI, + raw_text: str, + pool=None, + source: str = "exam_upload", + source_url: str | None = None, +) -> IngestOutput: + settings = get_settings() + response = await call_with_retry( + client, + model=settings.default_model, + messages=[ + {"role": "system", "content": MODE_PROMPTS["INGEST"]}, + {"role": "user", "content": raw_text}, + ], + max_tokens=2000, + ) + content = _extract_json(response.choices[0].message.content or "{}") + parsed = json.loads(content) + output = IngestOutput(**parsed) + + # Validate: each problem must have >= 2 wiki units + unit_map: dict[str, set[str]] = {} + for unit in output.wiki_units: + for pid in unit.problem_ids: + unit_map.setdefault(pid, set()).add(unit.id) + + for problem in output.problems: + if len(unit_map.get(problem.problem_id, set())) < 2: + raise ValueError( + f"Problem {problem.problem_id} has fewer than 2 wiki units" + ) + + if pool: + existing_hashes = await pg_db.get_all_content_hashes(pool) + for unit in output.wiki_units: + content_hash = hashlib.md5(unit.content.encode()).hexdigest() + if content_hash in existing_hashes: + logger.info("ingest: skipped %s — duplicate content hash", unit.id) + continue + if await is_near_duplicate_pg(pool, unit.content): + logger.info("ingest: skipped %s — near-duplicate by embedding (similarity>0.92)", unit.id) + continue + await pg_db.upsert_wiki_unit(pool, unit, source=source, source_url=source_url) + existing_hashes.add(content_hash) + inc_wiki_units_added() + + for problem in output.problems: + await pg_db.upsert_problem(pool, problem) + + return output diff --git a/backend/app/math_wiki/agents/ocr.py b/backend/app/math_wiki/agents/ocr.py new file mode 100644 index 0000000000000000000000000000000000000000..71bc29a1465dc9542da226106c7a89d19c366250 --- /dev/null +++ b/backend/app/math_wiki/agents/ocr.py @@ -0,0 +1,73 @@ +import base64 +from openai import AsyncOpenAI +from app.config import get_settings +from app.agent.core import call_with_retry + +# Phrases Claude returns when image content was stripped by the proxy +_NO_IMAGE_PHRASES = ( + "chưa đính kèm hình ảnh", + "không thấy hình ảnh", + "không có hình ảnh", + "vui lòng tải lên hình ảnh", + "vui lòng gửi hình ảnh", + "no image attached", + "no image provided", + "i don't see any image", +) + +_SYSTEM_PROMPT = ( + "You are a Vietnamese math OCR assistant. Extract all text, mathematical content, and visual elements from the image.\n" + "Rules:\n" + "- Preserve all LaTeX notation exactly; wrap inline math in $...$ and display math in $$...$$\n" + "- Keep Vietnamese text exactly as written\n" + "- List each numbered problem separately\n" + "- Do not solve or explain — only transcribe or describe what is visible\n" + "- If a symbol is unclear, use your best judgment\n" + "- For handwritten content: interpret symbols especially carefully. Common handwritten forms:\n" + " fraction a/b → \\frac{a}{b}; square root → \\sqrt{}; exponent → ^{}; subscript → _{};\n" + " absolute value bars → |...|; multiplication dot → \\cdot\n" + "- When a symbol is ambiguous, choose the most mathematically plausible interpretation\n" + "- Preserve problem number labels exactly as they appear (Bài 1, Câu 2, etc.)\n" + "Visual elements — when the image contains shapes, graphs, or drawings that cannot be expressed as plain text:\n" + "- Geometric figures: describe the shape (triangle, circle, quadrilateral…), label each vertex/point as shown," + " list all given side lengths, angles, and any marked equal/parallel/perpendicular relationships." + " Example: 'Tam giác ABC vuông tại A, AB = 3, BC = 5, AC = 4'\n" + "- Coordinate graphs / function plots: state axis labels and scale, identify key points (intercepts, maxima," + " minima, intersection points) with their coordinates, describe the curve type (line, parabola, circle…)." + " Example: 'Đồ thị hàm số y = f(x) qua các điểm (0, 2) và (3, 0), là đường thẳng giảm dần'\n" + "- Hand-drawn or complex diagrams: give a concise prose description of every element, dimension, and label" + " that appears, sufficient for a solver to reconstruct the problem without seeing the image\n" + "- Place visual descriptions inline, immediately after the problem text they accompany" +) + + +async def extract_math_from_image( + client: AsyncOpenAI, image_bytes: bytes, mime_type: str +) -> str: + settings = get_settings() + data_uri = f"data:{mime_type};base64,{base64.b64encode(image_bytes).decode()}" + + response = await call_with_retry( + client, + model=settings.default_model, + messages=[ + {"role": "system", "content": _SYSTEM_PROMPT}, + { + "role": "user", + "content": [ + {"type": "image_url", "image_url": {"url": data_uri}}, + {"type": "text", "text": "Trích xuất toàn bộ nội dung toán học từ hình ảnh này."}, + ], + }, + ], + max_tokens=4096, + ) + + text = (response.choices[0].message.content or "").strip() + if not text: + raise ValueError("Claude Vision returned empty response") + if any(phrase in text.lower() for phrase in _NO_IMAGE_PHRASES): + raise ValueError( + "Vision API not supported by this AI router — please type the problem manually." + ) + return text diff --git a/backend/app/math_wiki/agents/quiz_generator.py b/backend/app/math_wiki/agents/quiz_generator.py new file mode 100644 index 0000000000000000000000000000000000000000..35d630de4d5a021202ee8f0da37b641012a2de34 --- /dev/null +++ b/backend/app/math_wiki/agents/quiz_generator.py @@ -0,0 +1,471 @@ +import json +import logging +import random +import re +from openai import AsyncOpenAI +from app.config import get_settings +from app.agent.core import call_with_retry + +logger = logging.getLogger(__name__) + +# Sources for distractor taxonomy and techniques: +# - Vanderbilt / Lamar algebra error catalogs (sign, distribution, sqrt errors) +# - NAACL 2024: naming error mechanisms raises distractor plausibility ~2.68→~3.7 on 5-pt scale +# - INFORMS 2022: partial-solution and conceptual-reversal are most convincing traps in quant MCQs +# - Student Choice Prediction (ACL 2025): conceptual-overlap traps attract highest-ability students +# - LookAlike ACL 2025: surface-form consistency across all options blocks answer-by-elimination +# - NYSED item writing guide: sign/coefficient/unit alteration as a systematic distractor family +_SYSTEM = r"""Bạn là giáo viên Toán lớp 9 chuyên ôn thi vào lớp 10 TPHCM, đồng thời là chuyên gia thiết kế đề trắc nghiệm. +Nhiệm vụ: tạo câu hỏi trắc nghiệm chất lượng cao — mỗi phương án sai phải dựa trên lỗi nhận thức thực sự của học sinh và không thể loại bằng hình thức. + +═══ NGUYÊN TẮC CỐT LÕI ═══ + +1. TÍNH NHẨM ĐƯỢC: Mọi con số phải tính được bằng đầu óc — KHÔNG cần máy tính. +2. 4 PHƯƠNG ÁN: Mỗi câu có đúng 4 lựa chọn (A–D), chỉ một đúng. +3. BẪY CÓ NGUỒN GỐC: Mỗi phương án sai PHẢI xuất phát từ một loại lỗi cụ thể trong bảng taxonomy. + Ba bẫy trong một câu phải đến từ BA loại lỗi KHÁC NHAU. +4. LOOKALIKE — NGOẠI HÌNH GIỐNG NHAU: Tất cả 4 phương án phải có cùng dạng ký hiệu, cùng cấu trúc + (cùng loại biểu thức, số hạng tương đương, độ phức tạp tương tự). Học sinh không được loại + phương án sai chỉ bằng cách nhìn hình thức mà phải tính toán. +5. TỰ KIỂM TRA: Tính lại đáp án đúng từng bước trước khi viết JSON. Xác nhận mỗi bẫy thực sự sai. +6. ĐỘ KHÓ BLOOM: Sắp xếp từ dễ → khó theo thang nhận thức. +7. NGÔN NGỮ: Tiếng Việt. LaTeX trong $...$ cho ký hiệu toán. +8. JSON THUẦN: Chỉ trả về JSON, không có text ngoài. + +═══ BẢNG LỖI SAI THỰC CHỨNG (DISTRACTOR TAXONOMY) ═══ + +Mỗi bẫy phải thuộc một trong 13 loại sau — ghi tên loại trong explanation: + +── NHÓM DẤU VÀ HỆ SỐ ── + +[SIGN_ERROR] Nhầm dấu âm/dương + • $-(a-b)$ → ghi $-a-b$ thay vì $-a+b$ + • Tổng Viète $x_1+x_2=-b/a$ → ghi $+b/a$ (bỏ dấu âm) + • Tích Viète $x_1x_2=c/a$ → ghi $-c/a$ + • $-x$ khi $x=-5$ → ghi $-5$ thay vì $5$ + +[COEFFICIENT_ERROR] Sai hệ số hoặc bội số + • $(a+b)^2=a^2+2ab+b^2$ → bỏ hệ số 2: viết $a^2+ab+b^2$ + • $\Delta=b^2-4ac$ → viết $b^2-ac$ (quên hệ số 4) + • $2a\cdot x_0 = -b$ → viết $a\cdot x_0=-b$ (quên hệ số 2) + • Kết quả đúng nhân hay chia thêm 2, 4, hoặc $\pi$ do nhầm công thức + +[WRONG_OPERATION] Dùng phép tính sai — số đúng, phép tính sai + • Cộng thay nhân: $P = a \times b$ → viết $P = a + b$ + • Bình phương thay nhân đôi: $2r$ → viết $r^2$ + • Khai căn thay bình phương: $x^2=k$ → ghi $x=k^2$ thay $x=\sqrt{k}$ + • Chia thay trừ trong hệ thức lượng + +── NHÓM NGHIỆM VÀ MIỀN ── + +[MISSING_ROOT] Bỏ sót nghiệm + • $x^2=9$ → chỉ lấy $x=3$, quên $x=-3$ + • Chia hai vế cho $x$ → mất nghiệm $x=0$ + • $|x|=5$ → quên $x=-5$ + • Phương trình tích: chỉ lấy một trong hai nghiệm + +[EXTRANEOUS_ROOT] Nghiệm ngoại lai (không kiểm tra lại) + • Bình phương hai vế rồi không thử lại vào phương trình gốc + • Đặt ẩn phụ $t=\sqrt{x}\geq0$ rồi nhận $t<0$ + • Nhận nghiệm nằm ngoài điều kiện xác định + +[INEQUALITY_FLIP] Quên đảo chiều bất phương trình + • Nhân/chia hai vế với số âm mà không lật dấu $\leq\to\geq$ + • Kết quả là phần bù của tập nghiệm đúng + +── NHÓM CĂN VÀ PHÂN PHỐI ── + +[SQRT_LINEARITY] Giả sử căn là tuyến tính + • $\sqrt{a^2+b^2}\to a+b$ (cộng thẳng, bỏ dấu căn) + • $\sqrt{(a+b)^2}=a+b$ (bỏ trị tuyệt đối) + • $\sqrt{9+16}=3+4=7$ thay vì $\sqrt{25}=5$ + +[SQRT_NO_ABS] Quên trị tuyệt đối khi khai căn + • $\sqrt{x^2}=x$ thay vì $|x|$ + • $\sqrt{(x-3)^2}=x-3$ thay vì $|x-3|$ + +[DISTRIBUTION_ERROR] Phân phối/nhân sai + • $(a+b)^2\neq a^2+b^2$ (bỏ hạng tử $2ab$) + • $a(b-c)^2\neq(ab-ac)^2$: nhân $a$ vào trước khi bình phương + • $\frac{a+b}{c}\neq\frac{a}{c}+b$: chỉ rút gọn một hạng tử + +── NHÓM CÔNG THỨC VÀ KHÁI NIỆM ── + +[DELTA_ERROR] Sai công thức Delta + • $\Delta=b^2-4ac$: nhầm dấu $c$, hoặc dùng $b$ thay $b'=b/2$ + • Chỉ lấy một nghiệm $x_1$ hoặc chỉ lấy $x_2$ + +[CONCEPTUAL_REVERSAL] Đảo ngược một quan hệ toán học + • Phân số: $\frac{a}{b}\to\frac{b}{a}$ (đảo tử/mẫu) + • Tỉ số lượng giác: $\sin\theta=\frac{\text{đối}}{\text{huyền}}$ → dùng $\frac{\text{huyền}}{\text{đối}}$ + • Viète: dùng tổng thay tích hoặc ngược lại + • Hệ thức: $x_1\cdot x_2=c/a$ → học sinh dùng $x_1+x_2$ + • Tỉ lệ thức: $\frac{a}{b}=\frac{c}{d}$ → giải nhầm thành $ad=bc$ rồi hoán vị sai + +[VERTEX_SIGN] Nhầm dấu tọa độ đỉnh parabol + • $x_0=-b/(2a)$ → dùng $+b/(2a)$ + • Tính $f(x_0)$ nhưng thay $-x_0$ + +[FORMULA_MIX] Nhầm công thức hoặc điều kiện áp dụng + • Diện tích/chu vi: nhầm hình tròn với hình quạt hay hình chữ nhật + • Định lý Pythagore: nhầm vai trò cạnh huyền + • Hệ thức lượng trong tam giác vuông: nhầm cạnh với chiều cao + +── NHÓM ĐẶC BIỆT: BẪY MẠNH NHẤT ── + +[PARTIAL_SOLUTION] Nghiệm trung gian — học sinh dừng lại quá sớm + Đây là loại bẫy hiệu quả nhất với học sinh giỏi. + Kỹ thuật: lấy KẾT QUẢ CỦA BƯỚC TRUNG GIAN đúng trong lời giải đầy đủ làm phương án sai. + • Bài 3 bước: kết quả bước 2 là đáp án trông "hợp lý" nhất + • Tìm $x$: học sinh tính được $2x=10$ rồi ghi ngay $10$ thay vì $5$ + • Tìm diện tích: tính đúng bán kính $r$ rồi ghi $r$ thay vì $\pi r^2$ + • Giải hệ: tìm đúng $x$ rồi quên thay vào tìm $y$ + Cách tạo: Viết lời giải đầy đủ 4–5 bước → lấy kết quả bước 2 và bước 3 làm hai bẫy. + +═══ QUY TRÌNH ESSAY-TO-MCQ (chuyển bài tự luận thành trắc nghiệm) ═══ + +Đây là kỹ thuật cốt lõi để tạo bẫy cực kỳ thuyết phục: + +Bước 1 — Viết lời giải tự luận đầy đủ: + Liệt kê từng bước tính: $k_1=\ldots$, $k_2=\ldots$, $k_3=\ldots$, đáp án $=k_n$. + +Bước 2 — Thu hoạch bẫy từ lời giải: + • Bẫy A = $k_{n-1}$ (kết quả bước cuối-1): [PARTIAL_SOLUTION] + • Bẫy B = kết quả nếu dùng sai công thức tại bước quan trọng nhất: [FORMULA_MIX] hoặc [DELTA_ERROR] + • Bẫy C = đáp án đúng nhưng đổi dấu hoặc đảo tử/mẫu: [SIGN_ERROR] hoặc [CONCEPTUAL_REVERSAL] + +Bước 3 — Áp dụng nguyên tắc LOOKALIKE: + Đảm bảo A, B, C, D (đáp án đúng) cùng dạng: đều là số nguyên, đều là phân số, đều có $\sqrt{}$, + cùng số hạng, giá trị gần nhau về độ lớn. + +Bước 4 — Sắp xếp các phương án theo thứ tự tăng dần (với số) hoặc theo độ phức tạp. + +Bước 5 — Kiểm tra: Đọc từng bẫy và tự hỏi "Học sinh nào sẽ chọn cái này và tại sao?" + Bẫy tốt = có câu trả lời rõ ràng cho câu hỏi đó. + +═══ THANG ĐỘ KHÓ BLOOM ═══ + +"easy" → Nhớ/Hiểu: nhận dạng công thức, tính một bước, bẫy là lỗi cơ bản (SIGN_ERROR, MISSING_ROOT) +"medium" → Vận dụng: giải 2–3 bước, bẫy bao gồm PARTIAL_SOLUTION từ bước trung gian +"hard" → Phân tích/Đánh giá: 3–5 bước, hai bẫy kết hợp (ví dụ PARTIAL_SOLUTION + CONCEPTUAL_REVERSAL), + điều kiện ẩn, học sinh giỏi vẫn có thể mắc bẫy nếu không kiểm tra kỹ + +═══ TỰ KIỂM TRA BẮT BUỘC TRƯỚC KHI XUẤT JSON ═══ + +Sau khi tạo xong từng câu, thực hiện kiểm tra sau — nếu không qua thì viết lại câu đó: + +CHK-1 TOÁN HỌC CHÍNH XÁC — GIẢI TRƯỚC KHI ĐẶT correct_index + → Viết lời giải tự luận đầy đủ từng bước số học cụ thể. + → Ghi kết quả cuối: "Đáp án đúng = ". + → Tìm phần tử trong choices khớp giá trị đó → đó là correct_index. + → Xác nhận 3 phương án còn lại đều SAI. + → KHÔNG được gán correct_index trước rồi mới giải — luôn giải trước, gán sau. + +CHK-2 NHẤT QUÁN GIẢI THÍCH — ĐÁP ÁN + → Nội dung "Đáp án đúng:" trong explanation PHẢI KHỚP giá trị số với choices[correct_index]. + → Nếu không khớp → viết lại câu từ đầu. + +CHK-3 NHẤT QUÁN BẪY — EXPLANATION + → Với mỗi bẫy (phương án sai), explanation phải ghi đúng tên loại lỗi và cơ chế sai khớp với giá trị trong choices. + +CHK-4 LOOKALIKE ĐỦ ĐIỀU KIỆN + → Tất cả 4 phương án cùng dạng ký hiệu và cấu trúc. + → Không có phương án nào quá dài/ngắn hoặc phức tạp khác biệt rõ ràng so với các phương án khác. + +CHK-5 EXPLANATION NGẮN GỌN — KHÔNG BIỆN HỘ SAU + → Explanation CHỈ được giải bài toán GỐC trong stem — KHÔNG được thử nghiệm thay đổi đề bài. + → Nếu tính toán cho kết quả không khớp choices nào → viết lại câu hỏi để sửa, KHÔNG viết dài thêm để biện hộ. + → Giới hạn: phần "Đáp án đúng:" tối đa 4 câu/bước; mỗi bẫy tối đa 1 câu ngắn. + +QUAN TRỌNG: correct_index trong JSON PHẢI trỏ đúng vào phương án chứa đáp án đã tính ở CHK-1. +Đây là điều kiện tối thiểu — sai ở đây là lỗi nghiêm trọng nhất.""" + +_PROMPT_TMPL = """Trọng tâm tuần: {focus} +Nhiệm vụ học trong tuần: +{tasks} + +Ngữ cảnh kiến thức từ kho tri thức: +{context} + +Tạo {n} câu hỏi trắc nghiệm (từ dễ → khó theo Bloom). + +YÊU CẦU BẮT BUỘC cho mỗi câu: +1. Áp dụng quy trình Essay-to-MCQ: viết lời giải tự luận trước, sau đó thu hoạch bẫy. +2. Đảm bảo nguyên tắc LOOKALIKE: 4 phương án cùng dạng ký hiệu, giá trị gần nhau. +3. Ba bẫy từ ba loại lỗi KHÁC NHAU trong taxonomy. +4. Explanation nêu rõ: tính toán đáp án đúng + tên loại lỗi + cơ chế sai của từng bẫy. +5. PHÂN BỐ correct_index: trong {n} câu, đáp án đúng phải rải đều A/B/C/D — KHÔNG được tập trung vào một vị trí. Đặt đáp án đúng vào vị trí bất kỳ (0, 1, 2, hoặc 3) sau khi sắp xếp choices. + +Trả về JSON hợp lệ, không có text nào ngoài JSON: +{{ + "questions": [ + {{ + "stem": "Nội dung câu hỏi (tiếng Việt, LaTeX $...$)", + "choices": ["A. ...", "B. ...", "C. ...", "D. ..."], + "correct_index": "", + "difficulty": "easy|medium|hard", + "bloom_level": "remember|understand|apply|analyze", + "explanation": "Đáp án đúng: . Bẫy [LOẠI_LỖI]: . Bẫy [LOẠI_LỖI]: . Bẫy [LOẠI_LỖI]: ." + }} + ] +}}""" + + +# Reviewer prompt: independent mathematical validation of each generated question. +# Uses default_model (sonnet) because haiku cannot reliably verify multi-step algebra +# (Vieta, completing the square, optimization with constraints, etc.). +_REVIEWER_SYSTEM = r"""Bạn là giáo viên Toán lớp 9 kiểm duyệt độc lập. Với MỖI câu hỏi: + +BƯỚC 1 — GIẢI ĐỘC LẬP (bắt buộc, không đọc explanation trước): + Đọc "stem". Tính kết quả đúng từ đầu theo từng bước số học cụ thể. + Ghi kết quả tính được: result = . + +BƯỚC 2 — ĐỐI CHIẾU VỚI CHOICES: + Tìm phần tử trong "choices" chứa giá trị = result ở Bước 1. + Đó là correct_index thực sự (0=A, 1=B, 2=C, 3=D). + +BƯỚC 3 — SO SÁNH VỚI correct_index Đà CHO: + Nếu correct_index đã cho = correct_index thực sự → valid: true. + Nếu khác → valid: false, báo corrected_correct_index = correct_index thực sự. + Nếu không có choice nào khớp result → valid: false, corrected_correct_index: null (bỏ câu). + +QUY TẮC QUAN TRỌNG: +- KHÔNG được tin vào explanation — nó có thể sai hoặc cố tình biện hộ cho đáp án sai. +- KHÔNG được chấp nhận lý luận dài dòng thay đổi đề bài. Chỉ kiểm tra stem gốc. +- Nếu explanation mâu thuẫn với kết quả tính ở Bước 1 → luôn tin vào tính toán, không tin explanation. + +Trả về JSON thuần (không có text ngoài): +{"results": [{"index": , "valid": true|false, "corrected_correct_index": }]}""" + + +async def _review_and_patch( + client: AsyncOpenAI, + questions: list[dict], + settings, +) -> list[dict]: + """Send generated questions to a reviewer model; drop or patch invalid ones.""" + if not questions: + return questions + + payload = json.dumps({"questions": questions}, ensure_ascii=False) + try: + response = await call_with_retry( + client, + model=settings.default_model, + max_tokens=3000, + messages=[ + {"role": "system", "content": _REVIEWER_SYSTEM}, + {"role": "user", "content": payload}, + ], + ) + raw = _extract_json(response.choices[0].message.content or "{}") + data = json.loads(raw) + results = {r["index"]: r for r in data.get("results", [])} + except Exception as exc: + logger.warning("quiz_generator: reviewer call failed (%s), skipping review", exc) + return questions + + patched: list[dict] = [] + for i, q in enumerate(questions): + verdict = results.get(i) + if verdict is None or verdict.get("valid"): + patched.append(q) + continue + corrected = verdict.get("corrected_correct_index") + if corrected is not None and 0 <= corrected < len(q.get("choices", [])): + logger.info( + "quiz_generator: patching correct_index %d→%d for question %d (issues: %s)", + q["correct_index"], corrected, i, verdict.get("issues"), + ) + q = dict(q, correct_index=corrected) + patched.append(q) + else: + logger.warning( + "quiz_generator: dropping question %d — reviewer flagged unfixable issues: %s", + i, verdict.get("issues"), + ) + return patched + + +def _fix_latex_escapes(text: str) -> str: + """Double-escape backslashes that are not valid JSON escape sequences. + + LLMs frequently emit bare LaTeX (e.g. \\sqrt, \\frac) inside JSON strings. + Valid JSON escapes after '\\' are: " \\ / b f n r t u. + """ + return re.sub(r'\\(?!["\\/bfnrtu])', r'\\\\', text) + + +def _extract_json(text: str) -> str: + """Strip code fences, repair LaTeX escapes, and extract a valid JSON object.""" + text = text.strip() + if text.startswith("```"): + parts = text.split("```") + text = parts[1] if len(parts) > 1 else text + if text.startswith("json"): + text = text[4:] + text = text.strip() + try: + json.loads(text) + return text + except json.JSONDecodeError: + pass + fixed = _fix_latex_escapes(text) + try: + json.loads(fixed) + return fixed + except json.JSONDecodeError: + pass + m = re.search(r'\{[\s\S]*\}', text) + if m: + candidate = m.group(0) + return _fix_latex_escapes(candidate) + return fixed + + +def _validate_structure(questions: list[dict]) -> list[dict]: + """Deterministic post-generation guard before questions reach the UI. + + Hard drops (structural): + - correct_index not an int 0-3 + - choices count != 4 + - any choice is empty/missing + - stem is empty + + Soft warns (content consistency — LLM reviewer already validated the math): + - explanation missing "Đáp án đúng" section + - choices[correct_index] value not found in explanation answer section + """ + valid: list[dict] = [] + for i, q in enumerate(questions): + ci = q.get("correct_index") + choices = q.get("choices") or [] + stem = (q.get("stem") or "").strip() + explanation = (q.get("explanation") or "").strip() + + # ── Hard structural checks ────────────────────────────────────────── + if not isinstance(ci, int) or not (0 <= ci <= 3): + logger.warning("quiz_validate: q%d dropped — invalid correct_index %r", i, ci) + continue + if len(choices) != 4: + logger.warning("quiz_validate: q%d dropped — expected 4 choices, got %d", i, len(choices)) + continue + if not all(isinstance(c, str) and c.strip() for c in choices): + logger.warning("quiz_validate: q%d dropped — empty or non-string choice", i) + continue + if not stem: + logger.warning("quiz_validate: q%d dropped — empty stem", i) + continue + + # ── Soft content-consistency checks (warn only) ───────────────────── + if not explanation: + logger.warning("quiz_validate: q%d has no explanation", i) + elif "Đáp án đúng" not in explanation: + logger.warning("quiz_validate: q%d explanation missing 'Đáp án đúng' section", i) + else: + # Extract correct choice value (strip "A. " label and LaTeX $ markers + whitespace) + correct_body = re.sub(r'^[A-D]\.\s*', '', choices[ci]).strip() + # Get answer section (text before first "Bẫy" label) + ans_section = re.split(r'\bBẫy\s+[A-D]\b', explanation, maxsplit=1)[0] + + def _norm(s: str) -> str: + return re.sub(r'[\s$]', '', s) + + if correct_body and _norm(correct_body) not in _norm(ans_section): + logger.warning( + "quiz_validate: q%d explanation/answer mismatch — " + "choice[%d]=%r not found in answer section %r", + i, ci, correct_body[:50], ans_section[:100], + ) + + valid.append(q) + return valid + + +def _shuffle_answer_position(questions: list[dict]) -> list[dict]: + """Randomly redistribute the correct answer across A/B/C/D positions. + + LLMs have a strong bias toward correct_index=0. This post-processor + reassigns each question's correct answer to a random position so the + distribution is uniform regardless of what the model output. + """ + result = [] + for q in questions: + old_idx = q["correct_index"] + choices = list(q["choices"]) + new_idx = random.randint(0, 3) + if new_idx != old_idx: + choices[old_idx], choices[new_idx] = choices[new_idx], choices[old_idx] + # Re-label A/B/C/D to match new positions + relabeled = [] + for i, c in enumerate(choices): + label = chr(65 + i) + ". " + body = re.sub(r'^[A-D]\.\s*', '', c) + relabeled.append(label + body) + q = dict(q, choices=relabeled, correct_index=new_idx) + result.append(q) + return result + + +async def generate_week_quiz( + client: AsyncOpenAI, + pool, + week_focus: str, + week_tasks: list[str], + n: int = 4, +) -> list[dict]: + """Generate n MCQ for a study-plan week, grounded in wiki knowledge.""" + context = "" + if pool: + try: + from app.math_wiki.storage import pg_vectors, pg_db + query = week_focus + " " + " ".join(week_tasks) + ids = await pg_vectors.query_pgvector(pool, query, top_k=8) + units = await pg_db.get_wiki_units_by_ids(pool, ids) if ids else [] + if units: + context = "\n\n".join( + f"[{u.get('id', '')}] {u.get('content', '')}" + for u in units[:6] + ) + except Exception as exc: + logger.warning("quiz_generator: wiki retrieval failed (%s), continuing without context", exc) + + if not context: + context = "(Không có ngữ cảnh từ kho tri thức — tự tạo câu hỏi dựa trên trọng tâm)" + + prompt = _PROMPT_TMPL.format( + focus=week_focus, + tasks="\n".join(f"- {t}" for t in week_tasks), + context=context, + n=n, + ) + + settings = get_settings() + try: + response = await call_with_retry( + client, + model=settings.default_model, + max_tokens=4000, + messages=[ + {"role": "system", "content": _SYSTEM}, + {"role": "user", "content": prompt}, + ], + ) + raw = _extract_json(response.choices[0].message.content or "{}") + data = json.loads(raw) + questions = data.get("questions", []) + # bloom_level is optional for backward compatibility + questions = [ + q for q in questions + if isinstance(q.get("stem"), str) + and isinstance(q.get("choices"), list) + and len(q["choices"]) == 4 + and isinstance(q.get("correct_index"), int) + ] + questions = await _review_and_patch(client, questions, settings) + questions = _validate_structure(questions) + questions = _shuffle_answer_position(questions) + return questions + except Exception as exc: + logger.error("quiz_generator: generation failed: %s", exc) + raise diff --git a/backend/app/math_wiki/agents/reranker.py b/backend/app/math_wiki/agents/reranker.py new file mode 100644 index 0000000000000000000000000000000000000000..c39caa25980b543d46ab2189f9fa4aef4f99d13d --- /dev/null +++ b/backend/app/math_wiki/agents/reranker.py @@ -0,0 +1,43 @@ +import json +import logging +from openai import AsyncOpenAI +from app.config import get_settings +from app.agent.core import call_with_retry +from app.math_wiki.prompts import MODE_PROMPTS +from app.math_wiki.utils import _extract_json +from app.math_wiki.schemas import WikiUnit + +logger = logging.getLogger(__name__) + + +async def rerank(client: AsyncOpenAI, query: str, candidates: list[WikiUnit]) -> list[str]: + settings = get_settings() + candidate_input = [ + {"id": u.id, "type": u.type, "content": u.content} + for u in candidates + ] + payload = json.dumps({"query": query, "candidates": candidate_input}) + response = await call_with_retry( + client, + model=settings.default_model, + messages=[ + {"role": "system", "content": MODE_PROMPTS["RERANK"]}, + {"role": "user", "content": payload}, + ], + max_tokens=200, + ) + content = _extract_json(response.choices[0].message.content or "{}") + try: + parsed = json.loads(content) + except json.JSONDecodeError: + parsed = {} + top_ids: list[str] = parsed.get("top_ids", []) + + valid_ids = {u.id for u in candidates} + filtered = [uid for uid in top_ids if uid in valid_ids] + if len(filtered) < len(top_ids): + logger.warning( + "Reranker returned %d unknown ID(s), filtered out", + len(top_ids) - len(filtered), + ) + return filtered[:5] diff --git a/backend/app/math_wiki/agents/reviewer.py b/backend/app/math_wiki/agents/reviewer.py new file mode 100644 index 0000000000000000000000000000000000000000..2754eabd3d5be666d0cb163e88991938884a4d58 --- /dev/null +++ b/backend/app/math_wiki/agents/reviewer.py @@ -0,0 +1,75 @@ +import json +import logging +from openai import AsyncOpenAI +from app.config import get_settings +from app.agent.core import call_with_retry +from app.math_wiki.prompts import MODE_PROMPTS +from app.math_wiki.utils import _extract_json +from app.math_wiki.schemas import WikiUnit, ReviewOutput + +logger = logging.getLogger(__name__) + +_VALID_VERDICTS = {"correct", "partial", "incorrect"} + + +def _is_inconsistent(parsed: dict) -> bool: + """Return True when verdict is non-correct but all explanatory fields are empty.""" + if parsed.get("verdict") == "correct": + return False + has_errors = bool([e for e in parsed.get("errors", []) if str(e).strip()]) + has_feedback = bool(str(parsed.get("feedback", "")).strip()) + return not has_errors and not has_feedback + + +async def _call_reviewer(client: AsyncOpenAI, messages: list, settings) -> dict: + response = await call_with_retry( + client, + model=settings.default_model, + messages=messages, + max_tokens=2048, + ) + content = _extract_json(response.choices[0].message.content or "{}") + try: + return json.loads(content) + except json.JSONDecodeError: + raise ValueError("Review agent returned malformed JSON") + + +async def review_solution( + client: AsyncOpenAI, + problem: str, + solution: str, + context: list[WikiUnit], +) -> ReviewOutput: + settings = get_settings() + payload = ( + json.dumps({ + "problem": problem, + "solution": solution, + "context": [{"id": u.id, "content": u.content} for u in context], + }) + + "\n\nRespond with ONLY a JSON object. No prose or markdown." + ) + messages = [ + {"role": "system", "content": MODE_PROMPTS["REVIEW"]}, + {"role": "user", "content": payload}, + ] + + parsed = await _call_reviewer(client, messages, settings) + + if _is_inconsistent(parsed): + logger.warning("Reviewer returned inconsistent response (non-correct with no errors/feedback) — retrying") + parsed = await _call_reviewer(client, messages, settings) + + verdict = parsed.get("verdict", "incorrect") + if verdict not in _VALID_VERDICTS: + verdict = "incorrect" + + return ReviewOutput( + verdict=verdict, + score=str(parsed.get("score", "0/10")), + correct_steps=[str(s) for s in parsed.get("correct_steps", []) if str(s).strip()], + errors=[str(e) for e in parsed.get("errors", []) if str(e).strip()], + feedback=str(parsed.get("feedback", "")), + correct_approach=str(parsed.get("correct_approach", "")), + ) diff --git a/backend/app/math_wiki/agents/solver.py b/backend/app/math_wiki/agents/solver.py new file mode 100644 index 0000000000000000000000000000000000000000..1f369fe87d5d1e91730019a7bb731ed3be1730d5 --- /dev/null +++ b/backend/app/math_wiki/agents/solver.py @@ -0,0 +1,362 @@ +import json +import re +import logging +from openai import AsyncOpenAI +from app.config import get_settings +from app.agent.core import call_with_retry +from app.math_wiki.prompts import MODE_PROMPTS +from app.math_wiki.utils import _extract_json, InsufficientKnowledgeError, VALID_CONFIDENCE +from app.math_wiki.schemas import WikiUnit, SolverOutput + +logger = logging.getLogger(__name__) + +# Bare slug/ID with no whitespace — not a human-readable step +_SLUG_RE = re.compile(r'^[\w-]+$') +_BUOC_RE = re.compile(r'^Bước\s+\d+\s*:', re.UNICODE) +_PHAN_RE = re.compile(r'^\*\*Phần\s+[a-dA-D]', re.UNICODE) # multi-part section headers + +_EXPECTED_KEYS = {"problem_type", "steps", "final_answer", "confidence", "used_knowledge_ids"} + + +def _inject_buoc_prefix(steps: list[str]) -> list[str]: + """Ensure every non-header step starts with 'Bước N: '. + + The SOLVE prompt requires this format but the model often skips it for + simple problems. Post-processing guarantees format compliance without + changing any mathematical content. + """ + result: list[str] = [] + n = 0 + for step in steps: + s = step.strip() + if not s: + continue + # Section headers (multi-part) and already-prefixed steps pass through unchanged + if _PHAN_RE.match(s) or _BUOC_RE.match(s): + if not _PHAN_RE.match(s): + n += 1 # count prefixed steps so subsequent injections are sequential + result.append(s) + else: + n += 1 + result.append(f"Bước {n}: {s}") + return result + + +def _safe_parse_literal(s: str): + """Parse a Python-style literal string using json.loads only (no ast.literal_eval). + Single quotes are normalized to double quotes as a best-effort step.""" + try: + return json.loads(s) + except json.JSONDecodeError: + pass + try: + return json.loads(s.replace("'", '"')) + except json.JSONDecodeError: + return None + + +def _normalize(parsed: dict, valid_ids: set[str], _label_hint: str = "") -> SolverOutput: + """Map whatever JSON structure the model returns into SolverOutput fields.""" + + # Model sometimes wraps the response in a single top-level key (e.g. {"proof": {...}}). + # Unwrap when none of the expected keys are present at the top level. + if parsed and not (_EXPECTED_KEYS & parsed.keys()): + inner = next(iter(parsed.values())) + if isinstance(inner, dict): + parsed = inner + + # Multi-part problems: model may return {"parts": [{"label": "a", "steps": [...], "final_answer": "..."}]} + # Fold into the flat schema: part headers injected into steps, combined final_answer "a) X; b) Y". + raw_parts = parsed.get("parts") + if isinstance(raw_parts, list) and raw_parts and isinstance(raw_parts[0], dict): + combined_steps: list[str] = [] + combined_answers: list[str] = [] + for part in raw_parts: + label = str(part.get("label", "")).strip().rstrip(")") + header = f"**Phần {label})**" if label else None + if header: + combined_steps.append(header) + part_steps = part.get("steps", []) + if isinstance(part_steps, list): + combined_steps.extend(str(s) for s in part_steps if str(s).strip()) + fa = str(part.get("final_answer", part.get("answer", ""))).strip() + if fa: + prefix = f"{label}) " if label else "" + combined_answers.append(f"{prefix}{fa}") + if combined_answers: + parsed = dict(parsed) + parsed["steps"] = combined_steps + parsed["final_answer"] = "; ".join(combined_answers) + parsed.pop("parts", None) + + # --- steps --- + def _step_to_str(s) -> str: + if isinstance(s, str): + # Slug-like strings (no whitespace) are leaked wiki IDs, not steps + if _SLUG_RE.match(s): + return "" + return s + if isinstance(s, dict): + # Model returned {"step": N, "description"/"action"/"detail": "...", "result": "..."} + desc = str(s.get("description") or s.get("action") or s.get("statement") + or s.get("detail") or s.get("work") or s.get("explanation") or "") + result = str(s.get("result") or "") + if not desc: + # Collect string values; for nested dicts, recurse one level deep + parts = [] + for k, v in s.items(): + if k == "step": + continue + if isinstance(v, str) and v.strip(): + parts.append(v) + elif isinstance(v, dict): + parts.append(_step_to_str(v)) + desc = " ".join(p for p in parts if p) + if desc and result and result not in desc: + return f"{desc} → {result}" + return desc or result or str(s) + return str(s) + + steps: list[str] = [] + if isinstance(parsed.get("steps"), list): + steps = [_step_to_str(s) for s in parsed["steps"]] + elif isinstance(parsed.get("solution"), dict): + sol = parsed["solution"] + if isinstance(sol.get("steps"), list): + steps = [_step_to_str(s) for s in sol["steps"]] + if not steps: + for key in ("work", "explanation", "method"): + if val := parsed.get(key): + steps = [str(val)] + break + # Drop empty strings AND slug-like tokens regardless of how they were produced + steps = [s for s in steps if s.strip() and not _SLUG_RE.match(s.strip())] + + # --- final_answer --- + _SIMPLE_VAR = re.compile(r'^[a-zA-Z_]\w{0,3}$') # x, y, x1, y_0 — short math vars only + + def _dict_to_str(d: dict) -> str: + """Convert a dict final_answer to a readable string. + Simple variable keys (x, y) → $x = 3$; Vietnamese/long keys → plain "key: value".""" + parts = [] + for k, v in d.items(): + k_str = str(k).replace("_", " ") + v_str = str(v) + if _SIMPLE_VAR.match(str(k)): + parts.append(f"${k_str} = {v_str}$") + else: + parts.append(f"{k_str}: {v_str}") + return " và ".join(parts) if parts else "" + + def _coerce_final(val) -> str: + if isinstance(val, dict): + return _dict_to_str(val) + s = str(val) + # Model returned a Python dict repr string like "{'x': 3, 'y': 2}" + if s.startswith("{") and s.endswith("}"): + parsed_val = _safe_parse_literal(s) + if isinstance(parsed_val, dict): + return _dict_to_str(parsed_val) + return s + + final_answer: str = "" + for key in ("final_answer", "answer"): + if val := parsed.get(key): + final_answer = _coerce_final(val) + break + if not final_answer: + sol = parsed.get("solution") + if isinstance(sol, str): + final_answer = sol + elif isinstance(sol, dict): + inner = sol.get("answer") or sol.get("result") or sol.get("conclusion") or sol.get("summary") + if inner: + final_answer = _coerce_final(inner) + else: + # Try solution.results (e.g. {"cuc_dai": {"x": -1, "y": 10}, ...}) + results = sol.get("results") + if isinstance(results, dict): + parts = [] + for k, v in results.items(): + k_label = str(k).replace("_", " ") + v_str = _dict_to_str(v) if isinstance(v, dict) else str(v) + parts.append(f"{k_label}: {v_str}") + final_answer = "; ".join(parts) + # Don't call _dict_to_str(sol) — that formats steps+results together which is ugly + if not final_answer: + for key in ("roots", "solutions", "result", "x"): + if val := parsed.get(key): + final_answer = str(val) + break + # Proof-mode fallbacks: model may use "statement" or "conclusion" instead of "final_answer" + if not final_answer: + for key in ("statement", "conclusion", "summary"): + if val := parsed.get(key): + final_answer = str(val) + if not final_answer.rstrip().endswith("∎"): + final_answer = final_answer.rstrip() + " ∎" + break + + # Last-resort catch-all: model used completely non-standard keys. + if not final_answer: + # 1. If steps were extracted, use the last step as final_answer. + if steps: + final_answer = steps[-1] + logger.warning("Derived final_answer from last step (keys: %s)", list(parsed.keys())) + else: + # 2. Collect string values; use them as steps + answer. + all_vals = [str(v) for v in parsed.values() if isinstance(v, str) and str(v).strip()] + if all_vals: + steps = all_vals + final_answer = all_vals[-1] + logger.warning("Used string catch-all for keys: %s", list(parsed.keys())) + else: + # 3. Format list-of-dict values (e.g. extrema, roots) into a readable string. + for v in parsed.values(): + if isinstance(v, list) and v and isinstance(v[0], dict): + parts = [] + for item in v: + parts.append(", ".join(f"{k} = {val}" for k, val in item.items())) + final_answer = "; ".join(parts) + steps = [final_answer] + logger.warning("Formatted list-of-dict value for keys: %s", list(parsed.keys())) + break + + # Guard: model returned a list (JSON array or Python literal) instead of a formatted string. + # Reformat as human-readable "x = a hoặc x = b" — the validator still flags + # ODE cases where roots ≠ general solution. + if final_answer and final_answer.lstrip().startswith('['): + parsed_fa = _safe_parse_literal(final_answer) + if isinstance(parsed_fa, list) and parsed_fa: + raw_items = [str(v) for v in parsed_fa] + + def _has_equation(v: str) -> bool: + inner = v.strip() + if inner.startswith('$') and inner.endswith('$'): + inner = inner[1:-1] + return '=' in inner + + parts = [item if _has_equation(item) else f"x = {item}" for item in raw_items] + final_answer = parts[0] if len(parts) == 1 else " hoặc ".join(parts) + logger.warning("Reformatted list final_answer to: %r", final_answer) + + # --- problem_type --- + _LABEL_VI = { + "algebra": "đại số", "geometry": "hình học", "calculus": "giải tích", + "trigonometry": "lượng giác", "statistics": "thống kê", "probability": "xác suất", + "combinatorics": "tổ hợp", "number_theory": "số học", + "complex_numbers": "số phức", "sequences": "dãy số", + "vectors": "vectơ", "functions": "hàm số", + } + _raw_pt = str(parsed.get("problem_type", parsed.get("method", ""))) + if _raw_pt: + problem_type = _raw_pt + else: + problem_type = _LABEL_VI.get(_label_hint, _label_hint or "đại số") + + # --- used_knowledge_ids — keep only IDs that actually exist in context --- + raw_ids = parsed.get("used_knowledge_ids", []) + if not isinstance(raw_ids, list): + raw_ids = [] + used_ids = [uid for uid in raw_ids if uid in valid_ids] + + # --- confidence --- + confidence = str(parsed.get("confidence", "medium")) + if confidence not in VALID_CONFIDENCE: + confidence = "medium" + + if not final_answer: + raise InsufficientKnowledgeError("Solver returned no answer") + if final_answer.strip().upper() == "INSUFFICIENT_KNOWLEDGE": + raise InsufficientKnowledgeError("Solver indicated insufficient knowledge") + + # Warn when final_answer is not mentioned in any step — likely a commit-before-compute error. + # Skip for multi-part answers (format "a) X; b) Y") — the combined string won't appear verbatim. + _is_multipart = bool(re.match(r'^[a-dA-D]\)', final_answer.strip())) + if steps and not _is_multipart and not any(final_answer.lower()[:20] in s.lower() for s in steps): + logger.warning( + "final_answer %r not found in steps — possible answer/step mismatch", final_answer + ) + + if not steps: + logger.warning("solver: no parseable steps in response — using final_answer as sole step. raw=%r", str(parsed)[:200]) + + final_steps = _inject_buoc_prefix(steps or [final_answer]) + + return SolverOutput( + problem_type=problem_type, + used_knowledge_ids=used_ids, + steps=final_steps, + final_answer=final_answer, + confidence=confidence, + ) + + +async def solve( + client: AsyncOpenAI, + problem_text: str, + context: list[WikiUnit], + label: str = "", + prior_failure: str | None = None, +) -> SolverOutput: + settings = get_settings() + payload = json.dumps({ + "problem": problem_text, + "context": [{"id": u.id, "type": u.type, "content": u.content} for u in context], + }) + if prior_failure: + payload += f"\n\n⚠ Lưu ý từ lần giải trước: {prior_failure}" + payload += "\n\nRespond with ONLY a JSON object. No prose or markdown." + response = await call_with_retry( + client, + model=settings.default_model, + messages=[ + {"role": "system", "content": MODE_PROMPTS["SOLVE"]}, + {"role": "user", "content": payload}, + ], + max_tokens=4096, + ) + content = _extract_json(response.choices[0].message.content or "{}") + try: + parsed = json.loads(content) + except json.JSONDecodeError: + # Truncated response — retry without context payload (shorter prompt, better chance) + logger.warning("Solver response truncated; retrying without context") + bare_payload = ( + json.dumps({"problem": problem_text, "context": []}) + + "\n\nRespond with ONLY a JSON object. No prose or markdown." + ) + retry_response = await call_with_retry( + client, + model=settings.default_model, + messages=[ + {"role": "system", "content": MODE_PROMPTS["SOLVE"]}, + {"role": "user", "content": bare_payload}, + ], + max_tokens=4096, + ) + retry_content = _extract_json(retry_response.choices[0].message.content or "{}") + try: + parsed = json.loads(retry_content) + except json.JSONDecodeError: + raise InsufficientKnowledgeError("Malformed solver response") + valid_ids = {u.id for u in context} + result = _normalize(parsed, valid_ids, _label_hint=label) + + # Guard: if final_answer echoes the problem (starts with an imperative verb and overlaps + # significantly with the question), replace it with the last non-trivial step. + _STARTERS = ("tìm ", "cho ", "tính ", "giải ", "chứng ", "hãy ", "biết ", "xét ") + fa_lower = result.final_answer.lower().strip() + pt_lower = problem_text.lower() + if (any(fa_lower.startswith(s) for s in _STARTERS) + and pt_lower[:40] in fa_lower): + candidate = next( + (s for s in reversed(result.steps) + if s.strip() and not any(s.lower().strip().startswith(st) for st in _STARTERS)), + None, + ) + if candidate: + logger.warning("final_answer resembled the question — substituted last useful step") + result = result.model_copy(update={"final_answer": candidate}) + + return result diff --git a/backend/app/math_wiki/agents/sympy_verifier.py b/backend/app/math_wiki/agents/sympy_verifier.py new file mode 100644 index 0000000000000000000000000000000000000000..462237cf4f5bf3ee5e2a09ba4e549d73d35fb112 --- /dev/null +++ b/backend/app/math_wiki/agents/sympy_verifier.py @@ -0,0 +1,274 @@ +"""Deterministic symbolic verification of math solutions using SymPy. + +Returns (True, []) on confirmed correct, (False, [issues]) on confirmed wrong, +(None, []) when inconclusive (parse failure, proof/geometry/combinatorics, etc.). +Never raises — all exceptions produce (None, []) to avoid false negatives. +""" +from __future__ import annotations +import logging +import re + +logger = logging.getLogger(__name__) + +# Topics where symbolic substitution doesn't apply. +_SKIP_TYPES = frozenset({ + "chứng minh", "proof", "geometry", "hình học", + "combinatorics", "tổ hợp", "statistics", "thống kê", + "probability", "xác suất", "number_theory", "số học", +}) + + +def _should_skip(problem_type: str) -> bool: + pt = problem_type.lower() + return any(s in pt for s in _SKIP_TYPES) + + +# ── answer parsing ──────────────────────────────────────────────────────────── + +def _parse_candidates(final_answer: str) -> list[str]: + """Extract individual candidate value strings from a final_answer string. + + Handles forms like: + "x = 2 hoặc x = 3" → ["2", "3"] + "$x = 2$ hoặc $x = 3$" → ["2", "3"] + "x = 2" → ["2"] + "x1 = 1, x2 = -1" → ["1", "-1"] + Returns raw value strings to be parsed by SymPy. + """ + # Strip LaTeX delimiters + text = re.sub(r'\$', '', final_answer) + # Split on Vietnamese "hoặc", commas, semicolons, or "or" + parts = re.split(r'\bhoặc\b|\bor\b|[,;]', text, flags=re.IGNORECASE) + values: list[str] = [] + for part in parts: + part = part.strip() + # Look for "var = value" pattern + m = re.search(r'=\s*(.+)$', part) + if m: + values.append(m.group(1).strip()) + return values + + +def _parse_system_assignments(final_answer: str) -> dict[str, str] | None: + """Parse 'x = a, y = b' style system answers into {var: value} dict.""" + text = re.sub(r'\$', '', final_answer) + parts = re.split(r'\bvà\b|\band\b|[,;]', text, flags=re.IGNORECASE) + assignments: dict[str, str] = {} + for part in parts: + part = part.strip() + m = re.match(r'^([a-zA-Z]\w*)\s*=\s*(.+)$', part) + if m: + assignments[m.group(1).strip()] = m.group(2).strip() + return assignments if len(assignments) >= 2 else None + + +def _extract_ode_solution(final_answer: str): + """Extract y = f(x) from an ODE general solution string. + Returns a SymPy expression for f(x), or None on failure.""" + try: + import sympy as sp + text = re.sub(r'\$', '', final_answer).strip() + # Match "y = ..." at start of answer + m = re.match(r'y\s*=\s*(.+)', text, re.IGNORECASE) + if not m: + return None + expr_str = m.group(1).strip() + # Replace C1, C2 constants with SymPy symbols + expr_str = re.sub(r'\bC_?(\d+)\b', r'C\1', expr_str) + x, C1, C2, C3 = sp.symbols('x C1 C2 C3') + local_dict = { + 'x': x, 'C1': C1, 'C2': C2, 'C3': C3, + 'e': sp.E, 'pi': sp.pi, 'sin': sp.sin, 'cos': sp.cos, + 'exp': sp.exp, 'ln': sp.ln, 'sqrt': sp.sqrt, + } + return sp.sympify(expr_str, locals=local_dict) + except Exception: + return None + + +def _strip_prose(s: str) -> str: + """Strip leading prose words from a potential math expression.""" + m = re.search(r'[0-9x\(\-\+\*\/\^\.]', s) + return s[m.start():] if m else s + + +# ── verification routines ───────────────────────────────────────────────────── + +def _verify_equation(problem_text: str, candidate_values: list[str]) -> tuple[bool | None, list[str]]: + """Substitute each candidate into the equation extracted from problem_text.""" + try: + import sympy as sp + text = re.sub(r'\$', '', problem_text) + x = sp.Symbol('x') + local = {'x': x, 'sqrt': sp.sqrt, 'abs': sp.Abs, 'log': sp.log, 'ln': sp.ln} + + # Try each "lhs = rhs" match; skip those whose lhs won't sympify + expr = None + for eq_match in re.finditer(r'([^:=\n]+)=([^:=\n]+)', text): + lhs_raw = _strip_prose(eq_match.group(1).strip()) + rhs_raw = eq_match.group(2).strip() + try: + lhs = sp.sympify(lhs_raw, locals=local) + rhs = sp.sympify(rhs_raw, locals=local) + expr = lhs - rhs + break + except Exception: + continue + + if expr is None: + return None, [] + + issues: list[str] = [] + valid_count = 0 + for val_str in candidate_values: + try: + val = sp.sympify(val_str, locals={'sqrt': sp.sqrt}) + residual = sp.simplify(expr.subs(x, val)) + if residual == 0: + valid_count += 1 + else: + issues.append(f"x = {val_str} does not satisfy the equation (residual = {residual})") + except Exception: + return None, [] # can't evaluate — inconclusive + + if issues: + return False, issues + if valid_count > 0: + return True, [] + return None, [] + except Exception as exc: + logger.debug("_verify_equation failed: %s", exc) + return None, [] + + +def _verify_system(problem_text: str, assignments: dict[str, str]) -> tuple[bool | None, list[str]]: + """Substitute variable assignments into all equations in problem_text.""" + try: + import sympy as sp + text = re.sub(r'\$', '', problem_text) + # Split on conjunctions first so each clause is a single equation candidate + clauses = re.split(r'\bvà\b|\band\b|[;\n]', text, flags=re.IGNORECASE) + + syms = {v: sp.Symbol(v) for v in assignments} + local = {**syms, 'sqrt': sp.sqrt, 'abs': sp.Abs} + + val_map = {} + for var, val_str in assignments.items(): + try: + val_map[syms[var]] = sp.sympify(val_str, locals=local) + except Exception: + return None, [] + + issues: list[str] = [] + checked = 0 + for clause in clauses[:4]: # limit to first 4 clauses + if '=' not in clause: + continue + parts = clause.split('=', 1) + if len(parts) != 2: + continue + try: + lhs = sp.sympify(_strip_prose(parts[0].strip()), locals=local) + rhs = sp.sympify(parts[1].strip(), locals=local) + residual = sp.simplify((lhs - rhs).subs(val_map)) + checked += 1 + if residual != 0: + issues.append(f"Assignment {assignments} does not satisfy equation '{clause.strip()}'") + except Exception: + continue + + if not checked: + return None, [] + return (False, issues) if issues else (True, []) + except Exception as exc: + logger.debug("_verify_system failed: %s", exc) + return None, [] + + +def _verify_ode(problem_text: str, solution_expr) -> tuple[bool | None, list[str]]: + """Differentiate the proposed solution and substitute into the ODE.""" + try: + import sympy as sp + text = re.sub(r'\$', '', problem_text) + # Extract ODE — look for y'' / y' notation and convert + ode_match = re.search(r"y[''′]+[^=\n]*=[^\n]+", text) + if not ode_match: + return None, [] + + x = sp.Symbol('x') + y_fn = sp.Function('y') + y = solution_expr # already a SymPy expression in x + + ode_str = ode_match.group(0) + # Replace y'', y' with computed derivatives + d2y = sp.diff(y, x, 2) + dy = sp.diff(y, x) + + # Build ODE expression by substituting into string-parsed version + ode_expr_str = ( + ode_str + .replace("y''", f"({d2y})") + .replace("y'", f"({dy})") + .replace("y", f"({y})") + ) + parts = ode_expr_str.split('=', 1) + if len(parts) != 2: + return None, [] + + local = {'x': x, 'exp': sp.exp, 'sin': sp.sin, 'cos': sp.cos, + 'sqrt': sp.sqrt, 'ln': sp.ln, 'C1': sp.Symbol('C1'), + 'C2': sp.Symbol('C2'), 'C3': sp.Symbol('C3')} + lhs = sp.sympify(parts[0].strip(), locals=local) + rhs = sp.sympify(parts[1].strip(), locals=local) + residual = sp.simplify(lhs - rhs) + if residual == 0: + return True, [] + return False, [f"Proposed ODE solution does not satisfy the equation (residual = {residual})"] + except Exception as exc: + logger.debug("_verify_ode failed: %s", exc) + return None, [] + + +# ── public API ──────────────────────────────────────────────────────────────── + +def sympy_verify( + problem_text: str, + final_answer: str, + problem_type: str = "", +) -> tuple[bool | None, list[str]]: + """Verify final_answer against problem_text symbolically. + + Returns: + (True, []) — confirmed correct + (False, [issues]) — confirmed wrong + (None, []) — inconclusive (proof, geometry, parse failure, etc.) + """ + try: + if _should_skip(problem_type): + return None, [] + + # Multi-part answer ("a) X; b) Y") — can't verify symbolically without splitting the problem + if re.match(r'^[a-dA-D]\)', final_answer.strip()): + return None, [] + + # ODE path: problem_type contains "vi phân" or "ode" and answer has y = + is_ode = any(k in problem_type.lower() for k in ("vi phân", "ode", "differential")) + if is_ode or "y = " in final_answer.lower(): + sol = _extract_ode_solution(final_answer) + if sol is not None: + return _verify_ode(problem_text, sol) + + # System of equations: answer has multiple var=val pairs + assignments = _parse_system_assignments(final_answer) + if assignments: + return _verify_system(problem_text, assignments) + + # Single/multiple roots + candidates = _parse_candidates(final_answer) + if candidates: + return _verify_equation(problem_text, candidates) + + return None, [] + except Exception as exc: + logger.debug("sympy_verify top-level exception: %s", exc) + return None, [] diff --git a/backend/app/math_wiki/agents/validator.py b/backend/app/math_wiki/agents/validator.py new file mode 100644 index 0000000000000000000000000000000000000000..e23158fab3ecdc944ff985aa825b41dc476b7b83 --- /dev/null +++ b/backend/app/math_wiki/agents/validator.py @@ -0,0 +1,59 @@ +import json +import logging +from openai import AsyncOpenAI +from app.config import get_settings +from app.agent.core import call_with_retry +from app.math_wiki.prompts import MODE_PROMPTS +from app.math_wiki.utils import _extract_json +from app.math_wiki.schemas import WikiUnit, SolverOutput, ValidationResult +from app.math_wiki.agents.sympy_verifier import sympy_verify + +logger = logging.getLogger(__name__) + + +async def validate( + client: AsyncOpenAI, + solver_output: SolverOutput, + context: list[WikiUnit], + problem_text: str = "", +) -> ValidationResult: + settings = get_settings() + solver_dict = { + "problem_type": solver_output.problem_type, + "steps": solver_output.steps, + "final_answer": solver_output.final_answer, + "confidence": solver_output.confidence, + } + payload = json.dumps({ + "solver_output": solver_dict, + "context": [u.model_dump() for u in context], + }) + response = await call_with_retry( + client, + model=settings.default_model, + messages=[ + {"role": "system", "content": MODE_PROMPTS["VALIDATE"]}, + {"role": "user", "content": payload}, + ], + max_tokens=600, + ) + content = _extract_json(response.choices[0].message.content or "{}") + try: + parsed = json.loads(content) + except json.JSONDecodeError: + logger.warning("Validator returned malformed JSON — skipping UI issue") + return ValidationResult(valid=False, issues=[]) + llm_result = ValidationResult(**parsed) + + # Deterministic override: if SymPy confirms the answer is wrong, trust it over LLM. + if problem_text: + sympy_valid, sympy_issues = sympy_verify( + problem_text=problem_text, + final_answer=solver_output.final_answer, + problem_type=solver_output.problem_type, + ) + if sympy_valid is False: + logger.debug("SymPy overrides LLM validation — issues: %s", sympy_issues) + return ValidationResult(valid=False, issues=llm_result.issues + sympy_issues) + + return llm_result diff --git a/backend/app/math_wiki/autoirt.py b/backend/app/math_wiki/autoirt.py new file mode 100644 index 0000000000000000000000000000000000000000..27bc35cf43be6a79a5b254c261e715b1c2eb2c86 --- /dev/null +++ b/backend/app/math_wiki/autoirt.py @@ -0,0 +1,74 @@ +"""AutoIRT: data-driven IRT parameter recalibration from response logs. + +Uses MLE to estimate irt_a (discrimination) and irt_b (difficulty) from +accumulated solution_logs responses. Requires minimum 50 responses per question. +irt_c (guessing) is fixed at 0.25 for MCQ (4 choices). +""" +import logging +import math + +logger = logging.getLogger(__name__) + +_MIN_RESPONSES = 50 +_GUESSING = 0.25 + + +def _irt_prob(theta: float, a: float, b: float, c: float = _GUESSING) -> float: + """3PL IRT probability of correct response.""" + return c + (1 - c) / (1 + math.exp(-a * (theta - b))) + + +def _mle_difficulty(correct: int, total: int, a: float = 1.0, c: float = _GUESSING) -> float: + """Simple MLE estimate of irt_b given observed proportion correct.""" + if total == 0: + return 0.0 + p = max(c + 0.01, min(0.99, correct / total)) + # Invert 3PL: b = theta - (1/a) * log((p-c)/(1-p)) + try: + b = -(1 / a) * math.log((p - c) / (1 - p)) + except (ValueError, ZeroDivisionError): + b = 0.0 + return round(max(-3.0, min(3.0, b)), 2) + + +def _mle_discrimination(correct: int, total: int, irt_b: float) -> float: + """Estimate irt_a from point-biserial correlation proxy.""" + if total < _MIN_RESPONSES: + return 1.0 + p = correct / total + if p <= 0 or p >= 1: + return 1.0 + # Bock's approximation: a ∝ p(1-p) / phi(b) + phi_b = math.exp(-irt_b ** 2 / 2) / math.sqrt(2 * math.pi) + if phi_b < 0.001: + return 1.0 + a = (p * (1 - p)) / phi_b + return round(max(0.5, min(3.0, a)), 2) + + +async def run_recalibration(pool) -> dict: + """Recalibrate IRT params for all questions with ≥50 responses.""" + rows = await pool.fetch(""" + SELECT sl.problem_id, + COUNT(*) as total, + SUM(CASE WHEN sl.actual_correct = 1 THEN 1 ELSE 0 END) as correct + FROM solution_logs sl + WHERE sl.problem_id IS NOT NULL AND sl.actual_correct IS NOT NULL + GROUP BY sl.problem_id + HAVING total >= ? + """, _MIN_RESPONSES) + + if not rows: + return {"recalibrated": 0, "message": f"No questions with ≥{_MIN_RESPONSES} responses yet"} + + updated = 0 + for row in rows: + irt_b = _mle_difficulty(row["correct"], row["total"]) + irt_a = _mle_discrimination(row["correct"], row["total"], irt_b) + await pool.execute( + "UPDATE problems SET irt_a = ?, irt_b = ? WHERE problem_id = ?", + irt_a, irt_b, row["problem_id"], + ) + updated += 1 + + return {"recalibrated": updated, "min_responses_threshold": _MIN_RESPONSES} diff --git a/backend/app/math_wiki/bobcat.py b/backend/app/math_wiki/bobcat.py new file mode 100644 index 0000000000000000000000000000000000000000..09fb1f76aa19f01819af1ab9fd8f652cc6af60c4 --- /dev/null +++ b/backend/app/math_wiki/bobcat.py @@ -0,0 +1,85 @@ +"""BOBCAT: Bilevel Optimization-Based CAT (data-gated). + +Reference: arXiv 2108.07386, IJCAI 2021 +GitHub: github.com/arghosh/BOBCAT + +This module provides the BOBCAT question selection policy as a drop-in +replacement for MaxInformationSelector once sufficient data is available. + +DATA GATE: Requires ≥500 completed adaptive exam sessions in exam_sessions table. +Deploy: Train offline with `python -m app.math_wiki.bobcat_train`, then + set BOBCAT_ENABLED=true in environment to activate. +""" +import logging +import os + +logger = logging.getLogger(__name__) + +_ENABLED = os.getenv("BOBCAT_ENABLED", "false").lower() == "true" +_MIN_SESSIONS = 500 + +_model = None + + +def is_ready() -> bool: + """Returns True only when data gate is met and model is loaded.""" + return _ENABLED and _model is not None + + +def select_next_question( + item_params, # np.ndarray shape (n_items, 4) — [a, b, c, d] + administered_items, # list[int] — indices already administered + est_theta: float, # current ability estimate +) -> int: + """Select next question using BOBCAT policy. + + Falls back to MaxInformationSelector if model not loaded. + """ + if not is_ready(): + # Graceful degradation: use classical Fisher Information selection + try: + from catsim.selection import MaxInformationSelector + import numpy as np + selector = MaxInformationSelector() + return int(selector.select( + items=item_params, + administered_items=np.array(administered_items), + est_theta=est_theta, + )) + except Exception as exc: + logger.warning("BOBCAT fallback to MaxInfo failed: %s", exc) + # Last resort: pick first un-administered item + admin_set = set(administered_items) + for i in range(len(item_params)): + if i not in admin_set: + return i + return 0 + + # BOBCAT neural policy (activated when model loaded) + try: + import torch + import numpy as np + history_vec = _encode_history(item_params, administered_items, est_theta) + with torch.no_grad(): + scores = _model(torch.tensor(history_vec, dtype=torch.float32).unsqueeze(0)) + scores = scores.squeeze(0).numpy() + # Mask already-administered items + admin_set = set(administered_items) + for idx in admin_set: + scores[idx] = -1e9 + return int(np.argmax(scores)) + except Exception as exc: + logger.warning("BOBCAT selection failed (%s), falling back", exc) + return select_next_question(item_params, administered_items, est_theta) + + +def _encode_history(item_params, administered_items, est_theta: float): + """Encode student history as fixed-size input vector for BOBCAT network.""" + import numpy as np + n_items = len(item_params) + vec = np.zeros(n_items + 1) + for idx in administered_items: + if 0 <= idx < n_items: + vec[idx] = 1.0 + vec[-1] = est_theta / 3.0 # normalize theta to [-1, 1] approx + return vec diff --git a/backend/app/math_wiki/cache.py b/backend/app/math_wiki/cache.py new file mode 100644 index 0000000000000000000000000000000000000000..3d3a08749e5415dbbc428d345de195006bec04d6 --- /dev/null +++ b/backend/app/math_wiki/cache.py @@ -0,0 +1,43 @@ +"""LRU semantic cache for retrieval results. + +Key: normalized query text hash. +Value: list of wiki_unit IDs (retrieval result). +TTL: 1 hour. Max entries: 512. +""" +import hashlib +import logging +import time +from collections import OrderedDict + +logger = logging.getLogger(__name__) + +_MAX_ENTRIES = 512 +_TTL_SECONDS = 3600 + +_cache: OrderedDict[str, tuple[list[str], float]] = OrderedDict() + + +def _key(query: str) -> str: + normalized = " ".join(query.lower().split()) + return hashlib.sha256(normalized.encode()).hexdigest()[:16] + + +def get(query: str) -> list[str] | None: + k = _key(query) + entry = _cache.get(k) + if entry is None: + return None + ids, ts = entry + if time.time() - ts > _TTL_SECONDS: + del _cache[k] + return None + _cache.move_to_end(k) + return ids + + +def put(query: str, ids: list[str]) -> None: + k = _key(query) + _cache[k] = (ids, time.time()) + _cache.move_to_end(k) + while len(_cache) > _MAX_ENTRIES: + _cache.popitem(last=False) diff --git a/backend/app/math_wiki/context_builder.py b/backend/app/math_wiki/context_builder.py new file mode 100644 index 0000000000000000000000000000000000000000..5cbb96e65b5da776c45ccce2b37e8b825c68a45f --- /dev/null +++ b/backend/app/math_wiki/context_builder.py @@ -0,0 +1,41 @@ +"""Ordered context assembly for the math solver. + +Orders wiki units: prerequisites → core concept → formulas/theorems → examples → mistakes. +Caps total serialized content at ~8000 tokens (approx 32000 chars). +""" +from app.math_wiki.schemas import WikiUnit + +_NODE_ORDER = { + "theorem": 1, + "formula": 2, + "concept": 3, + "procedure": 4, + "pattern": 5, + "example": 6, + "mistake": 7, +} +_MAX_CHARS = 32_000 + + +def _rank(unit: WikiUnit) -> int: + node_type = getattr(unit, "node_type", None) or unit.type or "pattern" + return _NODE_ORDER.get(node_type, 5) + + +def build_context(units: list[WikiUnit]) -> str: + """Serialize wiki units into an ordered context string for the solver prompt.""" + if not units: + return "" + ordered = sorted(units, key=_rank) + parts = [] + total = 0 + for unit in ordered: + label = (getattr(unit, "node_type", None) or unit.type or "unit").upper() + topic = (unit.topic or "").upper() + content = unit.content or "" + entry = f"[{label}: {topic}]\n{content}\n---" + if total + len(entry) > _MAX_CHARS: + break + parts.append(entry) + total += len(entry) + return "\n".join(parts) diff --git a/backend/app/math_wiki/crag.py b/backend/app/math_wiki/crag.py new file mode 100644 index 0000000000000000000000000000000000000000..dc7361d9f119bd76dbabeee4ca8d758455ab4162 --- /dev/null +++ b/backend/app/math_wiki/crag.py @@ -0,0 +1,57 @@ +"""Corrective RAG scoring gate. + +Scores each retrieved candidate against the query using cosine similarity. +Discards candidates below threshold. If all are discarded, rewrites the query +and signals a retry. +""" +import json +import logging +import numpy as np +from app.config import get_settings + +logger = logging.getLogger(__name__) + + +def _cosine(a: list[float], b: list[float]) -> float: + va = np.array(a, dtype=np.float32) + vb = np.array(b, dtype=np.float32) + denom = (np.linalg.norm(va) + 1e-9) * (np.linalg.norm(vb) + 1e-9) + return float(np.dot(va, vb) / denom) + + +def score_candidates( + query_embedding: list[float], + candidates, # list[WikiUnit] + threshold: float | None = None, +) -> tuple[list, bool]: + """Score candidates, filter below threshold. + + Returns (filtered_candidates, should_retry). + should_retry=True when ALL candidates were below threshold. + """ + settings = get_settings() + t = threshold if threshold is not None else getattr(settings, "crag_threshold", 0.20) + + if not candidates or not query_embedding: + return candidates, False + + scored = [] + for unit in candidates: + try: + emb = json.loads(unit.embedding) if isinstance(unit.embedding, str) else unit.embedding + if emb: + score = _cosine(query_embedding, emb) + scored.append((score, unit)) + except Exception: + scored.append((0.0, unit)) # keep on parse error + + if not scored: + return candidates, False + + above = [(s, u) for s, u in scored if s >= t] + if not above: + logger.info("CRAG: all %d candidates below threshold %.2f — flagging retry", len(scored), t) + return [], True # signal retry + + above.sort(key=lambda x: x[0], reverse=True) + return [u for _, u in above], False diff --git a/backend/app/math_wiki/deep_cat.py b/backend/app/math_wiki/deep_cat.py new file mode 100644 index 0000000000000000000000000000000000000000..cbf70e41a6599f334a630e1153318d264be43634 --- /dev/null +++ b/backend/app/math_wiki/deep_cat.py @@ -0,0 +1,87 @@ +"""Deep CAT with Reinforcement Learning (data-gated). + +Reference: arXiv 2502.19275, 2025 +Uses double deep Q-learning for long-term optimal item selection. + +DATA GATE: Requires ≥5,000 completed adaptive exam sessions. + Current system needs significant user history before training. +Deploy: Accumulate exam_sessions data, then run offline RL training. + Set DEEP_CAT_ENABLED=true to activate once trained. +""" +import logging +import os + +logger = logging.getLogger(__name__) + +_ENABLED = os.getenv("DEEP_CAT_ENABLED", "false").lower() == "true" +_MIN_SESSIONS = 5_000 + +_q_network = None # Primary Q-network +_target_network = None # Target Q-network (double DQN) + + +def is_ready() -> bool: + return _ENABLED and _q_network is not None + + +def select_next_question( + state_vector, # np.ndarray — encoded student state + administered_items, # list[int] + n_items: int, +) -> int: + """Select next question via RL Q-network. + + Falls back to BOBCAT → MaxInformationSelector when not ready. + """ + if not is_ready(): + # Cascade: try BOBCAT first, then MaxInfo + try: + from app.math_wiki.bobcat import select_next_question as bobcat_select + return bobcat_select( + item_params=state_vector, + administered_items=administered_items, + est_theta=0.0, + ) + except Exception: + pass + # Raw fallback + admin_set = set(administered_items) + for i in range(n_items): + if i not in admin_set: + return i + return 0 + + try: + import torch + import numpy as np + admin_set = set(administered_items) + with torch.no_grad(): + q_values = _q_network( + torch.tensor(state_vector, dtype=torch.float32).unsqueeze(0) + ).squeeze(0).numpy() + q_values[list(admin_set)] = -1e9 # mask administered + return int(np.argmax(q_values)) + except Exception as exc: + logger.warning("Deep CAT selection failed (%s), falling back", exc) + admin_set = set(administered_items) + for i in range(n_items): + if i not in admin_set: + return i + return 0 + + +class DQNNetwork: + """Placeholder for the Double DQN architecture. + + Architecture (to be implemented during training phase): + - Input: state_dim (student history encoded) + - Hidden: 2 × 256 ReLU layers + - Output: n_items Q-values (one per question) + + Training algorithm: + - Replay buffer of (state, action, reward, next_state, done) tuples + - Target network updated every 100 steps (Polyak averaging tau=0.005) + - Reward: delta in ability estimate accuracy at exam completion + - Epsilon-greedy exploration (epsilon decays 1.0 → 0.05 over 10k steps) + """ + pass # Full implementation added during training phase diff --git a/backend/app/math_wiki/difficulty_estimator.py b/backend/app/math_wiki/difficulty_estimator.py new file mode 100644 index 0000000000000000000000000000000000000000..beef7ecff3f280067bc23481c5c68adf430e6a70 --- /dev/null +++ b/backend/app/math_wiki/difficulty_estimator.py @@ -0,0 +1,61 @@ +"""LLM-based IRT difficulty calibration. + +Uses Claude Haiku's self-reported confidence to estimate irt_b (difficulty) +for each question. Confidence near 1.0 → easy (irt_b negative), near 0 → hard (irt_b positive). +Mapping: irt_b = (0.5 - confidence) * 4 (range: -2.0 to +2.0) +""" +import json +import logging +from openai import AsyncOpenAI +from app.config import get_settings +from app.agent.core import call_with_retry + +logger = logging.getLogger(__name__) + + +async def calibrate_question(client: AsyncOpenAI, problem_id: str, problem_text: str) -> float | None: + """Estimate irt_b for a single question. Returns irt_b in range [-2.0, 2.0].""" + settings = get_settings() + prompt = ( + f"Giải bài toán sau và đánh giá mức độ tự tin của bạn từ 0.0 đến 1.0:\n\n{problem_text}\n\n" + "Trả về JSON: {\"confidence\": 0.8} — chỉ confidence, không giải thích thêm." + ) + try: + resp = await call_with_retry( + client, + model=settings.haiku_model, + max_tokens=50, + messages=[{"role": "user", "content": prompt}], + response_format={"type": "json_object"}, + ) + parsed = json.loads(resp.choices[0].message.content or "{}") + confidence = float(parsed.get("confidence", 0.5)) + confidence = max(0.0, min(1.0, confidence)) + irt_b = round((0.5 - confidence) * 4, 2) + logger.debug("Calibrated %s: confidence=%.2f → irt_b=%.2f", problem_id, confidence, irt_b) + return irt_b + except Exception as exc: + logger.warning("calibrate_question failed for %s: %s", problem_id, exc) + return None + + +async def run_calibration_job(pool, client: AsyncOpenAI, batch_size: int = 50) -> dict: + """Calibrate irt_b for all uncalibrated questions (irt_b == 0.0 and irt_a == 1.0 = default).""" + rows = await pool.fetch( + "SELECT problem_id, problem_text FROM problems WHERE irt_a = 1.0 AND irt_b = 0.0 LIMIT ?", + batch_size, + ) + if not rows: + return {"calibrated": 0, "message": "All questions already calibrated"} + + calibrated = 0 + for row in rows: + irt_b = await calibrate_question(client, row["problem_id"], row["problem_text"]) + if irt_b is not None: + await pool.execute( + "UPDATE problems SET irt_b = ? WHERE problem_id = ?", + irt_b, row["problem_id"], + ) + calibrated += 1 + + return {"calibrated": calibrated, "total_remaining": len(rows) - calibrated} diff --git a/backend/app/math_wiki/dkvmn.py b/backend/app/math_wiki/dkvmn.py new file mode 100644 index 0000000000000000000000000000000000000000..017f50a9f279c8ad7b1e4b153ac88f818d962ba3 --- /dev/null +++ b/backend/app/math_wiki/dkvmn.py @@ -0,0 +1,133 @@ +"""DKVMN (Dynamic Key-Value Memory Networks) for knowledge tracing. + +This module provides: +1. The DKVMN PyTorch model class (for offline training) +2. A predict_mastery() function that reads from concept_mastery/concept_elo tables + until a trained model is available. + +Training: run `python -m app.math_wiki.dkvmn_train` after accumulating solution_logs data. +The trained model weights are saved to data/dkvmn_weights.pt. +""" +import logging +import os + +logger = logging.getLogger(__name__) + +_MODEL_PATH = os.path.join(os.path.dirname(__file__), "..", "..", "data", "dkvmn_weights.pt") +_model = None +_model_loaded = False + +# Try to load PyTorch — gracefully degrade if not available +try: + import torch + import torch.nn as nn + import torch.nn.functional as F + _TORCH_AVAILABLE = True +except ImportError: + _TORCH_AVAILABLE = False + logger.info("PyTorch not available — DKVMN will use DB mastery fallback") + + +if _TORCH_AVAILABLE: + class DKVMN(nn.Module): + """Dynamic Key-Value Memory Network for knowledge tracing. + + Architecture: + - Key memory: static concept embeddings (n_concepts × key_dim) + - Value memory: dynamic knowledge state (memory_size × value_dim) + - Input: (question_embed, response) pairs + - Output: P(correct) per concept + """ + def __init__(self, n_concepts: int = 50, memory_size: int = 20, + key_dim: int = 50, value_dim: int = 200): + super().__init__() + self.key_memory = nn.Parameter(torch.randn(memory_size, key_dim)) + self.value_memory = nn.Parameter(torch.randn(memory_size, value_dim)) + self.key_embed = nn.Embedding(n_concepts, key_dim) + self.erase_linear = nn.Linear(value_dim, value_dim) + self.add_linear = nn.Linear(value_dim, value_dim) + self.fc_output = nn.Linear(value_dim + key_dim, 1) + + def forward(self, concept_ids: 'torch.Tensor', responses: 'torch.Tensor'): + """ + Args: + concept_ids: (batch, seq_len) concept indices + responses: (batch, seq_len) 0/1 correctness + Returns: + predictions: (batch, seq_len) P(correct) for each step + """ + batch_size, seq_len = concept_ids.shape + value_mem = self.value_memory.unsqueeze(0).expand(batch_size, -1, -1).clone() + predictions = [] + + for t in range(seq_len): + q_embed = self.key_embed(concept_ids[:, t]) # (batch, key_dim) + # Correlation weights + w = F.softmax(q_embed @ self.key_memory.T, dim=-1) # (batch, memory_size) + # Read + r = (w.unsqueeze(-1) * value_mem).sum(dim=1) # (batch, value_dim) + # Predict + pred = torch.sigmoid(self.fc_output(torch.cat([r, q_embed], dim=-1))) + predictions.append(pred) + # Write (update value memory with response) + if t < seq_len - 1: + resp_embed = responses[:, t].float().unsqueeze(-1).expand_as(r) + combined = r * resp_embed + erase = torch.sigmoid(self.erase_linear(combined)) + add = torch.tanh(self.add_linear(combined)) + value_mem = value_mem * (1 - w.unsqueeze(-1) * erase.unsqueeze(1)) + \ + w.unsqueeze(-1) * add.unsqueeze(1) + + return torch.stack(predictions, dim=1).squeeze(-1) + + +def load_model(path: str = _MODEL_PATH) -> bool: + """Load trained DKVMN weights. Returns True if successful.""" + global _model, _model_loaded + if not _TORCH_AVAILABLE: + return False + if not os.path.exists(path): + return False + try: + _model = DKVMN() + _model.load_state_dict(torch.load(path, map_location="cpu")) + _model.eval() + _model_loaded = True + logger.info("DKVMN model loaded from %s", path) + return True + except Exception as exc: + logger.warning("Failed to load DKVMN model: %s", exc) + return False + + +async def predict_mastery(pool, user_id: int) -> dict[str, float]: + """Return concept mastery scores {concept_id: 0.0-1.0}. + + Uses DKVMN if trained model is available, otherwise falls back to DB. + """ + # Fallback: read from concept_mastery or concept_elo tables + try: + rows = await pool.fetch( + "SELECT concept_id, mastery_score FROM concept_mastery WHERE user_id = ?", + user_id, + ) + if rows: + return {r["concept_id"]: float(r["mastery_score"]) for r in rows} + + # ELO fallback: normalize to 0-1 + elo_rows = await pool.fetch( + "SELECT concept_id, elo_score FROM concept_elo WHERE user_id = ?", + user_id, + ) + if elo_rows: + scores = [r["elo_score"] for r in elo_rows] + min_s, max_s = min(scores), max(scores) + rng = max_s - min_s or 1 + return { + r["concept_id"]: round((r["elo_score"] - min_s) / rng, 3) + for r in elo_rows + } + except Exception as exc: + logger.debug("predict_mastery DB fallback failed: %s", exc) + + return {} diff --git a/backend/app/math_wiki/dkvmn_train.py b/backend/app/math_wiki/dkvmn_train.py new file mode 100644 index 0000000000000000000000000000000000000000..b9e043fad47d0b3c1db06196862fef3593c2a813 --- /dev/null +++ b/backend/app/math_wiki/dkvmn_train.py @@ -0,0 +1,123 @@ +"""Offline DKVMN training script. + +Usage: python -m app.math_wiki.dkvmn_train + +Reads solution_logs from the DB, trains the DKVMN model, +saves weights to data/dkvmn_weights.pt. +Requires: PyTorch, sufficient data (≥1000 interaction sequences). +""" +import asyncio +import json +import logging +import os +import sys + +logger = logging.getLogger(__name__) + +_WEIGHTS_PATH = os.path.join(os.path.dirname(__file__), "..", "..", "data", "dkvmn_weights.pt") +_MIN_SEQUENCES = 100 +_EPOCHS = 20 +_BATCH_SIZE = 32 +_LR = 0.001 + + +async def load_training_data(pool) -> list[list[tuple[int, int]]]: + """Load (concept_idx, correct) sequences from solution_logs.""" + # Get concept taxonomy + rows = await pool.fetch("SELECT DISTINCT topic FROM problems WHERE topic IS NOT NULL ORDER BY topic") + topics = [r["topic"] for r in rows] + topic_to_idx = {t: i for i, t in enumerate(topics)} + + # Load user interaction sequences + user_rows = await pool.fetch( + """SELECT sl.user_id, p.topic, sl.actual_correct + FROM solution_logs sl + JOIN problems p ON p.problem_id = sl.problem_id + WHERE sl.actual_correct IS NOT NULL AND p.topic IS NOT NULL + ORDER BY sl.user_id, sl.created_at""" + ) + + sequences: dict[int, list[tuple[int, int]]] = {} + for row in user_rows: + uid = row["user_id"] + topic_idx = topic_to_idx.get(row["topic"], 0) + correct = 1 if row["actual_correct"] else 0 + sequences.setdefault(uid, []).append((topic_idx, correct)) + + return list(sequences.values()), len(topics) + + +async def train(pool) -> bool: + """Train DKVMN and save weights. Returns True on success.""" + try: + import torch + import torch.nn as nn + from app.math_wiki.dkvmn import DKVMN + except ImportError as e: + logger.error("PyTorch required for DKVMN training: %s", e) + return False + + seqs, n_concepts = await load_training_data(pool) + if len(seqs) < _MIN_SEQUENCES: + logger.warning( + "Only %d sequences found — need ≥%d for reliable training. Aborting.", + len(seqs), _MIN_SEQUENCES, + ) + return False + + logger.info("Training DKVMN on %d sequences, %d concepts", len(seqs), n_concepts) + model = DKVMN(n_concepts=n_concepts) + optimizer = torch.optim.Adam(model.parameters(), lr=_LR) + criterion = nn.BCELoss() + + # Pad sequences to same length + max_len = min(max(len(s) for s in seqs), 200) + for epoch in range(_EPOCHS): + total_loss = 0.0 + batches = 0 + for i in range(0, len(seqs), _BATCH_SIZE): + batch = seqs[i:i + _BATCH_SIZE] + # Truncate/pad + concepts = torch.zeros(len(batch), max_len, dtype=torch.long) + responses = torch.zeros(len(batch), max_len, dtype=torch.float) + for j, seq in enumerate(batch): + for k, (c, r) in enumerate(seq[:max_len]): + concepts[j, k] = c + responses[j, k] = r + + optimizer.zero_grad() + preds = model(concepts, responses) # (batch, seq_len) + # Predict next response: shift by 1 + loss = criterion(preds[:, :-1], responses[:, 1:]) + loss.backward() + optimizer.step() + total_loss += loss.item() + batches += 1 + + if (epoch + 1) % 5 == 0: + logger.info("Epoch %d/%d — loss: %.4f", epoch + 1, _EPOCHS, total_loss / max(batches, 1)) + + os.makedirs(os.path.dirname(_WEIGHTS_PATH), exist_ok=True) + torch.save(model.state_dict(), _WEIGHTS_PATH) + logger.info("DKVMN weights saved to %s", _WEIGHTS_PATH) + return True + + +if __name__ == "__main__": + import sys + sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..")) + logging.basicConfig(level=logging.INFO) + + async def main(): + from app.db import AsyncSQLitePool + from app.config import get_settings + settings = get_settings() + pool = AsyncSQLitePool(settings.sqlite_path) + await pool.initialize() + try: + success = await train(pool) + finally: + await pool.close() + sys.exit(0 if success else 1) + + asyncio.run(main()) diff --git a/backend/app/math_wiki/exam_sessions.py b/backend/app/math_wiki/exam_sessions.py new file mode 100644 index 0000000000000000000000000000000000000000..b38a3c3881838904652a4ae84fe108de53ac83ac --- /dev/null +++ b/backend/app/math_wiki/exam_sessions.py @@ -0,0 +1,167 @@ +"""CAT session management using catsim. + +Each session tracks the student's current ability estimate and the ordered +question sequence selected by Maximum Fisher Information. +""" +import json +import logging +import uuid +from datetime import datetime + +logger = logging.getLogger(__name__) + +import math as _math + +def _fisher_info(theta: float, a: float, b: float, c: float) -> float: + """3PL Fisher Information at ability theta for one item.""" + try: + p = c + (1 - c) / (1 + _math.exp(-a * (theta - b))) + p = max(1e-6, min(1 - 1e-6, p)) + return a ** 2 * ((p - c) ** 2) / ((1 - c) ** 2 * p * (1 - p)) + except Exception: + return 0.0 + + +# catsim imports — gracefully degrade if not installed +try: + from catsim.selection import MaxInformationSelector + from catsim.estimation import HillClimbEstimator + from catsim.stopping import MaxItemStopper + import numpy as np + _CAT_AVAILABLE = True +except ImportError: + _CAT_AVAILABLE = False + logger.warning("catsim not installed — adaptive exam endpoint will return 503") + + +async def create_session(pool, user_id: int, topic: str | None = None) -> dict: + """Start a new adaptive exam session. Returns session dict with first question_id.""" + if not _CAT_AVAILABLE: + raise RuntimeError("catsim not available") + + # Load questions with IRT params + query = "SELECT problem_id, irt_a, irt_b, irt_c FROM problems WHERE irt_b IS NOT NULL" + params = [] + if topic: + query += " AND topic = ?" + params.append(topic) + rows = await pool.fetch(query, *params) + if not rows: + raise ValueError("No calibrated questions available") + + question_ids = [r["problem_id"] for r in rows] + item_params = np.array([[r["irt_a"], r["irt_b"], r["irt_c"], 1.0] for r in rows]) + + # Select first question near difficulty 0 (average ability) + selector = MaxInformationSelector() + first_idx = selector.select(items=item_params, administered_items=[], est_theta=0.0) + + session_id = str(uuid.uuid4()) + session = { + "id": session_id, + "user_id": user_id, + "question_ids": question_ids, + "item_params": item_params.tolist(), + "answered_indices": [first_idx], # seed with first question so submit_answer knows what was served + "responses": [], + "ability": 0.0, + "ability_se": 1.0, + "status": "active", + } + await pool.execute( + """INSERT INTO exam_sessions (id, user_id, question_ids, item_params, answered_indices, responses, ability, ability_se) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)""", + session_id, user_id, + json.dumps(question_ids), json.dumps(item_params.tolist()), + json.dumps([first_idx]), "[]", 0.0, 1.0, + ) + return { + "session_id": session_id, + "question_id": question_ids[first_idx], + "question_index": 0, + "ability": 0.0, + "items_administered": 0, + "done": False, + } + + +async def submit_answer(pool, session_id: str, user_id: int, correct: bool) -> dict: + """Record answer, update ability estimate, select next question.""" + if not _CAT_AVAILABLE: + raise RuntimeError("catsim not available") + + row = await pool.fetchrow( + "SELECT * FROM exam_sessions WHERE id = ? AND user_id = ? AND status = 'active'", + session_id, user_id, + ) + if not row: + raise ValueError("Session not found or already complete") + + question_ids = json.loads(row["question_ids"]) + item_params = np.array(json.loads(row["item_params"])) + answered = json.loads(row["answered_indices"]) + responses = json.loads(row["responses"]) + + # answered stores [q0, q1, ..., current] — last element is the question just answered + # administered_now IS answered (no duplication); next question appended below if not done + if not answered: + raise ValueError("Session has no administered question — session state corrupted") + administered_now = list(answered) # copy; last element = question just answered + responses_now = responses + [1 if correct else 0] + + # Update ability estimate + estimator = HillClimbEstimator() + try: + new_ability = estimator.estimate( + items=item_params, + administered_items=np.array(administered_now), + response_vector=np.array(responses_now), + est_theta=row["ability"], + ) + except Exception: + new_ability = row["ability"] + + # Compute SE(θ) from Fisher Information: SE = 1 / sqrt(Σ I_i(θ)) + total_info = sum( + _fisher_info(new_ability, *item_params[i][:3]) + for i in administered_now + if i < len(item_params) + ) + new_ability_se = 1.0 / math.sqrt(max(total_info, 0.01)) + + # Check stopping condition + stopper = MaxItemStopper(max_items=30) + done = stopper.stop(item_params, len(administered_now)) + + next_question_id = None + next_idx = None + if not done: + selector = MaxInformationSelector() + next_idx = int(selector.select( + items=item_params, + administered_items=np.array(administered_now), + est_theta=new_ability, + )) + # Append next index so next submit_answer knows which was answered + administered_now.append(next_idx) + next_question_id = question_ids[next_idx] + + await pool.execute( + """UPDATE exam_sessions SET answered_indices=?, responses=?, ability=?, ability_se=?, status=?, updated_at=datetime('now') + WHERE id=?""", + json.dumps(administered_now), json.dumps(responses_now), + new_ability, new_ability_se, "complete" if done else "active", + session_id, + ) + + # Map theta (-3 to +3) to 0-10 score + score = round(max(0.0, min(10.0, (new_ability + 3) / 6 * 10)), 1) + + return { + "session_id": session_id, + "question_id": next_question_id, + "items_administered": len(responses_now), + "ability": round(new_ability, 3), + "score": score if done else None, + "done": done, + } diff --git a/backend/app/math_wiki/figures/__init__.py b/backend/app/math_wiki/figures/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..311010167b2256465d6fc91613167da21c3d0dbc --- /dev/null +++ b/backend/app/math_wiki/figures/__init__.py @@ -0,0 +1,3 @@ +from app.math_wiki.figures.figure import generate_figure + +__all__ = ["generate_figure"] diff --git a/backend/app/math_wiki/figures/figure.py b/backend/app/math_wiki/figures/figure.py new file mode 100644 index 0000000000000000000000000000000000000000..8dff8794b9e6870dfbe9fba94b7ba92e71e90186 --- /dev/null +++ b/backend/app/math_wiki/figures/figure.py @@ -0,0 +1,306 @@ +"""Universal GeoGebra figure generator — one LLM call, all math domains.""" +import logging +import math +from app.math_wiki.schemas import FigureOutput, SolverOutput + +logger = logging.getLogger(__name__) + +_PROMPT = """\ +You are a GeoGebra Classic expert. Read the math problem below and write GeoGebra Classic commands that draw an accurate diagram for it. + +Output exactly NO_FIGURE (nothing else) when the problem has no geometric object to draw — e.g. pure arithmetic, number theory, or combinatorics counting problems. + +══ GEOGEBRA CLASSIC 5 — 2D COMMANDS ══ +Point: P = (x, y) +Segment: Segment(A, B) +Line through 2 pts: l = Line(A, B) +Perp foot (ONLY way): h = PerpendicularLine(P, l) then D = Intersect(h, l) +Intersect 2 lines: H = Intersect(l1, l2) ← never nest Line() inside +Circle (center+pt): c = Circle(M, A) +Circumscribed circle (ONLY way): pb1=PerpendicularBisector(A,B) pb2=PerpendicularBisector(B,C) O=Intersect(pb1,pb2) c=Circle(O,A) +Incircle: ic = Incircle(A, B, C) +Midpoint: M = Midpoint(A, B) +Polygon: poly = Polygon(A, B, C, D) +Angle bisector: b = AngleBisector(B, A, C) +Perp bisector: pb = PerpendicularBisector(A, B) +Function: f(x) = +Multiple functions: f(x) = (then on separate lines) g(x) = h(x) = +Tangent line: t = Tangent(f, (x0, f(x0))) +Integral region: I = Integral(f, a, b) +Vector: v = Vector((0,0), (3,4)) +Hide object: HideObject(obj) +Color / fill: SetColor(obj, "SteelBlue") / SetFilling(obj, 0.15) + +══ 3D COMMANDS — use for pyramids, prisms, cuboids, spheres, cones, cylinders ══ +3D Point: A = (x, y, z) ← 3 coordinates; place base face in z=0 plane +Segment (3D): e = Segment(A, B) ← same command works in 3D +Polygon (face): base = Polygon(A, B, C, D) +Pyramid: p = Pyramid(base, S) ← base polygon + apex point S +Prism: pr = Prism(base, A1) ← base polygon + top-face image of 1st vertex + Example prism: A=(0,0,0) B=(2,0,0) C=(1,1.73,0) A1=(0,0,3) base=Polygon(A,B,C) pr=Prism(base,A1) +Cube: cu = Cube(A, B) ← A and B are adjacent base vertices +Sphere: sp = Sphere(M, r) ← center M + radius (number) +Cone: cn = Cone(A, B, r) ← apex A, base center B, base radius r +Cylinder: cy = Cylinder(A, B, r) ← bottom center A, top center B, radius r +Plane: pl = Plane(A, B, C) ← plane through 3 points +Cross-section: cs = IntersectPath(solid, pl) ← polygon cross-section of a solid with a plane + +══ BANNED COMMANDS (cause runtime errors — never use) ══ +PerpendicularFoot Circumcircle CircumscribedCircle Circumcenter Foot + +══ RULES ══ +1. Every name MUST be assigned (name = …) before it is used anywhere else. +2. For any perpendicular foot from point P to line l: use PerpendicularLine then Intersect (two separate lines). +3. For circumscribed circles: use two PerpendicularBisectors, Intersect them for center, then Circle. +4. After using auxiliary lines: hide them with HideObject(obj). +5. Draw only what the problem explicitly mentions or needs for the proof — nothing decorative. +6. Do NOT include any ZoomIn or ZoomOut command — the viewer auto-fits. +7. Your ENTIRE response must be GeoGebra commands — no preamble, no explanation, no "Let me…" sentences. The very first character must start a command. +8. For function graphs, ALWAYS use the named function form: f(x) = . NEVER write y = — that creates an implicit curve object, not a function. Use f, g, h, p, q for multiple functions. +9. For 3D geometry (hình chóp, lăng trụ, hình hộp, hình cầu…): use 3D coordinates (x,y,z). Place the base face in z=0. Compute all vertex coordinates numerically from the given edge lengths before writing any command. A right pyramid S.ABCD with square base side a and SA⊥base: A=(0,0,0), B=(a,0,0), C=(a,a,0), D=(0,a,0), S=(a/2,a/2,h). A right prism: base in z=0, corresponding top vertices at z=height. + +══ PROBLEM ══ +{problem_text} + +{solver_hint} +{extra_hint}""" + + +import re as _re + +# GeoGebra commands either contain '=' (assignment/relation) or start with a +# known function name. Lines that are plain English sentences are preamble text +# the LLM accidentally included — drop them silently. +_CMD_RE = _re.compile( + r'^[A-Za-z_][A-Za-z0-9_]*\s*=' # assignment: Name = … + r'|^[A-Za-z_][A-Za-z0-9_]*\([a-zA-Z]\)\s*=' # function def: f(x) = … + r'|^\s*(?:Segment|Line|Circle|Circumcircle|Incircle|Midpoint|Polygon|' + r'AngleBisector|PerpendicularBisector|PerpendicularLine|PerpendicularFoot|Intersect|' + r'Tangent|Integral|Root|Asymptote|Vector|Reflect|Rotate|Translate|' + r'Pyramid|Prism|Cube|Sphere|Cone|Cylinder|Plane|IntersectPath|' + r'HideObject|ShowObject|SetColor|SetFilling|SetVisible|SetLineThickness|' + r'ZoomIn|ZoomOut)\s*[\(\[]', + _re.IGNORECASE, +) + +def _filter_commands(raw: str) -> str: + """Drop lines that are not GeoGebra commands (e.g. LLM preamble text).""" + kept = [] + for line in raw.splitlines(): + stripped = line.strip() + if not stripped: + continue + if _CMD_RE.match(stripped): + kept.append(stripped) + else: + logger.debug("Filtered non-command line: %r", stripped[:80]) + return "\n".join(kept) + + +# Patterns for commands unsupported by GeoGebra Classic 5 +_PERP_FOOT_RE = _re.compile( + r'^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:PerpendicularFoot|Foot)\s*\(([^,]+),\s*([^)]+)\)\s*$', + _re.IGNORECASE, +) +_CIRCUMCIRCLE_RE = _re.compile( + r'^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:Circumcircle|CircumscribedCircle)\s*\(([^,]+),\s*([^,]+),\s*([^)]+)\)\s*$', + _re.IGNORECASE, +) +_CIRCUMCENTER_RE = _re.compile( + r'^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*Circumcenter\s*\(([^,]+),\s*([^,]+),\s*([^)]+)\)\s*$', + _re.IGNORECASE, +) + +def _fix_unsupported_commands(commands: str) -> str: + """ + Replace GeoGebra commands that crash in Classic 5 with working equivalents. + + PerpendicularFoot(P, l) → PerpendicularLine + Intersect + HideObject + Circumcircle(A, B, C) → two PerpendicularBisectors + Intersect + Circle + Circumcenter(A, B, C) → two PerpendicularBisectors + Intersect + """ + out = [] + for line in commands.splitlines(): + s = line.strip() + if not s: + continue + + m = _PERP_FOOT_RE.match(s) + if m: + name, pt, ln = m.group(1).strip(), m.group(2).strip(), m.group(3).strip() + aux = f"_aux_h_{name}" + out += [ + f"{aux} = PerpendicularLine({pt}, {ln})", + f"{name} = Intersect({aux}, {ln})", + f"HideObject({aux})", + ] + logger.debug("Rewrote PerpendicularFoot(%s,%s) → 3 commands", pt, ln) + continue + + m = _CIRCUMCIRCLE_RE.match(s) + if m: + name, a, b, c = m.group(1).strip(), m.group(2).strip(), m.group(3).strip(), m.group(4).strip() + pb1, pb2, ctr = f"_aux_pb1_{name}", f"_aux_pb2_{name}", f"_aux_O_{name}" + out += [ + f"{pb1} = PerpendicularBisector({a}, {b})", + f"{pb2} = PerpendicularBisector({b}, {c})", + f"{ctr} = Intersect({pb1}, {pb2})", + f"{name} = Circle({ctr}, {a})", + f"HideObject({pb1})", + f"HideObject({pb2})", + ] + logger.debug("Rewrote Circumcircle(%s,%s,%s) → 6 commands", a, b, c) + continue + + m = _CIRCUMCENTER_RE.match(s) + if m: + name, a, b, c = m.group(1).strip(), m.group(2).strip(), m.group(3).strip(), m.group(4).strip() + pb1, pb2 = f"_aux_pb1_{name}", f"_aux_pb2_{name}" + out += [ + f"{pb1} = PerpendicularBisector({a}, {b})", + f"{pb2} = PerpendicularBisector({b}, {c})", + f"{name} = Intersect({pb1}, {pb2})", + f"HideObject({pb1})", + f"HideObject({pb2})", + ] + logger.debug("Rewrote Circumcenter(%s,%s,%s) → 5 commands", a, b, c) + continue + + out.append(s) + return "\n".join(out) + + +_PT3D_RE = _re.compile( + r'^([A-Za-z_][A-Za-z0-9_]*)\s*=\s*\(\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*,\s*(-?[\d.]+)\s*\)', +) + +def _parse_3d_points(commands: str) -> dict[str, tuple[float, float, float]]: + pts: dict[str, tuple[float, float, float]] = {} + for line in commands.splitlines(): + m = _PT3D_RE.match(line.strip()) + if m: + try: + pts[m.group(1)] = (float(m.group(2)), float(m.group(3)), float(m.group(4))) + except ValueError: + pass + return pts + + +def _dist3(a, b): + return math.sqrt(sum((a[i] - b[i]) ** 2 for i in range(3))) + + +def _vec3(a, b): + return (b[0] - a[0], b[1] - a[1], b[2] - a[2]) + + +_EDGE_RE = _re.compile( + r'(?:cạnh|AB|BC|CD|SA|SB|SC|SD|a\s*=|b\s*=|c\s*=|h\s*=)[^\d]*(\d+(?:[.,]\d+)?)', + _re.IGNORECASE, +) +_SA_PERP_RE = _re.compile( + r'SA\s*(?:⊥|vuông\s*góc)\s*(?:đáy|base|ABCD|ABC|\(ABCD\)|\(ABC\))', + _re.IGNORECASE, +) + + +def _check_3d_constraints(problem: str, commands: str) -> str | None: + """Return an error hint string if geometric constraints are violated, else None.""" + pts = _parse_3d_points(commands) + if not pts: + return None + + issues: list[str] = [] + + # Check SA⊥base: apex S must lie directly above the base centroid + if _SA_PERP_RE.search(problem): + s = pts.get('S') + base_pts = {k: v for k, v in pts.items() if k != 'S' and not k.startswith('_')} + if s and len(base_pts) >= 3: + base_z_ok = all(abs(v[2]) < 0.05 for v in base_pts.values()) + if base_z_ok: + cx = sum(v[0] for v in base_pts.values()) / len(base_pts) + cy = sum(v[1] for v in base_pts.values()) / len(base_pts) + lateral_offset = math.sqrt((s[0] - cx) ** 2 + (s[1] - cy) ** 2) + if lateral_offset > 0.15: + issues.append( + f"SA⊥base violated: apex S=({s[0]:.2f},{s[1]:.2f},{s[2]:.2f}) " + f"is offset {lateral_offset:.3f} from base centroid ({cx:.2f},{cy:.2f}). " + f"Place S directly above the base centroid at ({cx:.2f},{cy:.2f},h)." + ) + + if not issues: + return None + return " | ".join(issues) + + +async def generate_figure( + client, + question: str, + label: str, + solver_output: SolverOutput, + image_bytes: bytes | None = None, + image_mime: str | None = None, +) -> FigureOutput | None: + """Return FigureOutput or None (for NO_FIGURE). Never raises.""" + import base64 as _b64 + from app.config import get_settings + settings = get_settings() + + solver_hint = "" + if solver_output.steps: + preview = "\n".join(f" {s}" for s in solver_output.steps[:4]) + solver_hint = f"SOLUTION CONTEXT (use to understand the problem, do not copy verbatim):\n{preview}" + + MAX_RETRIES = 2 + extra_hint = "" + + for attempt in range(MAX_RETRIES + 1): + try: + prompt = _PROMPT.format( + problem_text=question, + solver_hint=solver_hint, + extra_hint=f"\nPrevious attempt failed: {extra_hint}\nFix those issues." if extra_hint else "", + ) + + user_content: list = [] + if image_bytes and image_mime: + data_uri = f"data:{image_mime};base64,{_b64.b64encode(image_bytes).decode()}" + user_content.append({"type": "image_url", "image_url": {"url": data_uri}}) + user_content.append({"type": "text", "text": prompt}) + + resp = await client.chat.completions.create( + model=settings.default_model, + messages=[{"role": "user", "content": user_content}], + max_tokens=900, + temperature=0, + ) + raw = resp.choices[0].message.content.strip() + + if raw == "NO_FIGURE": + return None + + # Strip markdown fences if LLM wrapped output + if raw.startswith("```"): + raw = "\n".join(line for line in raw.split("\n") if not line.startswith("```")) + + commands = _filter_commands(raw.strip()) + if not commands: + raise ValueError("LLM returned empty GeoGebra commands") + + commands = _fix_unsupported_commands(commands) + + constraint_err = _check_3d_constraints(question, commands) + if constraint_err and attempt < MAX_RETRIES: + extra_hint = constraint_err + logger.debug("3D constraint violation on attempt %d: %s", attempt + 1, constraint_err) + continue + + return FigureOutput(type="geogebra", data=commands) + + except Exception as exc: + if attempt == MAX_RETRIES: + logger.warning("Figure generation failed after %d attempts: %s", MAX_RETRIES + 1, exc) + return None + extra_hint = str(exc) + logger.debug("Figure generation attempt %d failed: %s", attempt + 1, exc) diff --git a/backend/app/math_wiki/graph.py b/backend/app/math_wiki/graph.py new file mode 100644 index 0000000000000000000000000000000000000000..21a618639d5f8563f236b62dd565f8392eb0e771 --- /dev/null +++ b/backend/app/math_wiki/graph.py @@ -0,0 +1,99 @@ +"""Concept graph traversal utilities. + +Loads the concept DAG from app.data.concepts and provides: + - get_prerequisites(concept_id, depth) → list of concept_ids + - get_applications(concept_id) → list of concept_ids + - find_path(from_id, to_id) → shortest path or [] + - topic_to_concepts(topic) → concept_ids for a topic label + +All functions are pure (no DB calls) and operate on the in-memory graph +built from CONCEPTS at import time. +""" +from __future__ import annotations +from collections import deque +from functools import lru_cache + +from app.data.concepts import CONCEPTS + +# ── Graph construction ───────────────────────────────────────────────────────── + +_by_id: dict[str, dict] = {c["id"]: c for c in CONCEPTS} + +# Forward edges: id → set of ids that *depend on* id (id is a prerequisite for them) +_dependents: dict[str, set[str]] = {c["id"]: set() for c in CONCEPTS} +# Backward edges: id → set of prerequisite ids +_prerequisites: dict[str, set[str]] = {c["id"]: set() for c in CONCEPTS} + +for _c in CONCEPTS: + for _pre in _c.get("prerequisite_ids", []): + _prerequisites[_c["id"]].add(_pre) + if _pre in _dependents: + _dependents[_pre].add(_c["id"]) + +# topic → set of concept ids +_topic_index: dict[str, set[str]] = {} +for _c in CONCEPTS: + _topic_index.setdefault(_c["topic"], set()).add(_c["id"]) + + +# ── Public API ───────────────────────────────────────────────────────────────── + +@lru_cache(maxsize=256) +def get_prerequisites(concept_id: str, depth: int = 2) -> list[str]: + """Return all prerequisite concept_ids up to `depth` hops away. + BFS through backward edges. Does not include `concept_id` itself. + """ + if concept_id not in _by_id: + return [] + visited: set[str] = set() + queue: deque[tuple[str, int]] = deque([(concept_id, 0)]) + result: list[str] = [] + while queue: + cid, d = queue.popleft() + if cid in visited: + continue + visited.add(cid) + if cid != concept_id: + result.append(cid) + if d < depth: + for pre in _prerequisites.get(cid, set()): + if pre not in visited: + queue.append((pre, d + 1)) + return result + + +@lru_cache(maxsize=256) +def get_applications(concept_id: str) -> list[str]: + """Return direct dependents of concept_id (concepts that require it as prerequisite).""" + return list(_dependents.get(concept_id, set())) + + +def find_path(from_id: str, to_id: str) -> list[str]: + """BFS shortest path from from_id to to_id through prerequisite edges. + Returns the path including both endpoints, or [] if no path exists. + """ + if from_id not in _by_id or to_id not in _by_id: + return [] + if from_id == to_id: + return [from_id] + visited: set[str] = {from_id} + queue: deque[list[str]] = deque([[from_id]]) + while queue: + path = queue.popleft() + node = path[-1] + for nxt in _dependents.get(node, set()): + if nxt == to_id: + return path + [to_id] + if nxt not in visited: + visited.add(nxt) + queue.append(path + [nxt]) + return [] + + +def topic_to_concepts(topic: str) -> list[str]: + """Return all concept_ids whose topic matches the given label.""" + return list(_topic_index.get(topic, set())) + + +def concept_exists(concept_id: str) -> bool: + return concept_id in _by_id diff --git a/backend/app/math_wiki/graph_retrieval.py b/backend/app/math_wiki/graph_retrieval.py new file mode 100644 index 0000000000000000000000000000000000000000..35b76883c5f19a7c99b65298b61bce508b4713f5 --- /dev/null +++ b/backend/app/math_wiki/graph_retrieval.py @@ -0,0 +1,56 @@ +"""Typed BFS graph traversal for GraphRAG. + +Expands seed wiki_unit IDs along typed concept_edges to surface +prerequisite, illustrative, and related content. +""" +import logging +from collections import deque + +logger = logging.getLogger(__name__) + +# Edge types to follow for each query mode +_DEFAULT_EDGE_TYPES = {"requires", "illustrates", "similar_to", "generalizes"} +_MISTAKE_EDGE_TYPES = {"common_mistake", "requires"} +_APPLICATION_EDGE_TYPES = {"applies", "requires"} + + +async def graph_expand( + pool, + seed_ids: list[str], + edge_types: set[str] | None = None, + depth: int = 2, + max_nodes: int = 30, +) -> list[str]: + """BFS from seed_ids along edge_types. Returns ordered list of wiki_unit IDs. + + Nodes closer to seeds are ranked higher. Seeds themselves are included first. + """ + if not seed_ids or pool is None: + return seed_ids + + if edge_types is None: + edge_types = _DEFAULT_EDGE_TYPES + + visited: dict[str, int] = {uid: 0 for uid in seed_ids} # id -> depth + queue: deque[tuple[str, int]] = deque((uid, 0) for uid in seed_ids) + + try: + while queue and len(visited) < max_nodes: + current_id, current_depth = queue.popleft() + if current_depth >= depth: + continue + rows = await pool.fetch( + "SELECT to_id FROM concept_edges WHERE from_id = ? AND edge_type = ANY(?)", + current_id, list(edge_types), + ) + for row in rows: + neighbor = row["to_id"] + if neighbor not in visited and len(visited) < max_nodes: + visited[neighbor] = current_depth + 1 + queue.append((neighbor, current_depth + 1)) + except Exception as exc: + logger.warning("graph_expand BFS failed (%s) — returning seeds only", exc) + return seed_ids + + # Sort: seeds first (depth 0), then by ascending depth + return sorted(visited.keys(), key=lambda uid: visited[uid]) diff --git a/backend/app/math_wiki/kg_recommender.py b/backend/app/math_wiki/kg_recommender.py new file mode 100644 index 0000000000000000000000000000000000000000..0c0f80ebe3333a07ea3a6ded2a4f77a5606889c1 --- /dev/null +++ b/backend/app/math_wiki/kg_recommender.py @@ -0,0 +1,89 @@ +"""Knowledge Graph exercise recommender. + +Walks concept_edges from the student's weakest concepts to find +problems whose topic covers the weakness. Returns ranked problems +with graph-path explanation. +""" +import logging +from collections import deque + +logger = logging.getLogger(__name__) + + +async def recommend_exercises( + pool, + user_id: int, + top_k: int = 5, +) -> list[dict]: + """Recommend exercises targeting the student's weakest concepts via graph traversal.""" + if pool is None: + return [] + try: + # Get weakest concepts + weak_rows = await pool.fetch( + """SELECT concept_id, mastery_score FROM concept_mastery + WHERE user_id = ? ORDER BY mastery_score ASC LIMIT 3""", + user_id, + ) + if not weak_rows: + weak_rows = await pool.fetch( + """SELECT concept_id, elo_score / 1000.0 as mastery_score FROM concept_elo + WHERE user_id = ? ORDER BY elo_score ASC LIMIT 3""", + user_id, + ) + if not weak_rows: + return [] + + # Expand weak concepts via graph to find related topics + target_concepts: list[tuple[str, str, float]] = [] # (concept_id, path_label, score) + for row in weak_rows: + cid = row["concept_id"] + mastery = float(row["mastery_score"]) + target_concepts.append((cid, cid, mastery)) + # One-hop expansion for broader coverage + neighbors = await pool.fetch( + "SELECT to_id FROM concept_edges WHERE from_id = ? AND edge_type IN ('requires', 'similar_to') LIMIT 3", + cid, + ) + for n in neighbors: + path_label = f"{cid} → {n['to_id']}" + target_concepts.append((n["to_id"], path_label, mastery * 0.8)) + + # Already-solved problem IDs (exclude) + solved = await pool.fetch( + "SELECT DISTINCT problem_id FROM solution_logs WHERE user_id = ? LIMIT 200", + user_id, + ) + solved_ids = {r["problem_id"] for r in solved} + + # Find problems for each target concept + recs: list[dict] = [] + seen_ids: set[str] = set() + for concept_id, path_label, priority_score in target_concepts: + if len(recs) >= top_k: + break + rows = await pool.fetch( + """SELECT problem_id, problem_text, topic, difficulty + FROM problems WHERE topic = ? ORDER BY RANDOM() LIMIT 3""", + concept_id, + ) + for row in rows: + if row["problem_id"] in solved_ids or row["problem_id"] in seen_ids: + continue + seen_ids.add(row["problem_id"]) + recs.append({ + "problem_id": row["problem_id"], + "topic": row["topic"], + "difficulty": row["difficulty"], + "path": path_label, + "priority_score": round(1.0 - priority_score, 2), # lower mastery = higher priority + "reason": f"Luyện tập khái niệm: {concept_id}", + }) + if len(recs) >= top_k: + break + + return sorted(recs, key=lambda x: x["priority_score"], reverse=True)[:top_k] + + except Exception as exc: + logger.debug("recommend_exercises failed: %s", exc) + return [] diff --git a/backend/app/math_wiki/pipeline.py b/backend/app/math_wiki/pipeline.py new file mode 100644 index 0000000000000000000000000000000000000000..5d53d33b6ffb46fd2ecb9878dae769c071a1f835 --- /dev/null +++ b/backend/app/math_wiki/pipeline.py @@ -0,0 +1,406 @@ +import asyncio +import hashlib +import json +import logging +import re +from openai import AsyncOpenAI +from app.math_wiki.storage import pg_db, pg_vectors +from app.math_wiki.storage.retriever import retrieve_with_prerequisites +from app.math_wiki.crag import score_candidates as _crag_score +from app.math_wiki.storage.analytics import log_solution +from app.metrics import record_validation +from app.math_wiki.agents.classifier import classify_problem +from app.math_wiki.agents.reranker import rerank +from app.math_wiki.agents.solver import solve +from app.math_wiki.agents.validator import validate +from app.math_wiki.agents.sympy_verifier import sympy_verify +from app.math_wiki.agents.decomposer import decompose_query +from app.math_wiki.schemas import ValidationResult, FigureOutput, Problem, SolverOutput +from app.math_wiki.utils import InsufficientKnowledgeError +from app.math_wiki.figures import generate_figure +from app.math_wiki.graph_retrieval import graph_expand +from app.math_wiki.context_builder import build_context +from app.math_wiki import cache as retrieval_cache + +logger = logging.getLogger(__name__) + +_wiki_status: dict = {"phase": "starting", "progress": 0, "error": None} +_bm25_index = None # BM25Okapi instance +_bm25_id_map: list[str] = [] +_bm25_ready_event = asyncio.Event() + + +def get_wiki_status() -> dict: + return dict(_wiki_status) + + +async def _ensure_bm25(pool) -> None: + global _bm25_index, _bm25_id_map + try: + _wiki_status.update({"phase": "loading_units", "progress": 40, "error": None}) + if pool is None: + logger.warning("No pool available — BM25 index will be empty") + _bm25_ready_event.set() + _wiki_status.update({"phase": "ready", "progress": 100, "error": None}) + return + + units = await pg_db.get_all_wiki_units(pool) + _wiki_status.update({"phase": "building_bm25", "progress": 70, "error": None}) + + loop = asyncio.get_event_loop() + _bm25_index, _bm25_id_map = await loop.run_in_executor(None, _build_bm25, units) + + _bm25_ready_event.set() + _wiki_status.update({"phase": "ready", "progress": 100, "error": None}) + logger.info("BM25 index built: %d units", len(units)) + except Exception as exc: + _bm25_ready_event.set() + _wiki_status.update({"phase": "failed", "progress": 0, "error": str(exc)}) + logger.error("_ensure_bm25 failed: %s", exc) + + +def _build_bm25(units): + from app.math_wiki.storage.bm25 import build_bm25_index + if not units: + return None, [] + return build_bm25_index(units) + + +async def _retrieve_rerank_context(pool, client: AsyncOpenAI, question: str, topic: str | None = None): + # Check cache first + cached = retrieval_cache.get(question) + if cached: + context = await pg_db.get_wiki_units_by_ids(pool, cached) if pool else [] + return cached, context + + retrieved_ids = await retrieve_with_prerequisites(pool, question, topic=topic) if pool else [] + + # BM25 hybrid: merge BM25 keyword results with vector results for better recall + if _bm25_index is not None: + from app.math_wiki.storage.bm25 import query_bm25 + bm25_ids = query_bm25(_bm25_index, _bm25_id_map, question, top_k=10) + seen = set(retrieved_ids) + for bid in bm25_ids: + if bid not in seen: + seen.add(bid) + retrieved_ids = list(retrieved_ids) + [bid] + + # Graph expansion: enrich seed IDs with typed graph traversal + if retrieved_ids: + expanded_ids = await graph_expand(pool, retrieved_ids) + retrieved_ids = expanded_ids + + candidates = await pg_db.get_wiki_units_by_ids(pool, retrieved_ids) if pool else [] + if candidates: + try: + top_ids = await rerank(client, question, candidates) + except Exception as exc: + logger.warning("Reranker failed (%s), using raw retrieval order", exc) + top_ids = [] + else: + top_ids = [] + context = await pg_db.get_wiki_units_by_ids(pool, top_ids) if top_ids and pool else candidates + + # CRAG scoring gate: filter low-relevance candidates using cosine similarity + if context: + try: + from app.math_wiki.storage.vectors import embed_texts + loop = asyncio.get_event_loop() + query_vecs = await loop.run_in_executor(None, embed_texts, [question], "query") + query_emb = query_vecs[0] if query_vecs else [] + context, should_retry = _crag_score( + query_embedding=query_emb, + candidates=context, + threshold=None, # uses settings.crag_threshold (0.20) + ) + if should_retry: + logger.info("CRAG: all candidates below threshold — returning empty context for question: %.60s", question) + except Exception as exc: + logger.debug("CRAG scoring failed (non-fatal): %s", exc) + + retrieval_cache.put(question, retrieved_ids) + return retrieved_ids, context + + +def _problem_hash(question: str) -> str: + normalized = re.sub(r'\s+', ' ', question.strip().lower()) + return hashlib.sha256(normalized.encode()).hexdigest() + + +# Keywords that signal a real-world science/geography question — not THPT math +_OUT_OF_SCOPE_KEYWORDS: frozenset[str] = frozenset([ + # Astronomy / solar system + "mặt trăng", "trái đất", "mặt trời", "sao hỏa", "sao mộc", "sao thổ", "sao kim", + "thiên hà", "vũ trụ", "hành tinh", "thiên thể", + # Geography / demography + "thủ đô", "quốc gia", "dân số", "lục địa", "đại dương", "biển cả", + # Physics / chemistry that are not math problems + "nguyên tử", "phân tử", "electron", "proton", "nhiệt độ", "áp suất", +]) + +_OUT_OF_SCOPE_RESPONSE = ( + "Câu hỏi này nằm ngoài phạm vi toán học THPT. " + "Mình chỉ hỗ trợ các bài toán thuộc chương trình lớp 9–12 nhé!" +) + + +def _is_out_of_scope(question: str) -> bool: + q = question.lower() + return any(kw in q for kw in _OUT_OF_SCOPE_KEYWORDS) + + +_PART_HEADER_RE = re.compile(r'^\*\*Phần\s+([a-d])\w*\)\*\*$') + +def _split_parts(steps: list[str]) -> dict[str, list[str]]: + parts: dict[str, list[str]] = {} + current: str | None = None + for s in steps: + m = _PART_HEADER_RE.match(s) + if m: + current = m.group(1) + parts[current] = [] + elif current: + parts[current].append(s) + return parts + + +async def _ensure_vietnamese(client: AsyncOpenAI, output: SolverOutput) -> SolverOutput: + """Pre-stream Opus correction: translate any non-Vietnamese steps to Vietnamese. + Only called on low-confidence responses. Adds ~500ms on the unhappy path.""" + from app.config import get_settings + from app.agent.core import call_with_retry + import json as _json + settings = get_settings() + steps_text = "\n".join(f"{i+1}. {s}" for i, s in enumerate(output.steps)) + prompt = ( + "Kiểm tra các bước giải sau. Nếu có bất kỳ từ tiếng Anh nào (không phải biểu thức toán học), " + "hãy dịch toàn bộ sang tiếng Việt và trả về JSON {\"steps\": [...], \"final_answer\": \"...\"}. " + "Nếu đã hoàn toàn bằng tiếng Việt, trả về nguyên văn JSON như trên.\n\n" + f"Bước giải:\n{steps_text}\n\nĐáp án: {output.final_answer}" + ) + try: + resp = await call_with_retry( + client.chat.completions.create, + model=settings.opus_model, + max_tokens=2000, + messages=[{"role": "user", "content": prompt}], + response_format={"type": "json_object"}, + ) + raw = resp.choices[0].message.content or "{}" + parsed = _json.loads(raw) + corrected_steps = parsed.get("steps", output.steps) + corrected_answer = parsed.get("final_answer", output.final_answer) + return SolverOutput( + problem_type=output.problem_type, + steps=corrected_steps if isinstance(corrected_steps, list) else output.steps, + final_answer=corrected_answer, + confidence=output.confidence, + used_knowledge_ids=output.used_knowledge_ids, + figure=output.figure, + ) + except Exception as exc: + logger.warning("_ensure_vietnamese failed (%s) — returning original", exc) + return output + + +async def run_pipeline( + pool, + client: AsyncOpenAI, + question: str, + image_bytes: bytes | None = None, + image_mime: str | None = None, +) -> dict: + await asyncio.wait_for(_bm25_ready_event.wait(), timeout=120) + + # Scope guard: refuse clearly non-THPT questions before any expensive AI calls + if _is_out_of_scope(question): + logger.info("Out-of-scope question rejected: %.60s", question) + return { + "label": "other", + "answer": { + "problem_type": "ngoài phạm vi", + "steps": [f"Bước 1: {_OUT_OF_SCOPE_RESPONSE}"], + "final_answer": _OUT_OF_SCOPE_RESPONSE, + "confidence": "high", + "used_knowledge_ids": [], + "figure": None, + "figures": {}, + }, + "validation": {"valid": False, "issues": ["Câu hỏi ngoài phạm vi toán THPT"]}, + "retrieved_ids": [], + "wiki_assisted": False, + "log_id": -1, + } + + label = await classify_problem(client, question) + logger.debug("Classified as: %s", label) + + # Multi-domain decomposition: if problem spans two topics, retrieve across both + decomposed = None + try: + decomposed = await decompose_query(client, question) + except Exception as exc: + logger.debug("Decomposer failed (non-fatal): %s", exc) + + if decomposed and decomposed.requires_multi_domain and decomposed.secondary_topics: + # Retrieve for each sub-question, merge unique units + all_ids: list[str] = [] + all_units: list = [] + seen_ids: set[str] = set() + for sub_q in decomposed.sub_questions[:3]: + sub_ids, sub_units = await _retrieve_rerank_context(pool, client, sub_q) + for uid in sub_ids: + if uid not in seen_ids: + seen_ids.add(uid) + all_ids.append(uid) + for u in sub_units: + if u.id not in seen_ids: + seen_ids.add(u.id) + all_units.append(u) + retrieved_ids, context = all_ids, all_units + logger.debug("Multi-domain: %d units from %d sub-questions", len(context), len(decomposed.sub_questions)) + else: + retrieved_ids, context = await _retrieve_rerank_context(pool, client, question, topic=label) + logger.debug("Retrieved %d units: %s", len(retrieved_ids), retrieved_ids) + + # P6: order context by node_type priority (prerequisites → concept → formula → example → mistake) + if context: + from app.math_wiki.context_builder import _rank + context = sorted(context, key=_rank) + + try: + solver_output = await solve(client, question, context, label=label) + except InsufficientKnowledgeError: + return {"error": "INSUFFICIENT_KNOWLEDGE"} + logger.debug("Solver confidence: %s", solver_output.confidence) + + # Symbolic verification: if SymPy detects a wrong answer with high confidence, retry once + sympy_valid, sympy_issues = sympy_verify( + question, solver_output.final_answer, solver_output.problem_type + ) + if sympy_valid is False and solver_output.confidence == "high": + logger.info("SymPy caught high-confidence wrong answer — retrying solver") + hint = "Nghiệm trước không thỏa phương trình khi kiểm tra đại số: " + "; ".join(sympy_issues) + ". Hãy kiểm tra lại và tính nghiệm đúng." + try: + solver_output = await solve(client, question, context, label=label, prior_failure=hint) + except Exception as exc: + logger.warning("Retry solve after SymPy rejection failed: %s — keeping original", exc) + + # Layer 4 Vietnamese post-check: if low-confidence, run Opus correction pre-stream + if solver_output.confidence == "low": + solver_output = await _ensure_vietnamese(client, solver_output) + + figure: FigureOutput | None = None + prob_hash = _problem_hash(question) + + async def _figure_task(): + nonlocal figure + if solver_output.confidence == "low": + logger.debug("Skipping figure: low confidence") + return + + parts = _split_parts(solver_output.steps) + if len(parts) > 1: + # Multi-part problem: generate one figure per part (cap at 3) + part_labels = list(parts.keys())[:3] + + async def _gen_part(part_label: str) -> tuple[str, FigureOutput | None]: + part_hash = _problem_hash(question + ":part:" + part_label) + if pool: + cached = await pg_db.get_cached_figure(pool, part_hash) + if cached is not None: + cached_data, cached_type = cached + return part_label, FigureOutput(type=cached_type, data=cached_data) + part_solver = SolverOutput( + problem_type=solver_output.problem_type, + used_knowledge_ids=solver_output.used_knowledge_ids, + steps=parts[part_label], + final_answer=solver_output.final_answer, + confidence=solver_output.confidence, + ) + part_fig = await generate_figure(client, question, label, part_solver, image_bytes, image_mime) + if part_fig and part_fig.data and pool: + stub = Problem( + problem_id=part_hash[:16], + problem_text=question, + topic=label, + subtopic=label, + difficulty="medium", + problem_type=label, + ) + await pg_db.upsert_problem(pool, stub, figure_svg=part_fig.data, problem_hash=part_hash, figure_type=part_fig.type) + return part_label, part_fig + + results_parts = await asyncio.gather(*[_gen_part(pl) for pl in part_labels], return_exceptions=True) + for res in results_parts: + if isinstance(res, Exception): + logger.warning("Per-part figure generation failed: %s", res) + continue + pl, pf = res + if pf is not None: + solver_output.figures[pl] = pf + if solver_output.figures: + figure = next(iter(solver_output.figures.values())) + return + + # Single-part path (original logic) + if pool: + cached = await pg_db.get_cached_figure(pool, prob_hash) + if cached is not None: + cached_data, cached_type = cached + logger.debug("Figure cache hit for hash %s (type=%s)", prob_hash[:8], cached_type) + figure = FigureOutput(type=cached_type, data=cached_data) + return + try: + figure = await generate_figure(client, question, label, solver_output, image_bytes, image_mime) + if figure and figure.data and pool: + stub = Problem( + problem_id=prob_hash[:16], + problem_text=question, + topic=label, + subtopic=label, + difficulty="medium", + problem_type=label, + ) + await pg_db.upsert_problem(pool, stub, figure_svg=figure.data, problem_hash=prob_hash, figure_type=figure.type) + except Exception as exc: + logger.warning("Figure generation failed (non-fatal): %s", exc) + + results = await asyncio.gather( + validate(client, solver_output, context, problem_text=question), + _figure_task(), + return_exceptions=True, + ) + val_result = results[0] + validation = val_result if isinstance(val_result, ValidationResult) else ValidationResult(valid=False, issues=["validation error"]) + logger.debug("Validation: valid=%s issues=%s", validation.valid, validation.issues) + + record_validation(validation.valid) + + solver_output.figure = figure + + log_id = -1 + if pool: + try: + log_id = await log_solution( + pool, + problem_text=question, + classified_topic=label, + retrieved_ids=retrieved_ids, + used_ids=solver_output.used_knowledge_ids, + confidence=solver_output.confidence, + valid=validation.valid, + issues=validation.issues, + wiki_assisted=bool(context), + ) + except Exception as exc: + logger.warning("log_solution failed (non-fatal): %s", exc) + + return { + "label": label, + "answer": solver_output.model_dump(), + "validation": validation.model_dump(), + "retrieved_ids": retrieved_ids, + "wiki_assisted": bool(context), + "log_id": log_id, + } diff --git a/backend/app/math_wiki/prompts.py b/backend/app/math_wiki/prompts.py new file mode 100644 index 0000000000000000000000000000000000000000..be4e669e97eaba98d26d391f0bc590e7c2244ece --- /dev/null +++ b/backend/app/math_wiki/prompts.py @@ -0,0 +1,252 @@ +PROMPT_INGEST = """You are a math knowledge extraction system. +Given raw exam text, extract structured problems and wiki knowledge units. + +For each problem, identify: +- problem_id: unique string identifier +- problem_text: the question text +- choices: list of answer choices (or null for open-ended) +- correct_answer: the correct answer (or null if unknown) +- topic: main topic (algebra/geometry/statistics/probability/calculus/trigonometry/combinatorics/number_theory/differential_equations/linear_algebra/multivariable_calculus) +- subtopic: specific subtopic +- difficulty: easy/medium/hard +- problem_type: type of problem + +For each wiki unit, identify: +- id: unique string identifier +- type: one of: procedure | concept | theorem | definition | fact +- topic: one of: algebra|geometry|calculus|trigonometry|combinatorics|number_theory|statistics|probability|differential_equations|linear_algebra|multivariable_calculus +- subtopic: specific subtopic +- content: the knowledge content +- problem_ids: list of problem IDs this unit relates to + +Each problem must have at least 2 wiki units associated with it. + +LANGUAGE RULE: Write all "content" fields in Vietnamese. Math expressions may use standard notation. + +Return a JSON object with keys "problems" and "wiki_units". No other text.""" + +PROMPT_CLASSIFY = """You are a math problem classifier. Trả lời bằng tiếng Việt nếu có thể. +Given a problem, classify it into one of these categories: +algebra, geometry, statistics, probability, calculus, trigonometry, combinatorics, number_theory, +complex_numbers, sequences, vectors, functions + +Vietnamese examples: +- "Tính tích phân ∫..." or "Tính đạo hàm..." → calculus +- "Giải phương trình..." or "Giải hệ phương trình..." → algebra +- "Cho hình chóp S.ABC..." or "Tính diện tích..." → geometry +- "Cho dãy số..." or "Cấp số cộng/nhân..." → sequences +- "Cho vectơ..." or "Tính tích vô hướng..." → vectors +- "Xét hàm số y = ..." or "Tìm cực trị..." → functions +- "Số phức z = ..." → complex_numbers +- "Tính xác suất..." → probability +- "Tổ hợp, chỉnh hợp..." → combinatorics + +Return JSON: {"label": ""}. No other text.""" + +PROMPT_RERANK = """You are a knowledge relevance ranker. Respond with ONLY valid JSON, no other text. + +Select the top 5 most relevant candidate IDs for the query. +Output exactly: {"top_ids": ["id1", "id2", ...]} +Only use IDs from the candidates list. Maximum 5 IDs. Output ONLY the JSON object.""" + +PROMPT_SOLVE = """Bạn là gia sư Toán THPT người Việt. QUAN TRỌNG: Toàn bộ phản hồi phải bằng tiếng Việt — các bước giải, giải thích, đáp án, và mọi chú thích. Không dùng tiếng Anh trong bất kỳ phần nào của câu trả lời. + +You MUST output ONLY a single JSON object — no prose, no markdown, no text before or after the JSON. + +══ MANDATORY RULES — read these before everything else ══ + +NGÔN NGỮ: Mọi từ trong "steps", "problem_type", và "final_answer" PHẢI bằng tiếng Việt. Không được có bất kỳ từ tiếng Anh nào — kể cả một từ đơn lẻ như "wait", "and", "so", "then", "since", "let", "note", "check", "use", "rearrange", "square", "solve", "substitute", "simplify", "expand", "factor", "compute", "calculate", "verify". Biểu thức toán học trong $...$ được miễn trừ. + +KÝ HIỆU TOÁN: Bọc MỌI biểu thức toán học trong $...$. TUYỆT ĐỐI không dùng ký hiệu Unicode toán học ngoài dấu dollar. + ✗ SAI: "x² – 3x + 2 = 0", "√(x+3)", "±17", "x→∞", "3×4", "a/b" + ✓ ĐÚNG: "$x^2 - 3x + 2 = 0$", "$\\sqrt{x+3}$", "$\\pm 17$", "$x \\to \\infty$", "$3 \\times 4$", "$\\frac{a}{b}$" + Phân số: luôn dùng $\\frac{tử}{mẫu}$, không bao giờ viết "a/b" thuần túy. + Căn: luôn dùng $\\sqrt{...}$, không bao giờ dùng ký tự "√". + Luỹ thừa: luôn dùng $x^2$, không bao giờ dùng "x²" hay "x^2" ngoài dollar. + Cộng/trừ: luôn dùng $\\pm$, không bao giờ dùng ký tự "±". + Mũi tên: luôn dùng $\\Rightarrow$ hoặc $\\to$, không bao giờ dùng "→" hay "⇒" ngoài dollar. + +BƯỚC GIẢI: Mỗi bước PHẢI bắt đầu bằng "Bước N: " (ví dụ: "Bước 1: ", "Bước 2: "). Mỗi bước chỉ thực hiện một phép biến đổi duy nhất. TUYỆT ĐỐI không viết bất kỳ từ tự sửa lỗi nào ("wait", "thực ra", "nhận thấy sai", "tính lại", "recalculate", "let me", "oops", "actually"). Nếu phát hiện lỗi trong quá trình giải, chỉ viết bước đúng — không đề cập đến lỗi cũ. + +══ OUTPUT SCHEMA ══ + +EXACT output schema (use these exact key names, no others): +{ + "problem_type": "string mô tả dạng bài", + "used_knowledge_ids": ["list", "of", "context", "ids", "you", "used"], + "steps": ["Bước 1: ...", "Bước 2: ...", "Bước 3: ..."], + "final_answer": "đáp án cuối dưới dạng chuỗi — phải khớp với kết luận của bước cuối cùng", + "confidence": "high" +} + +CRITICAL: "steps" MUST be a JSON array of plain strings — NOT objects, NOT dicts, NOT nested JSON. +Each step is one string like "Bước 1: Tính đạo hàm $y' = 3x^2 - 6x$". +"final_answer" MUST be a plain string — NOT an object or dict. + +EXAMPLE (follow this format exactly): +Input: "Giải phương trình $x^2 - 5x + 6 = 0$" +Output: +{ + "problem_type": "phương trình bậc hai", + "used_knowledge_ids": [], + "steps": ["Bước 1: Tính $\\Delta = 25 - 24 = 1 > 0$", "Bước 2: $x_1 = 3, x_2 = 2$"], + "final_answer": "$x = 2$ hoặc $x = 3$", + "confidence": "high" +} + +- used_knowledge_ids: ONLY IDs that appear in the user's context list. +- steps: MUST be written entirely in Vietnamese, each starting with "Bước N: ". Work through the problem fully before writing final_answer; final_answer must be consistent with your last step. +- final_answer: for equations, check candidate solutions against the original equation and exclude extraneous roots. + For differential equations (ODEs), final_answer MUST be the general solution function (e.g. "$y = C_1e^{2x} + C_2e^{3x}$"), + NOT the characteristic roots. Characteristic roots are intermediate work only. +- confidence: MUST be exactly one of: "high", "medium", "low". + +MULTIPLE-CHOICE PROBLEMS (options labeled A/B/C/D, or "Đáp án A", "A.", "A)" etc.): +- Solve the problem completely using standard methods, treating the options as unknown at first. +- Match your computed result against the listed options. +- In final_answer: state the selected letter and value, e.g. "Chọn B: $x = 3$" +- If no option matches, write "Không có đáp án phù hợp, kết quả tính được: " +- problem_type: prepend "trắc nghiệm" + topic, e.g. "trắc nghiệm đại số" + +ESSAY / OPEN-ENDED PROBLEMS (asks to explain, discuss, describe, compare — no labeled answer choices): +- Use the same schema; write every reasoning step in full sentences +- final_answer: state the main conclusion concisely in Vietnamese + +PROOF PROBLEMS (questions containing "prove", "show that", "chứng minh", "chứng tỏ", "cm rằng", "demonstrate", "verify that"): +You MUST still use the EXACT same schema above — do NOT wrap the output in a "proof" key or any other wrapper. +- problem_type: "chứng minh" + brief method, e.g. "chứng minh quy nạp", "chứng minh hình học", "chứng minh bất đẳng thức" +- steps: each proof step as a plain string in Vietnamese, e.g. ["Bước 1: Phân tích...", "Bước 2: Xét..."] +- final_answer: the proved statement in full, ending with "∎", e.g. "Đã chứng minh $n^3 - n$ chia hết cho $6$ với mọi $n \\in \\mathbb{N}^+$. ∎" +- Do NOT leave final_answer empty. Do NOT use keys like "statement", "conclusion", or "proof" — use "final_answer". + +PROBLEMS WITH VISUAL DESCRIPTIONS (extracted from images — contain phrases like "Tam giác ABC", "đồ thị hàm số qua điểm", "hình vẽ cho thấy"): +- Treat the visual description exactly as you would an explicit numeric problem +- Extract all dimensions, labels, and relationships from the description before solving +- Include a step confirming which values were read from the description + +MULTIPLE INDEPENDENT PROBLEMS (input contains several completely separate problems labeled "Bài 1:", "Bài 2:", "Câu 1:", "Câu 2:", "Problem 1:", etc.): +Solve EVERY problem completely in sequence. +- steps: group by problem. Start each group with "**Bài 1)**", "**Câu 1)**", etc., followed by numbered steps. +- final_answer: "Bài 1: ; Bài 2: ; ..." +- problem_type: "nhiều bài toán" + the dominant topic. + +MULTI-PART PROBLEMS (questions with labeled parts like "a)", "b)", "c)" or "Ý a", "Ý b", "Câu a", "Phần a"): +Solve EVERY part completely. Do NOT skip or partially answer any part. +- steps: group steps by part. Start each group with a plain-string header "**Phần a)**", "**Phần b)**", etc., followed by that part's numbered steps. +- final_answer: combine all parts as "a) ; b) ; c) " in one string. + +EXAMPLE (multi-part): +Input: "a) Giải phương trình $x^2 - 5x + 6 = 0$. b) Giải bất phương trình $x^2 - 5x + 6 < 0$." +Output: +{ + "problem_type": "phương trình và bất phương trình bậc hai", + "used_knowledge_ids": [], + "steps": ["**Phần a)** Giải phương trình $x^2 - 5x + 6 = 0$", "Bước 1: Tính $\\Delta = 25 - 24 = 1 > 0$", "Bước 2: $x_1 = 3,\\ x_2 = 2$", "**Phần b)** Giải bất phương trình $x^2 - 5x + 6 < 0$", "Bước 1: Tam thức âm khi $x_2 < x < x_1$, tức $2 < x < 3$"], + "final_answer": "a) $x = 2$ hoặc $x = 3$; b) $2 < x < 3$", + "confidence": "high" +} + +ĐỊNH DẠNG TOÁN (bắt buộc): +- Dùng $...$ cho MỌI biểu thức toán học. Xem lại các ví dụ ở phần MANDATORY RULES phía trên. +- Dùng $$...$$ cho phương trình hiển thị độc lập (mỗi dòng một phương trình, không có văn bản xung quanh). +- KHÔNG BAO GIỜ viết lệnh LaTeX ngoài dấu dollar (ví dụ: \\frac, \\sqrt phải nằm trong $...$). +- Văn bản thuần tiếng Việt, đơn vị đo lường không cần dấu dollar. + +If the context array is empty or unhelpful, solve using your mathematical knowledge directly. +Set confidence to "medium" or "low" accordingly — do not refuse to answer. + +Output ONLY the JSON object. No other text.""" + +PROMPT_VALIDATE = """You are a math solution verifier. +Given a solver_output (problem_type, steps, final_answer) and context wiki units: + +1. Check that each step follows logically from the previous one. +2. CRITICAL: Check that final_answer matches the conclusion of the last step. If they differ, this is always an error — set valid=false and report "final_answer contradicts the last step". +3. For equations/inequalities: substitute the final_answer back into the ORIGINAL problem to confirm it satisfies it. If substitution fails, set valid=false. +4. Check for extraneous roots: if the original problem contains a square root, absolute value, or logarithm, verify no extraneous solutions are included in final_answer. +5. If the context array is empty, verify correctness by: (1) checking each step follows logically from the previous, (2) substituting the final answer back into the original equation/expression, (3) checking for extraneous roots. Do not penalise for absent wiki units. +6. For systems of equations (multiple variables), substitute EACH variable's value into EVERY equation in the system separately. If any single equation is not satisfied, set valid=false and report which equation fails. + +MULTIPLE-CHOICE PROBLEMS (final_answer starts with "Chọn"): +- Extract the selected letter (A/B/C/D) and the computed value from final_answer. +- Verify the computed value by substitution as in rule 3. +- Verify the chosen letter matches that value in the problem's option list; if it doesn't, add "Đáp án đã chọn không khớp với kết quả tính được" to issues and set valid=false. + +PROOF PROBLEMS (problem_type contains "chứng minh", "chứng tỏ", or "proof"): +- Skip rules 3 and 4 entirely — substitution and extraneous-root checks do not apply to proofs. +- For rule 2: verify only that the final conclusion follows logically from the last proof step; the exact wording need not match. +- Focus rule 1 on logical validity: each step must follow from prior steps or known theorems. + +MULTI-PART PROBLEMS (final_answer starts with "a)" or steps contain "**Phần"): +- Validate each part's steps independently in order. +- For rule 2: check that the last step of each part's group is consistent with that part's sub-answer in final_answer (e.g. the "a)" portion for part a). Do NOT try to match the entire combined final_answer against a single step. +- For rule 3: apply substitution only to parts that are equations or inequalities, using that part's sub-problem text and sub-answer. Do NOT substitute the combined "a) X; b) Y" string into any equation. + +Return JSON: {"valid": true|false, "issues": ["brief description of each specific error"]} +If valid, issues must be []. No other text.""" + +PROMPT_CONCEPT_INGEST = """You are a math knowledge extraction system. +Given a math article or tutorial excerpt, extract structured wiki knowledge units. +There are no exam problems in this text — extract only knowledge units. + +LANGUAGE RULE: Write all "content" fields in Vietnamese. Math expressions may use standard notation. + +For each wiki unit identify: +- id: unique slug (e.g. "alg-quadratic-formula-procedure") +- type: must be one of: procedure | concept | theorem | definition | fact +- topic: algebra|geometry|calculus|trigonometry|combinatorics|number_theory|statistics|probability|differential_equations|linear_algebra|multivariable_calculus +- subtopic: specific subtopic (e.g. "quadratic equations") +- content: the knowledge as a self-contained explanation (2-5 sentences) +- problem_ids: always [] +- bloom_level: integer 1–6 based on the cognitive demand of this unit: + 1=remember (recall formula/definition), 2=understand (explain meaning), + 3=apply (execute procedure), 4=analyze (decompose/derive), + 5=evaluate (assess correctness), 6=create (synthesize new) + +Extract 2-6 units per excerpt. Prefer concrete procedures and patterns over vague definitions. +Return JSON: {"wiki_units": [...]}. No other text.""" + +PROMPT_REVIEW = """You are a Vietnamese math solution reviewer and grader. +You will receive a JSON object with: +- "problem": the math problem text +- "solution": a student's solution attempt (may be transcribed from a handwritten image) +- "context": optional wiki knowledge units for reference + +Evaluate the solution systematically: +1. Identify the correct approach and expected answer for the problem. +2. Trace the student's steps one by one; flag the first error and all subsequent errors. +3. Assess how much of the reasoning is correct. + +EXACT output schema — respond with ONLY this JSON object, no prose: +{ + "verdict": "correct" | "partial" | "incorrect", + "score": "X/10", + "correct_steps": ["each step or portion of the solution that is right"], + "errors": ["specific description of each error, including where in the solution it occurs"], + "feedback": "concise overall feedback in Vietnamese — positive first, then what to fix", + "correct_approach": "brief description of the correct method if the student used the wrong one; empty string if approach was right" +} + +Scoring guide: +- "correct" (8–10): all steps and final answer are right; minor arithmetic slips get 9 +- "partial" (4–7): right approach, wrong calculation or incomplete; shows understanding +- "incorrect" (0–3): fundamental error in method, or no meaningful mathematical work shown + +Rules: +- Write ALL text fields (correct_steps, errors, feedback, correct_approach) entirely in Vietnamese. English is forbidden in any field. +- KÝ HIỆU TOÁN: Bọc MỌI biểu thức toán học trong $...$. Ví dụ: $x^2 + 3x - 4 = 0$, $\\sqrt{2}$, $\\frac{a}{b}$. TUYỆT ĐỐI không dùng ký hiệu Unicode toán học (√, ², ≤) ngoài dấu dollar. +- Be factual and objective. No emotional language, enthusiasm markers, or personal commentary. +- Be specific: "Bước 2: sai vì ..." not just "có lỗi" +- For proofs: judge logical validity, not exact wording +- For multiple-choice: check if the selected option matches the computed result +- Output ONLY the JSON object. No other text.""" + +MODE_PROMPTS: dict[str, str] = { + "INGEST": PROMPT_INGEST, + "CLASSIFY": PROMPT_CLASSIFY, + "RERANK": PROMPT_RERANK, + "SOLVE": PROMPT_SOLVE, + "VALIDATE": PROMPT_VALIDATE, + "CONCEPT_INGEST": PROMPT_CONCEPT_INGEST, + "REVIEW": PROMPT_REVIEW, +} diff --git a/backend/app/math_wiki/question_generator.py b/backend/app/math_wiki/question_generator.py new file mode 100644 index 0000000000000000000000000000000000000000..d763b355e25ff487121899b29740a6070b7747b0 --- /dev/null +++ b/backend/app/math_wiki/question_generator.py @@ -0,0 +1,239 @@ +"""MATH² + AgenticMath question generation pipeline. + +MATH²: extracts core skills from existing questions, recombines skill pairs, +and prompts Claude to generate novel questions. +AgenticMath: 4-stage QC — Filter, Rephrase, Augment, Evaluate. +Generated questions flow to staged_wiki_units for admin approval. +""" +import json +import logging +import uuid +from openai import AsyncOpenAI +from app.config import get_settings +from app.agent.core import call_with_retry + +logger = logging.getLogger(__name__) + + +async def extract_skills(client: AsyncOpenAI, question: str, topic: str) -> list[str]: + """MATH² stage 1: extract core skills from an existing question.""" + settings = get_settings() + prompt = ( + f"Phân tích bài toán toán THPT sau và liệt kê 2-3 kỹ năng toán học cốt lõi cần thiết.\n" + f"Chủ đề: {topic}\nBài toán: {question}\n\n" + "Trả về JSON: {\"skills\": [\"kỹ năng 1\", \"kỹ năng 2\", ...]}" + ) + try: + resp = await call_with_retry( + client, + model=settings.haiku_model, + max_tokens=150, + messages=[{"role": "user", "content": prompt}], + response_format={"type": "json_object"}, + ) + parsed = json.loads(resp.choices[0].message.content or "{}") + return parsed.get("skills", []) + except Exception as exc: + logger.warning("extract_skills failed: %s", exc) + return [] + + +async def generate_question( + client: AsyncOpenAI, + skill_a: str, + skill_b: str, + topic: str, + difficulty: str = "medium", +) -> dict | None: + """MATH² stage 2: generate a novel question combining two skills.""" + settings = get_settings() + diff_map = { + "easy": "dễ (học sinh trung bình làm được)", + "medium": "vừa (cần suy nghĩ)", + "hard": "khó (cần tư duy cao)", + } + diff_label = diff_map.get(difficulty, "vừa") + prompt = ( + f"Tạo một câu hỏi trắc nghiệm toán THPT Việt Nam kết hợp hai kỹ năng:\n" + f"- Kỹ năng 1: {skill_a}\n- Kỹ năng 2: {skill_b}\n" + f"Chủ đề: {topic}. Độ khó: {diff_label}.\n\n" + "Yêu cầu: câu hỏi rõ ràng, 4 lựa chọn A/B/C/D, có đúng 1 đáp án đúng, giải thích ngắn gọn.\n" + "Trả về JSON: {\"question\": \"...\", \"choices\": [\"A. ...\", \"B. ...\", \"C. ...\", \"D. ...\"], " + "\"correct\": 0, \"explanation\": \"...\", \"topic\": \"...\"}" + ) + try: + resp = await call_with_retry( + client, + model=settings.default_model, + max_tokens=600, + messages=[{"role": "user", "content": prompt}], + response_format={"type": "json_object"}, + ) + parsed = json.loads(resp.choices[0].message.content or "{}") + if not parsed.get("question") or not parsed.get("choices"): + return None + return parsed + except Exception as exc: + logger.warning("generate_question failed: %s", exc) + return None + + +async def agenticmath_qc(client: AsyncOpenAI, question: dict) -> dict | None: + """AgenticMath 4-stage QC: Filter → Rephrase → Augment → Evaluate.""" + settings = get_settings() + + # Stage 1 — Filter: check mathematical correctness + filter_prompt = ( + f"Kiểm tra câu hỏi toán sau có chính xác về mặt toán học không:\n" + f"Câu hỏi: {question['question']}\nLựa chọn: {question['choices']}\n" + f"Đáp án đúng (index 0-3): {question['correct']}\n\n" + "Trả về JSON: {\"valid\": true/false, \"issue\": \"mô tả vấn đề nếu có\"}" + ) + try: + resp = await call_with_retry( + client, + model=settings.haiku_model, + max_tokens=150, + messages=[{"role": "user", "content": filter_prompt}], + response_format={"type": "json_object"}, + ) + check = json.loads(resp.choices[0].message.content or "{}") + if not check.get("valid", True): + logger.info("AgenticMath Filter rejected: %s", check.get("issue")) + return None + except Exception: + pass # proceed if QC call fails + + # Stage 2 — Rephrase: improve clarity + rephrase_prompt = ( + f"Viết lại câu hỏi sau để rõ ràng hơn, tự nhiên hơn, giữ nguyên nội dung toán học:\n" + f"{question['question']}\n\nTrả về JSON: {{\"question\": \"câu hỏi đã viết lại\"}}" + ) + try: + resp = await call_with_retry( + client, + model=settings.haiku_model, + max_tokens=300, + messages=[{"role": "user", "content": rephrase_prompt}], + response_format={"type": "json_object"}, + ) + rephrased = json.loads(resp.choices[0].message.content or "{}") + if rephrased.get("question"): + question = {**question, "question": rephrased["question"]} + except Exception: + pass + + # Stage 3 — Augment: enrich explanation + augment_prompt = ( + f"Bổ sung lời giải chi tiết hơn cho câu hỏi:\n{question['question']}\n" + f"Đáp án đúng: {question['choices'][question['correct']]}\n" + f"Lời giải hiện tại: {question.get('explanation', '')}\n\n" + "Trả về JSON: {\"explanation\": \"lời giải chi tiết bằng tiếng Việt\"}" + ) + try: + resp = await call_with_retry( + client, + model=settings.haiku_model, + max_tokens=400, + messages=[{"role": "user", "content": augment_prompt}], + response_format={"type": "json_object"}, + ) + augmented = json.loads(resp.choices[0].message.content or "{}") + if augmented.get("explanation"): + question = {**question, "explanation": augmented["explanation"]} + except Exception: + pass + + # Stage 4 — Evaluate: final quality score + eval_prompt = ( + f"Đánh giá chất lượng câu hỏi toán THPT sau từ 1-5:\n{question['question']}\n" + "Tiêu chí: rõ ràng, chính xác toán học, phù hợp THPT, có giá trị học tập.\n" + "Trả về JSON: {\"score\": 4, \"approve\": true}" + ) + try: + resp = await call_with_retry( + client, + model=settings.haiku_model, + max_tokens=100, + messages=[{"role": "user", "content": eval_prompt}], + response_format={"type": "json_object"}, + ) + evaluation = json.loads(resp.choices[0].message.content or "{}") + if not evaluation.get("approve", True) or evaluation.get("score", 5) < 3: + logger.info( + "AgenticMath Evaluate rejected question (score=%s)", evaluation.get("score") + ) + return None + except Exception: + pass + + return question + + +async def run_generation_job( + pool, + client: AsyncOpenAI, + topic: str, + count: int = 5, + difficulty: str = "medium", +) -> dict: + """Full MATH² + AgenticMath pipeline. Stages generated questions for admin approval.""" + import itertools + import random + + # Sample existing questions for skill extraction + rows = await pool.fetch( + "SELECT problem_text, topic FROM problems WHERE topic = ? ORDER BY RANDOM() LIMIT 10", + topic, + ) + if not rows: + rows = await pool.fetch( + "SELECT problem_text, topic FROM problems ORDER BY RANDOM() LIMIT 10" + ) + + # Extract skill pool + skills: list[str] = [] + for row in rows[:5]: + extracted = await extract_skills(client, row["problem_text"], row["topic"]) + skills.extend(extracted) + skills = list(set(skills))[:20] + + if len(skills) < 2: + skills = [topic, "tư duy logic", "tính toán", "phân tích"] + + # Generate questions from skill pairs + generated = [] + pairs = list(itertools.combinations(skills, 2)) + random.shuffle(pairs) + + for skill_a, skill_b in pairs[: count * 2]: + if len(generated) >= count: + break + q = await generate_question(client, skill_a, skill_b, topic, difficulty) + if not q: + continue + q = await agenticmath_qc(client, q) + if q: + generated.append(q) + + # Stage approved questions for admin review + staged_ids = [] + for q in generated: + unit_id = f"gen_{uuid.uuid4().hex[:8]}" + await pool.execute( + """INSERT OR IGNORE INTO staged_wiki_units (id, type, topic, content, source, created_at) + VALUES (?, 'procedure', ?, ?, 'generated', datetime('now'))""", + unit_id, + topic, + json.dumps( + { + "question": q["question"], + "choices": q["choices"], + "correct": q["correct"], + "explanation": q["explanation"], + } + ), + ) + staged_ids.append(unit_id) + + return {"generated": len(generated), "staged_ids": staged_ids, "topic": topic} diff --git a/backend/app/math_wiki/schemas.py b/backend/app/math_wiki/schemas.py new file mode 100644 index 0000000000000000000000000000000000000000..ba7a1e44d1825b01ed3ba2300c787f44890de61b --- /dev/null +++ b/backend/app/math_wiki/schemas.py @@ -0,0 +1,124 @@ +from pydantic import BaseModel, ConfigDict, field_validator + + +class WikiUnit(BaseModel): + model_config = ConfigDict(extra="forbid") + + id: str + type: str # pattern | procedure | concept | mistake + topic: str + subtopic: str + content: str + problem_ids: list[str] + bloom_level: int = 0 # 0=untagged, 1=remember … 6=create (Bloom's taxonomy) + + @field_validator("problem_ids", mode="before") + @classmethod + def coerce_problem_ids(cls, v: object) -> list[str]: + if isinstance(v, list): + return [str(x) for x in v] + return v + + +class Problem(BaseModel): + model_config = ConfigDict(extra="forbid") + + problem_id: str + problem_text: str + choices: list[str] | None = None + correct_answer: str | None = None + topic: str + subtopic: str + difficulty: str # easy | medium | hard + problem_type: str + figure_svg: str | None = None + + +class FigureOutput(BaseModel): + type: str + data: str | None = None + error: str | None = None + + +class SolverOutput(BaseModel): + problem_type: str + used_knowledge_ids: list[str] + steps: list[str] + final_answer: str + confidence: str # high | medium | low + figure: "FigureOutput | None" = None + figures: "dict[str, FigureOutput]" = {} + + +class ValidationResult(BaseModel): + model_config = ConfigDict(extra="forbid") + + valid: bool + issues: list[str] + + +class ClassifyResult(BaseModel): + model_config = ConfigDict(extra="forbid") + + label: str + + +class RerankResult(BaseModel): + model_config = ConfigDict(extra="forbid") + + top_ids: list[str] + + +class IngestOutput(BaseModel): + model_config = ConfigDict(extra="forbid") + + problems: list[Problem] + wiki_units: list[WikiUnit] + + +class ConceptIngestOutput(BaseModel): + model_config = ConfigDict(extra="forbid") + + wiki_units: list[WikiUnit] + + +class DecomposedQuery(BaseModel): + model_config = ConfigDict(extra="forbid") + + primary_topic: str + secondary_topics: list[str] + sub_questions: list[str] + requires_multi_domain: bool + + +class ReviewOutput(BaseModel): + model_config = ConfigDict(extra="forbid") + + verdict: str # correct | partial | incorrect + score: str # "X/10" + correct_steps: list[str] + errors: list[str] + feedback: str + correct_approach: str = "" + + +class StagedWikiUnit(BaseModel): + staged_id: str + id: str + type: str + topic: str + subtopic: str + content: str + problem_ids: list[str] + source: str = "manual" + source_url: str | None = None + status: str = "pending" + proposed_by: str = "system" + created_at: str = "" + + @field_validator("problem_ids", mode="before") + @classmethod + def coerce_problem_ids(cls, v: object) -> list[str]: + if isinstance(v, list): + return [str(x) for x in v] + return v diff --git a/backend/app/math_wiki/score_predictor.py b/backend/app/math_wiki/score_predictor.py new file mode 100644 index 0000000000000000000000000000000000000000..285d3dad80b4771be8155e45ccbe5b830b5dc5e7 --- /dev/null +++ b/backend/app/math_wiki/score_predictor.py @@ -0,0 +1,143 @@ +"""Score prediction with IRT (Stage 2) and DKVMN (Stage 3) integration. + +Stage 1: Bootstrap CI (already in main.py /predict-score) +Stage 2: IRT ability-based prediction — activated when student has a CAT session theta +Stage 3: DKVMN concept-aware prediction — activated when DKVMN model is loaded +""" +import logging +import math + +logger = logging.getLogger(__name__) + + +def irt_expected_score(theta: float, items: list[dict]) -> float: + """Stage 2: Predict expected raw score using IRT Item Characteristic Curve. + + Args: + theta: Student ability estimate (from CAT session) + items: List of dicts with keys 'a' (discrimination), 'b' (difficulty), 'c' (guessing) + Returns: + Expected score 0-10 + """ + if not items: + return 5.0 + total = 0.0 + for item in items: + a = item.get("a", 1.0) + b = item.get("b", 0.0) + c = item.get("c", 0.25) + try: + p = c + (1 - c) / (1 + math.exp(-a * (theta - b))) + except (OverflowError, ZeroDivisionError): + p = c + total += p + raw = total / len(items) + return round(max(0.0, min(10.0, raw * 10)), 1) + + +def irt_confidence_interval(theta: float, theta_se: float, items: list[dict]) -> tuple[float, float]: + """Derive CI from standard error of ability estimate.""" + low_theta = theta - 1.28 * theta_se # 80% CI + high_theta = theta + 1.28 * theta_se + low_score = irt_expected_score(low_theta, items) + high_score = irt_expected_score(high_theta, items) + return (min(low_score, high_score), max(low_score, high_score)) + + +async def irt_predict(pool, user_id: int, exam_items: list[dict] | None = None) -> dict | None: + """Stage 2: IRT prediction from most recent CAT session ability. + + Returns None if no CAT session exists for the user. + """ + try: + row = await pool.fetchrow( + """SELECT ability, ability_se FROM exam_sessions + WHERE user_id = ? AND status = 'complete' + ORDER BY updated_at DESC LIMIT 1""", + user_id, + ) + if not row: + return None + + theta = row["ability"] + theta_se = row["ability_se"] + + # Use exam items if provided, else use a representative item bank sample + if not exam_items: + rows = await pool.fetch( + "SELECT irt_a as a, irt_b as b, irt_c as c FROM problems WHERE irt_b IS NOT NULL LIMIT 25" + ) + exam_items = [dict(r) for r in rows] + + if not exam_items: + return None + + predicted = irt_expected_score(theta, exam_items) + low, high = irt_confidence_interval(theta, theta_se, exam_items) + width = high - low + confidence = "high" if width < 0.8 else "medium" if width < 1.5 else "low" + + return { + "predicted": predicted, + "low": low, + "high": high, + "confidence": confidence, + "method": "irt", + "theta": round(theta, 3), + } + except Exception as exc: + logger.debug("irt_predict failed: %s", exc) + return None + + +async def dkvmn_predict(pool, user_id: int, exam_questions: list[dict] | None = None) -> dict | None: + """Stage 3: DKVMN concept-aware prediction. + + Returns None if DKVMN model is not loaded or concept mastery unavailable. + """ + try: + from app.math_wiki.dkvmn import predict_mastery, _model_loaded + if not _model_loaded: + return None + + mastery = await predict_mastery(pool, user_id) + if not mastery: + return None + + if not exam_questions: + rows = await pool.fetch( + "SELECT problem_id, topic, difficulty FROM problems ORDER BY RANDOM() LIMIT 25" + ) + exam_questions = [dict(r) for r in rows] + + if not exam_questions: + return None + + # Predict P(correct) per question based on concept mastery + p_correct_list = [] + for q in exam_questions: + topic = q.get("topic", "") + p = mastery.get(topic, 0.5) # default 50% if topic not in mastery + p_correct_list.append(p) + + avg_p = sum(p_correct_list) / len(p_correct_list) if p_correct_list else 0.5 + predicted = round(avg_p * 10, 1) + # CI based on variance across topics + import statistics + std = statistics.stdev(p_correct_list) if len(p_correct_list) > 1 else 0.2 + low = round(max(0.0, (avg_p - std) * 10), 1) + high = round(min(10.0, (avg_p + std) * 10), 1) + width = high - low + confidence = "high" if width < 0.8 else "medium" if width < 1.5 else "low" + + return { + "predicted": predicted, + "low": low, + "high": high, + "confidence": confidence, + "method": "dkvmn", + "concept_count": len(mastery), + } + except Exception as exc: + logger.debug("dkvmn_predict failed: %s", exc) + return None diff --git a/backend/app/math_wiki/storage/__init__.py b/backend/app/math_wiki/storage/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/app/math_wiki/storage/analytics.py b/backend/app/math_wiki/storage/analytics.py new file mode 100644 index 0000000000000000000000000000000000000000..6e3f86d7a17df713054091bc91b10cf4876041bf --- /dev/null +++ b/backend/app/math_wiki/storage/analytics.py @@ -0,0 +1,232 @@ +import hashlib +import json +import logging +from datetime import datetime, timedelta +from typing import Optional + +logger = logging.getLogger(__name__) + + +async def log_solution( + pool, + problem_text: str, + classified_topic: str, + retrieved_ids: list[str], + used_ids: list[str], + confidence: str, + valid: bool, + issues: Optional[list[str]], + wiki_assisted: bool, +) -> int: + """Log a solved problem for analytics. Returns the log ID. Non-fatal: all exceptions are caught.""" + problem_hash = hashlib.md5(problem_text.encode()).hexdigest() + # Try to link to a known problem ID (enables AutoIRT calibration) + problem_id: str | None = None + try: + async with pool.acquire() as conn: + pid_row = await conn.fetchrow( + "SELECT problem_id FROM problems WHERE problem_hash = $1 LIMIT 1", + problem_hash, + ) + if pid_row: + problem_id = pid_row["problem_id"] + except Exception: + pass # non-fatal — problem_id stays None + try: + async with pool.acquire() as conn: + row = await conn.fetchrow( + """ + INSERT INTO solution_logs + (problem_text, problem_hash, classified_topic, retrieved_ids, + used_knowledge_ids, solver_confidence, validation_valid, + validation_issues, wiki_assisted, problem_id) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) + RETURNING id + """, + problem_text, + problem_hash, + classified_topic, + json.dumps(retrieved_ids), + json.dumps(used_ids), + confidence, + valid, + json.dumps(issues or []), + wiki_assisted, + problem_id, + ) + return row["id"] + except Exception as exc: + logger.warning("log_solution failed (non-fatal): %s", exc) + return -1 + + +async def get_unit_usage_stats(pool, days: int = 30) -> list[dict]: + cutoff = (datetime.now() - timedelta(days=days)).isoformat() + try: + async with pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT used_knowledge_ids, solver_confidence, validation_valid + FROM solution_logs + WHERE created_at >= $1 + """, + cutoff, + ) + except Exception as exc: + logger.warning("get_unit_usage_stats failed: %s", exc) + return [] + + stats: dict[str, dict] = {} + for row in rows: + used_ids = json.loads(row["used_knowledge_ids"]) + for uid in used_ids: + if uid not in stats: + stats[uid] = {"times_used": 0, "high_conf": 0, "valid_count": 0} + stats[uid]["times_used"] += 1 + if row["solver_confidence"] == "high": + stats[uid]["high_conf"] += 1 + if row["validation_valid"]: + stats[uid]["valid_count"] += 1 + + result = [] + for uid, s in stats.items(): + total = s["times_used"] + result.append({ + "unit_id": uid, + "times_used": total, + "avg_confidence": "high" if s["high_conf"] / total > 0.5 else "medium", + "validation_rate": round(s["valid_count"] / total, 4) if total else 0.0, + }) + return sorted(result, key=lambda x: x["times_used"], reverse=True) + + +async def get_retrieval_effectiveness(pool, days: int = 30) -> dict: + cutoff = (datetime.now() - timedelta(days=days)).isoformat() + try: + async with pool.acquire() as conn: + row = await conn.fetchrow( + """ + SELECT + COUNT(*) AS total_solutions, + AVG(CASE WHEN wiki_assisted THEN 1.0 ELSE 0.0 END) AS wiki_assisted_rate, + AVG(CASE WHEN validation_valid THEN 1.0 ELSE 0.0 END) AS validation_rate, + COUNT(DISTINCT used_knowledge_ids) AS unique_units_used + FROM solution_logs + WHERE created_at >= $1 + """, + cutoff, + ) + except Exception as exc: + logger.warning("get_retrieval_effectiveness failed: %s", exc) + return {"error": str(exc)} + + if not row or row["total_solutions"] == 0: + return {"error": "no data"} + + return { + "total_solutions": row["total_solutions"], + "wiki_assisted_rate": round(float(row["wiki_assisted_rate"] or 0), 4), + "validation_rate": round(float(row["validation_rate"] or 0), 4), + "unique_units_used": row["unique_units_used"], + } + + +async def log_solution_feedback(pool, log_id: int, actual_correct: bool) -> None: + """Record whether a solution was actually correct (user-confirmed or test-verified).""" + if log_id < 0 or pool is None: + return + try: + async with pool.acquire() as conn: + await conn.execute( + "UPDATE solution_logs SET actual_correct = $1 WHERE id = $2", + int(actual_correct), log_id, + ) + except Exception as exc: + logger.warning("log_solution_feedback failed (non-fatal): %s", exc) + + +async def get_calibration_report(pool, days: int = 30) -> dict: + """Return confidence calibration cross-tab for the last N days. + + Reports: + - Overall pass rate (validation_valid) + - For each confidence level: total, correct (actual_correct=1), rate + - High-confidence wrong count (critical failures) + """ + cutoff = (datetime.now() - timedelta(days=days)).isoformat() + try: + async with pool.acquire() as conn: + rows = await conn.fetch( + """ + SELECT solver_confidence, validation_valid, actual_correct + FROM solution_logs + WHERE created_at >= $1 + """, + cutoff, + ) + except Exception as exc: + logger.warning("get_calibration_report failed: %s", exc) + return {"error": str(exc)} + + if not rows: + return {"error": "no data", "days": days} + + by_conf: dict[str, dict] = { + "high": {"total": 0, "validated": 0, "confirmed_correct": 0, "confirmed_wrong": 0}, + "medium": {"total": 0, "validated": 0, "confirmed_correct": 0, "confirmed_wrong": 0}, + "low": {"total": 0, "validated": 0, "confirmed_correct": 0, "confirmed_wrong": 0}, + } + for row in rows: + conf = row["solver_confidence"] or "medium" + if conf not in by_conf: + conf = "medium" + by_conf[conf]["total"] += 1 + if row["validation_valid"]: + by_conf[conf]["validated"] += 1 + if row["actual_correct"] is not None: + if row["actual_correct"]: + by_conf[conf]["confirmed_correct"] += 1 + else: + by_conf[conf]["confirmed_wrong"] += 1 + + total = len(rows) + validated = sum(r["validation_valid"] for r in rows) + high_conf_wrong = by_conf["high"]["confirmed_wrong"] + + high_total = by_conf["high"]["total"] + high_confirmed = by_conf["high"]["confirmed_correct"] + by_conf["high"]["confirmed_wrong"] + high_correct_rate = ( + round(by_conf["high"]["confirmed_correct"] / high_confirmed, 3) if high_confirmed else None + ) + + return { + "days": days, + "total_solutions": total, + "validation_rate": round(validated / total, 3) if total else 0, + "high_confidence_wrong": high_conf_wrong, + "high_confidence_correct_rate": high_correct_rate, + "calibration_target": 0.85, + "calibration_ok": (high_correct_rate or 0) >= 0.85 if high_correct_rate is not None else None, + "by_confidence": { + conf: { + "total": s["total"], + "validation_rate": round(s["validated"] / s["total"], 3) if s["total"] else 0, + "confirmed_correct": s["confirmed_correct"], + "confirmed_wrong": s["confirmed_wrong"], + } + for conf, s in by_conf.items() + }, + } + + +async def get_flagged_count(pool, days: int = 7) -> int: + cutoff = (datetime.now() - timedelta(days=days)).isoformat() + try: + async with pool.acquire() as conn: + return await conn.fetchval( + "SELECT COUNT(*) FROM flagged_solutions WHERE reviewed = false AND flagged_at >= $1", + cutoff, + ) + except Exception as exc: + logger.warning("get_flagged_count failed: %s", exc) + return 0 diff --git a/backend/app/math_wiki/storage/bm25.py b/backend/app/math_wiki/storage/bm25.py new file mode 100644 index 0000000000000000000000000000000000000000..44bc40ea2d373dd06a63c9127e48f6fe32302f8e --- /dev/null +++ b/backend/app/math_wiki/storage/bm25.py @@ -0,0 +1,75 @@ +import re +from rank_bm25 import BM25Okapi +from app.math_wiki.schemas import WikiUnit + +# Match LaTeX commands (\frac, \sqrt, \sin, etc.) — keep as single token +_LATEX_CMD_RE = re.compile(r'\\[a-zA-Z]+') +# Match variable+power expressions (x^2, a_n, x_1) +_VAR_EXPR_RE = re.compile(r'[a-zA-Z][_^][a-zA-Z0-9]+') +# Match plain numbers and identifiers +_WORD_RE = re.compile(r'[^\s+\-*/=×÷<>!?;:,.()\[\]{}\|\\]+') + + +def _bigrams(words: list[str]) -> list[str]: + """Generate bigrams from word list for Vietnamese multi-word math terms. + e.g. ['bất', 'đẳng', 'thức'] → ['bất đẳng', 'đẳng thức'] + """ + return [f"{words[i]} {words[i+1]}" for i in range(len(words) - 1)] + + +def _tokenize(text: str) -> list[str]: + """Math-aware tokenizer: preserves LaTeX commands, variable expressions, + and generates Vietnamese bigrams for multi-word math term matching.""" + tokens: list[str] = [] + remaining = text.lower() + + # Strip dollar delimiters — process content uniformly + remaining = re.sub(r'\$+', ' ', remaining) + + # Extract LaTeX commands first (highest priority) + for m in _LATEX_CMD_RE.finditer(remaining): + cmd = m.group()[1:] # strip backslash + if len(cmd) >= 2: + tokens.append(cmd) + remaining = _LATEX_CMD_RE.sub(' ', remaining) + + # Extract variable expressions (x^2, a_n) + for m in _VAR_EXPR_RE.finditer(remaining): + tokens.append(m.group()) + remaining = _VAR_EXPR_RE.sub(' ', remaining) + + # Extract remaining words/numbers + words = [] + for m in _WORD_RE.finditer(remaining): + t = m.group().strip('_^{}') + if len(t) >= 2: + tokens.append(t) + words.append(t) + + # Add bigrams for Vietnamese multi-word math terms + # e.g. "bất đẳng thức" → tokens include "bất đẳng" and "đẳng thức" + tokens.extend(_bigrams(words)) + + return tokens + + +def build_bm25_index(units: list[WikiUnit]) -> tuple[BM25Okapi | None, list[str]]: + if not units: + return None, [] + corpus = [_tokenize(u.content) for u in units] + id_map = [u.id for u in units] + return BM25Okapi(corpus), id_map + + +def query_bm25( + index: BM25Okapi | None, + id_map: list[str], + query: str, + top_k: int = 10, +) -> list[str]: + if index is None or not id_map: + return [] + tokens = _tokenize(query) + scores = index.get_scores(tokens) + ranked = sorted(range(len(scores)), key=lambda i: scores[i], reverse=True) + return [id_map[i] for i in ranked[:top_k] if scores[i] > 0] diff --git a/backend/app/math_wiki/storage/pg_db.py b/backend/app/math_wiki/storage/pg_db.py new file mode 100644 index 0000000000000000000000000000000000000000..3fcddca74f7f2984b8bab1b0a8593e0df6901f99 --- /dev/null +++ b/backend/app/math_wiki/storage/pg_db.py @@ -0,0 +1,469 @@ +import asyncio +import json +import logging +from datetime import datetime +from app.math_wiki.schemas import WikiUnit, Problem + +logger = logging.getLogger(__name__) + + +def _row_to_wiki_unit(row) -> WikiUnit: + return WikiUnit( + id=row["id"], + type=row["type"], + topic=row["topic"], + subtopic=row["subtopic"], + content=row["content"], + problem_ids=json.loads(row["problem_ids"]), + bloom_level=row["bloom_level"] if "bloom_level" in row.keys() else 0, + ) + + +# ── Core CRUD ───────────────────────────────────────────────────────────────── + +async def _compute_embedding(content: str) -> list[float]: + from app.math_wiki.storage.vectors import embed_texts + loop = asyncio.get_event_loop() + vecs = await loop.run_in_executor(None, embed_texts, [content], "passage") + return vecs[0] + + +async def upsert_wiki_unit( + pool, + unit: WikiUnit, + source: str = "manual", + source_url: str | None = None, + editor: str | None = None, + reason: str | None = None, + embedding: list[float] | None = None, +) -> None: + now = datetime.now() + + # Read current state without holding a connection open. + row = await pool.fetchrow( + "SELECT version, content, created_at FROM wiki_units WHERE id = $1", unit.id + ) + + # Compute embedding outside any connection so the connection is not held + # open during ML inference (which can take seconds and allows concurrent + # writers to corrupt the SQLite WAL). + if row is None: + if embedding is None: + embedding = await _compute_embedding(unit.content) + elif embedding is None and row["content"] != unit.content: + embedding = await _compute_embedding(unit.content) + + async with pool.acquire() as conn: + if row: + await conn.execute( + """INSERT INTO wiki_unit_history (unit_id, version, content, edited_by, reason) + VALUES ($1, $2, $3, $4, $5)""", + unit.id, row["version"], row["content"], editor, reason, + ) + if embedding is not None: + await conn.execute( + """UPDATE wiki_units + SET type=$1, topic=$2, subtopic=$3, content=$4, problem_ids=$5, + source=$6, source_url=$7, version=$8, last_edited_by=$9, + updated_at=$10, embedding=$11, bloom_level=$12 + WHERE id=$13""", + unit.type, unit.topic, unit.subtopic, unit.content, + json.dumps(unit.problem_ids), source, source_url, + row["version"] + 1, editor, now, embedding, + unit.bloom_level, unit.id, + ) + else: + await conn.execute( + """UPDATE wiki_units + SET type=$1, topic=$2, subtopic=$3, content=$4, problem_ids=$5, + source=$6, source_url=$7, version=$8, last_edited_by=$9, + updated_at=$10, bloom_level=$11 + WHERE id=$12""", + unit.type, unit.topic, unit.subtopic, unit.content, + json.dumps(unit.problem_ids), source, source_url, + row["version"] + 1, editor, now, + unit.bloom_level, unit.id, + ) + else: + await conn.execute( + """INSERT INTO wiki_units + (id, type, topic, subtopic, content, problem_ids, source, source_url, + deleted, version, last_edited_by, created_at, updated_at, embedding, bloom_level) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,false,1,$9,$10,$11,$12,$13)""", + unit.id, unit.type, unit.topic, unit.subtopic, + unit.content, json.dumps(unit.problem_ids), source, source_url, + editor, now, now, embedding, unit.bloom_level, + ) + + +async def get_all_wiki_units(pool) -> list[WikiUnit]: + async with pool.acquire() as conn: + rows = await conn.fetch("SELECT * FROM wiki_units WHERE deleted = false") + return [_row_to_wiki_unit(r) for r in rows] + + +async def get_wiki_units_by_ids(pool, ids: list[str]) -> list[WikiUnit]: + if not ids: + return [] + async with pool.acquire() as conn: + rows = await conn.fetch( + "SELECT * FROM wiki_units WHERE id = ANY($1) AND deleted = false", ids + ) + by_id = {r["id"]: _row_to_wiki_unit(r) for r in rows} + return [by_id[i] for i in ids if i in by_id] + + +async def count_wiki_units(pool) -> int: + async with pool.acquire() as conn: + return await conn.fetchval("SELECT COUNT(*) FROM wiki_units WHERE deleted = false") + + +async def count_wiki_units_by_topic(pool) -> dict[str, int]: + async with pool.acquire() as conn: + rows = await conn.fetch( + "SELECT topic, COUNT(*) AS cnt FROM wiki_units WHERE deleted = false GROUP BY topic" + ) + return {r["topic"]: r["cnt"] for r in rows} + + +async def count_problems(pool) -> int: + async with pool.acquire() as conn: + return await conn.fetchval("SELECT COUNT(*) FROM problems") + + +async def upsert_problem( + pool, + problem: Problem, + figure_svg: str | None = None, + problem_hash: str | None = None, + figure_type: str = "svg", +) -> None: + async with pool.acquire() as conn: + await conn.execute( + """INSERT INTO problems + (problem_id, problem_text, choices, correct_answer, topic, subtopic, + difficulty, problem_type, figure_svg, problem_hash, figure_type) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) + ON CONFLICT (problem_id) DO UPDATE SET + problem_text=EXCLUDED.problem_text, + choices=EXCLUDED.choices, + correct_answer=EXCLUDED.correct_answer, + figure_svg=EXCLUDED.figure_svg, + problem_hash=EXCLUDED.problem_hash, + figure_type=EXCLUDED.figure_type""", + problem.problem_id, + problem.problem_text, + json.dumps(problem.choices) if problem.choices is not None else None, + problem.correct_answer, + problem.topic, + problem.subtopic, + problem.difficulty, + problem.problem_type, + figure_svg, + problem_hash, + figure_type, + ) + + +async def get_cached_figure(pool, problem_hash: str) -> tuple[str, str] | None: + async with pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT figure_svg, figure_type FROM problems " + "WHERE problem_hash = $1 AND figure_svg IS NOT NULL LIMIT 1", + problem_hash, + ) + if row is None: + return None + return row["figure_svg"], row["figure_type"] or "svg" + + +async def get_all_content_hashes(pool) -> set[str]: + import hashlib + async with pool.acquire() as conn: + rows = await conn.fetch("SELECT content FROM wiki_units WHERE deleted = false") + return {hashlib.md5(r["content"].encode()).hexdigest() for r in rows} + + +# ── Admin read/write ────────────────────────────────────────────────────────── + +async def list_wiki_units_admin( + pool, + topic: str | None = None, + source: str | None = None, + include_deleted: bool = False, + limit: int = 50, + offset: int = 0, +) -> list[dict]: + conditions = [] + params: list = [] + idx = 1 + if not include_deleted: + conditions.append(f"deleted = false") + if topic: + conditions.append(f"topic = ${idx}") + params.append(topic) + idx += 1 + if source: + conditions.append(f"source = ${idx}") + params.append(source) + idx += 1 + where = ("WHERE " + " AND ".join(conditions)) if conditions else "" + params += [limit, offset] + query = f"SELECT * FROM wiki_units {where} ORDER BY updated_at DESC LIMIT ${idx} OFFSET ${idx+1}" + async with pool.acquire() as conn: + rows = await conn.fetch(query, *params) + return [dict(r) for r in rows] + + +async def get_wiki_unit_with_history(pool, unit_id: str) -> dict | None: + async with pool.acquire() as conn: + row = await conn.fetchrow("SELECT * FROM wiki_units WHERE id = $1", unit_id) + if not row: + return None + history = await conn.fetch( + "SELECT * FROM wiki_unit_history WHERE unit_id = $1 ORDER BY version DESC", + unit_id, + ) + return {"unit": dict(row), "history": [dict(h) for h in history]} + + +async def soft_delete_wiki_unit(pool, unit_id: str, editor: str = "admin") -> bool: + now = datetime.now() + async with pool.acquire() as conn: + row = await conn.fetchrow( + "SELECT version, content FROM wiki_units WHERE id = $1 AND deleted = false", unit_id + ) + if not row: + return False + await conn.execute( + """INSERT INTO wiki_unit_history (unit_id, version, content, edited_by, reason) + VALUES ($1,$2,$3,$4,$5)""", + unit_id, row["version"], row["content"], editor, "soft_delete", + ) + await conn.execute( + "UPDATE wiki_units SET deleted = true, last_edited_by = $1, updated_at = $2 WHERE id = $3", + editor, now, unit_id, + ) + return True + + +async def restore_wiki_unit(pool, unit_id: str, version: int | None = None, editor: str = "admin") -> bool: + now = datetime.now() + async with pool.acquire() as conn: + row = await conn.fetchrow("SELECT id FROM wiki_units WHERE id = $1", unit_id) + if not row: + return False + if version is not None: + hist = await conn.fetchrow( + "SELECT content FROM wiki_unit_history WHERE unit_id = $1 AND version = $2", + unit_id, version, + ) + if not hist: + return False + await conn.execute( + "UPDATE wiki_units SET content=$1, deleted=false, last_edited_by=$2, updated_at=$3 WHERE id=$4", + hist["content"], editor, now, unit_id, + ) + else: + await conn.execute( + "UPDATE wiki_units SET deleted=false, last_edited_by=$1, updated_at=$2 WHERE id=$3", + editor, now, unit_id, + ) + return True + + +async def list_feedback(pool, unresolved_only: bool = True) -> list[dict]: + async with pool.acquire() as conn: + if unresolved_only: + rows = await conn.fetch( + "SELECT * FROM unit_feedback WHERE resolved = false ORDER BY created_at DESC" + ) + else: + rows = await conn.fetch("SELECT * FROM unit_feedback ORDER BY created_at DESC") + return [dict(r) for r in rows] + + +async def resolve_feedback(pool, feedback_id: int) -> bool: + async with pool.acquire() as conn: + result = await conn.execute( + "UPDATE unit_feedback SET resolved = true WHERE id = $1", feedback_id + ) + return result.split()[-1] != "0" + + +async def get_flagged_solutions(pool, unreviewed_only: bool = True) -> list[dict]: + async with pool.acquire() as conn: + if unreviewed_only: + rows = await conn.fetch( + "SELECT * FROM flagged_solutions WHERE reviewed = false ORDER BY flagged_at DESC" + ) + else: + rows = await conn.fetch("SELECT * FROM flagged_solutions ORDER BY flagged_at DESC") + return [dict(r) for r in rows] + + +# ── Staged units ────────────────────────────────────────────────────────────── + +async def get_staged_wiki_units(pool, status: str = "pending") -> list[dict]: + async with pool.acquire() as conn: + rows = await conn.fetch( + "SELECT * FROM staged_wiki_units WHERE status = $1 ORDER BY created_at DESC", status + ) + result = [] + for row in rows: + d = dict(row) + unit_data = json.loads(d.pop("unit_data")) + result.append({**d, **unit_data}) + return result + + +async def approve_staged_wiki_unit(pool, staged_id: str) -> WikiUnit | None: + now = datetime.now() + async with pool.acquire() as conn: + async with conn.transaction(): + row = await conn.fetchrow( + "SELECT * FROM staged_wiki_units WHERE staged_id = $1 AND status = 'pending'", + staged_id, + ) + if not row: + return None + unit_data = json.loads(row["unit_data"]) + unit = WikiUnit(**unit_data) + # upsert inside the same transaction + existing = await conn.fetchrow( + "SELECT version, content FROM wiki_units WHERE id = $1", unit.id + ) + if existing: + await conn.execute( + """INSERT INTO wiki_unit_history (unit_id, version, content, edited_by, reason) + VALUES ($1,$2,$3,$4,$5)""", + unit.id, existing["version"], existing["content"], None, "staged_approve", + ) + await conn.execute( + """UPDATE wiki_units + SET type=$1, topic=$2, subtopic=$3, content=$4, problem_ids=$5, + source=$6, source_url=$7, version=$8, updated_at=$9, bloom_level=$10 + WHERE id=$11""", + unit.type, unit.topic, unit.subtopic, unit.content, + json.dumps(unit.problem_ids), row["source"], row["source_url"], + existing["version"] + 1, now, unit.bloom_level, unit.id, + ) + else: + await conn.execute( + """INSERT INTO wiki_units + (id, type, topic, subtopic, content, problem_ids, source, source_url, + deleted, version, created_at, updated_at, bloom_level) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,false,1,$9,$10,$11)""", + unit.id, unit.type, unit.topic, unit.subtopic, + unit.content, json.dumps(unit.problem_ids), row["source"], row["source_url"], + now, now, unit.bloom_level, + ) + await conn.execute( + "UPDATE staged_wiki_units SET status='approved', updated_at=$1 WHERE staged_id=$2", + now, staged_id, + ) + return unit + + +async def delete_staged_wiki_unit(pool, staged_id: str) -> None: + async with pool.acquire() as conn: + await conn.execute("DELETE FROM staged_wiki_units WHERE staged_id = $1", staged_id) + + +# ── Drafts ──────────────────────────────────────────────────────────────────── + +async def create_draft( + pool, + source_text: str, + source_url: str | None = None, + topic_hint: str | None = None, + proposed_units: list | None = None, +) -> str: + import uuid + draft_id = str(uuid.uuid4()) + async with pool.acquire() as conn: + await conn.execute( + """INSERT INTO wiki_drafts + (draft_id, source_url, source_text, proposed_units_json, topic_hint, status) + VALUES ($1,$2,$3,$4,$5,$6)""", + draft_id, source_url, source_text, + json.dumps([u.model_dump() for u in proposed_units]) if proposed_units else "[]", + topic_hint, "pending", + ) + logger.info("Draft created: %s (%d units)", draft_id, len(proposed_units or [])) + return draft_id + + +async def get_draft(pool, draft_id: str) -> dict | None: + async with pool.acquire() as conn: + row = await conn.fetchrow("SELECT * FROM wiki_drafts WHERE draft_id = $1", draft_id) + return dict(row) if row else None + + +async def list_drafts(pool, status: str = "pending") -> list[dict]: + async with pool.acquire() as conn: + rows = await conn.fetch( + "SELECT * FROM wiki_drafts WHERE status = $1 ORDER BY created_at DESC", status + ) + return [dict(r) for r in rows] + + +async def review_draft( + pool, + draft_id: str, + decision: str, + reviewer: str = "admin", + edits: list[dict] | None = None, +) -> dict: + from app.math_wiki.schemas import WikiUnit as WU + + async with pool.acquire() as conn: + draft_row = await conn.fetchrow("SELECT * FROM wiki_drafts WHERE draft_id = $1", draft_id) + if not draft_row: + raise ValueError(f"Draft {draft_id} not found") + + proposed = json.loads(draft_row["proposed_units_json"]) + final_units = proposed + if edits: + final_units = _apply_edits(proposed, edits) + + now = datetime.now() + if decision == "approve": + for unit_data in final_units: + unit = WU(**unit_data) + await upsert_wiki_unit(pool, unit, source="manual", editor=reviewer, reason="Approved from draft") + status = "approved" + elif decision == "reject": + status = "rejected" + elif decision == "edit": + await conn.execute( + "UPDATE wiki_drafts SET final_units_json=$1, status='edited' WHERE draft_id=$2", + json.dumps(final_units), draft_id, + ) + return {"status": "edited", "draft_id": draft_id} + else: + raise ValueError(f"Invalid decision: {decision}") + + await conn.execute( + "UPDATE wiki_drafts SET status=$1, reviewed_by=$2, reviewed_at=$3, final_units_json=$4 WHERE draft_id=$5", + status, reviewer, now, json.dumps(final_units), draft_id, + ) + + logger.info("Draft %s %s by %s", draft_id, status, reviewer) + return {"status": status, "draft_id": draft_id, "units_created": len(final_units) if decision == "approve" else 0} + + +def _apply_edits(proposed: list[dict], edits: list[dict]) -> list[dict]: + units = [u.copy() for u in proposed] + for edit in edits: + field = edit.get("field") + new_val = edit.get("new") + target_id = edit.get("unit_id") + if not field: + continue + for u in units: + if target_id and u.get("id") != target_id: + continue + if field in u: + u[field] = new_val + return units diff --git a/backend/app/math_wiki/storage/pg_vectors.py b/backend/app/math_wiki/storage/pg_vectors.py new file mode 100644 index 0000000000000000000000000000000000000000..d2a8977fcaceab63108f9a788bcc87dedc6f106c --- /dev/null +++ b/backend/app/math_wiki/storage/pg_vectors.py @@ -0,0 +1,71 @@ +"""Vector similarity search backed by SQLite + numpy (replaces pgvector/HNSW). + +Embeddings are stored as JSON arrays in the `embedding` TEXT column. +Similarity is computed in-process with numpy cosine similarity — fast enough +for the expected dataset size (hundreds to a few thousand wiki units). +""" +import asyncio +import json +import logging + +import numpy as np + +logger = logging.getLogger(__name__) + + +async def query_pgvector(pool, query_text: str, top_k: int = 10, min_score: float = 0.55) -> list[str]: + from app.math_wiki.storage.vectors import embed_texts + + loop = asyncio.get_event_loop() + vecs = await loop.run_in_executor(None, embed_texts, [query_text], "query") + if not vecs: # FlagEmbedding unavailable — fall back to empty (BM25 path handles retrieval) + return [] + query_vec = np.array(vecs[0], dtype=np.float32) + + async with pool.acquire() as conn: + rows = await conn.fetch( + "SELECT id, embedding FROM wiki_units WHERE deleted = 0 AND embedding IS NOT NULL" + ) + + if not rows: + return [] + + ids = [r["id"] for r in rows] + embeddings = np.array( + [json.loads(r["embedding"]) for r in rows], dtype=np.float32 + ) + + q_norm = query_vec / (np.linalg.norm(query_vec) + 1e-10) + e_norms = np.linalg.norm(embeddings, axis=1, keepdims=True) + scores = (embeddings / (e_norms + 1e-10)) @ q_norm + + mask = scores >= min_score + filtered = [(ids[i], float(scores[i])) for i in range(len(ids)) if mask[i]] + filtered.sort(key=lambda x: x[1], reverse=True) + return [fid for fid, _ in filtered[:top_k]] + + +async def is_near_duplicate_pg(pool, text: str, threshold: float = 0.92) -> bool: + from app.math_wiki.storage.vectors import embed_texts + + loop = asyncio.get_event_loop() + vecs = await loop.run_in_executor(None, embed_texts, [text], "passage") + if not vecs: + return False # can't check without embeddings — allow the insert + query_vec = np.array(vecs[0], dtype=np.float32) + + async with pool.acquire() as conn: + rows = await conn.fetch( + "SELECT embedding FROM wiki_units WHERE deleted = 0 AND embedding IS NOT NULL" + ) + + if not rows: + return False + + embeddings = np.array( + [json.loads(r["embedding"]) for r in rows], dtype=np.float32 + ) + q_norm = query_vec / (np.linalg.norm(query_vec) + 1e-10) + e_norms = np.linalg.norm(embeddings, axis=1, keepdims=True) + scores = (embeddings / (e_norms + 1e-10)) @ q_norm + return float(np.max(scores)) >= threshold diff --git a/backend/app/math_wiki/storage/retriever.py b/backend/app/math_wiki/storage/retriever.py new file mode 100644 index 0000000000000000000000000000000000000000..4c1e3baec6f575a3428d235f781e2c123def7e4a --- /dev/null +++ b/backend/app/math_wiki/storage/retriever.py @@ -0,0 +1,114 @@ +"""Retrieval layer: vector search + concept-graph prerequisite expansion.""" +from __future__ import annotations +import asyncio +import logging +from app.math_wiki.storage.pg_vectors import query_pgvector +from app.math_wiki.vector_store import vector_search as _vector_search, _vec_available + +logger = logging.getLogger(__name__) + + +async def vector_retrieve(pool, query: str, top_k: int = 15) -> list[str]: + if pool is None: + return [] + # Use sqlite-vec HNSW path (or its NumPy fallback) when available; + # this requires a pre-computed query embedding. + try: + from app.math_wiki.storage.vectors import embed_texts + loop = asyncio.get_event_loop() + vecs = await loop.run_in_executor(None, embed_texts, [query], "query") + if vecs: + return await _vector_search(pool, vecs[0], top_k=top_k) + except Exception as exc: + logger.debug("vector_store search failed (%s), falling back to query_pgvector", exc) + # Fallback: original pgvector / NumPy path + return await query_pgvector(pool, query, top_k=top_k) + + +async def retrieve_with_prerequisites( + pool, + query: str, + topic: str | None = None, + top_k: int = 15, + bloom_min: int = 0, +) -> list[str]: + """Retrieve wiki unit IDs for a query, then expand with prerequisite concept units. + + Args: + pool: DB connection pool. + query: The search query string. + topic: Detected topic label (used for graph-based prerequisite expansion). + top_k: Number of units to retrieve by vector similarity. + bloom_min: Minimum Bloom's level to include (0 = all). + + Returns: + Ordered list of wiki unit IDs (primary results first, prerequisite expansions appended). + """ + if pool is None: + return [] + + primary_ids = await vector_retrieve(pool, query, top_k=top_k) + + # Expand with prerequisite concept units when topic is provided + prereq_ids: list[str] = [] + if topic: + try: + from app.math_wiki.graph import topic_to_concepts, get_prerequisites + concept_ids = topic_to_concepts(topic) + all_prereq_concepts: set[str] = set() + for cid in concept_ids: + for pre in get_prerequisites(cid, depth=1): + all_prereq_concepts.add(pre) + if all_prereq_concepts: + prereq_ids = await _fetch_units_for_concepts(pool, list(all_prereq_concepts), top_k=5) + except Exception as exc: + logger.debug("Prerequisite expansion failed (non-fatal): %s", exc) + + # Merge: primary first, then unique prerequisite additions + seen = set(primary_ids) + result = list(primary_ids) + for uid in prereq_ids: + if uid not in seen: + seen.add(uid) + result.append(uid) + + # Optional Bloom's level filter + if bloom_min > 0: + result = await _filter_by_bloom(pool, result, bloom_min) + + return result + + +async def _fetch_units_for_concepts(pool, concept_ids: list[str], top_k: int = 5) -> list[str]: + """Fetch wiki unit IDs whose subtopic matches any of the given concept ids.""" + if not concept_ids or pool is None: + return [] + try: + placeholders = ",".join(f"${i+1}" for i in range(len(concept_ids))) + async with pool.acquire() as conn: + rows = await conn.fetch( + f"SELECT id FROM wiki_units WHERE deleted = 0 AND subtopic IN ({placeholders}) LIMIT {top_k}", + *concept_ids, + ) + return [row["id"] for row in rows] + except Exception as exc: + logger.debug("_fetch_units_for_concepts failed: %s", exc) + return [] + + +async def _filter_by_bloom(pool, unit_ids: list[str], bloom_min: int) -> list[str]: + """Return only those unit_ids whose bloom_level >= bloom_min.""" + if not unit_ids or pool is None: + return unit_ids + try: + placeholders = ",".join(f"${i+1}" for i in range(len(unit_ids))) + async with pool.acquire() as conn: + rows = await conn.fetch( + f"SELECT id FROM wiki_units WHERE id IN ({placeholders}) AND bloom_level >= ${len(unit_ids)+1}", + *unit_ids, bloom_min, + ) + kept = {row["id"] for row in rows} + return [uid for uid in unit_ids if uid in kept] + except Exception as exc: + logger.debug("_filter_by_bloom failed: %s", exc) + return unit_ids diff --git a/backend/app/math_wiki/storage/sanitizer.py b/backend/app/math_wiki/storage/sanitizer.py new file mode 100644 index 0000000000000000000000000000000000000000..810f3810010198098481674da8e89743971f0104 --- /dev/null +++ b/backend/app/math_wiki/storage/sanitizer.py @@ -0,0 +1,124 @@ +"""Async PostgreSQL sanitizer: fix non-canonical labels and remove duplicate wiki units.""" +import hashlib +import logging +from datetime import datetime + +from app.math_wiki.taxonomy import CANONICAL_TOPICS, CANONICAL_TYPES, TOPIC_MAP, TYPE_MAP + +logger = logging.getLogger(__name__) + + +async def fix_topic_slugs(pool, dry_run: bool = False) -> dict: + """Remap non-canonical topic slugs to canonical ones; soft-delete unmappable units.""" + async with pool.acquire() as conn: + rows = await conn.fetch("SELECT id, topic FROM wiki_units WHERE deleted = false") + + updates: list[tuple[str, str]] = [] # (new_topic, id) + deletes: list[str] = [] + + for row in rows: + topic = row["topic"] + if topic in CANONICAL_TOPICS: + continue + canonical = TOPIC_MAP.get(topic) + if canonical: + updates.append((canonical, row["id"])) + else: + deletes.append(row["id"]) + + logger.info( + "fix_topic_slugs: %d remaps, %d soft-deletes%s", + len(updates), len(deletes), " (dry_run)" if dry_run else "", + ) + + if not dry_run: + now = datetime.now() + async with pool.acquire() as conn: + for new_topic, uid in updates: + await conn.execute( + "UPDATE wiki_units SET topic=$1, updated_at=$2 WHERE id=$3", + new_topic, now, uid, + ) + for uid in deletes: + await conn.execute( + "UPDATE wiki_units SET deleted=true, updated_at=$1 WHERE id=$2", + now, uid, + ) + + return {"topic_remaps": len(updates), "topic_deletes": len(deletes), "dry_run": dry_run} + + +async def fix_unit_types(pool, dry_run: bool = False) -> dict: + """Remap non-canonical type values; unknown types collapse to 'concept'.""" + async with pool.acquire() as conn: + rows = await conn.fetch("SELECT id, type FROM wiki_units WHERE deleted = false") + + updates: list[tuple[str, str]] = [] # (new_type, id) + + for row in rows: + t = row["type"] + if t in CANONICAL_TYPES: + continue + canonical = TYPE_MAP.get(t, "concept") + updates.append((canonical, row["id"])) + + logger.info( + "fix_unit_types: %d remaps%s", + len(updates), " (dry_run)" if dry_run else "", + ) + + if not dry_run: + now = datetime.now() + async with pool.acquire() as conn: + for new_type, uid in updates: + await conn.execute( + "UPDATE wiki_units SET type=$1, updated_at=$2 WHERE id=$3", + new_type, now, uid, + ) + + return {"type_remaps": len(updates), "dry_run": dry_run} + + +async def dedup_wiki_units(pool, dry_run: bool = False) -> dict: + """Soft-delete duplicate wiki units that share the same content hash. + + For each group of duplicates, the oldest unit (earliest created_at) is kept; + all newer duplicates are soft-deleted. + """ + async with pool.acquire() as conn: + rows = await conn.fetch( + "SELECT id, content, created_at FROM wiki_units WHERE deleted = false ORDER BY created_at ASC" + ) + + seen: dict[str, str] = {} # content_hash → first id (to keep) + deletes: list[str] = [] + + for row in rows: + h = hashlib.md5(row["content"].encode()).hexdigest() + if h in seen: + deletes.append(row["id"]) + else: + seen[h] = row["id"] + + logger.info( + "dedup_wiki_units: %d duplicates found%s", + len(deletes), " (dry_run)" if dry_run else "", + ) + + if not dry_run and deletes: + now = datetime.now() + async with pool.acquire() as conn: + await conn.execute( + "UPDATE wiki_units SET deleted=true, updated_at=$1 WHERE id = ANY($2)", + now, deletes, + ) + + return {"duplicates_removed": len(deletes), "dry_run": dry_run} + + +async def run_all(pool, dry_run: bool = False) -> dict: + """Run all sanitization steps and return a combined report.""" + topic_result = await fix_topic_slugs(pool, dry_run=dry_run) + type_result = await fix_unit_types(pool, dry_run=dry_run) + dedup_result = await dedup_wiki_units(pool, dry_run=dry_run) + return {**topic_result, **type_result, **dedup_result} diff --git a/backend/app/math_wiki/storage/vectors.py b/backend/app/math_wiki/storage/vectors.py new file mode 100644 index 0000000000000000000000000000000000000000..88b707270f740fd599994eaf49e4a4bfa315ed9c --- /dev/null +++ b/backend/app/math_wiki/storage/vectors.py @@ -0,0 +1,40 @@ +from __future__ import annotations +import logging + +from app.config import get_settings + +logger = logging.getLogger(__name__) + +_local_model = None + + +_model_unavailable = False # set True once after a failed import + + +def _get_local_model(): + global _local_model, _model_unavailable + if _model_unavailable: + return None + if _local_model is None: + try: + from FlagEmbedding import BGEM3FlagModel + _local_model = BGEM3FlagModel( + get_settings().embedding_model_name, + use_fp16=False, + ) + except ImportError: + logger.warning("FlagEmbedding not installed — vector search disabled, BM25-only mode") + _model_unavailable = True + return None + return _local_model + + +def embed_texts(texts: list[str], prefix: str = "passage") -> list[list[float]]: + if not texts: + return [] + model = _get_local_model() + if model is None: + return [] # graceful fallback: no embeddings + prefixed = [f"{prefix}: {t}" for t in texts] + out = model.encode(prefixed, return_dense=True, return_sparse=False, return_colbert_vecs=False) + return out["dense_vecs"].tolist() diff --git a/backend/app/math_wiki/taxonomy.py b/backend/app/math_wiki/taxonomy.py new file mode 100644 index 0000000000000000000000000000000000000000..a8ba4f11b4cd56577a9067c0d7771b53812ea87f --- /dev/null +++ b/backend/app/math_wiki/taxonomy.py @@ -0,0 +1,178 @@ +"""Canonical topic and type definitions for the math wiki.""" + +CANONICAL_TOPICS: frozenset[str] = frozenset({ + "algebra", + "geometry", + "calculus", + "trigonometry", + "combinatorics", + "number_theory", + "statistics", + "probability", + "differential_equations", + "linear_algebra", + "multivariable_calculus", + # gap-fill additions + "radical_expressions", + "functions_and_graphs", + "inequalities_optimization", + "absolute_value", + "nonlinear_systems", + "polynomial_techniques", +}) + +# Maps every known non-canonical slug → canonical topic. +# Unmapped slugs that aren't already canonical get soft-deleted by fix_topic_slugs.py. +TOPIC_MAP: dict[str, str] = { + # algebra family + "precalculus": "algebra", + "sequences": "algebra", + "sequences-and-series": "algebra", + "series": "algebra", + "complex-numbers": "algebra", + "complex_numbers": "algebra", + "complex_analysis": "algebra", + "analysis": "algebra", + "set theory": "algebra", + "optimization": "algebra", + "functions": "algebra", + "vectors": "algebra", + # calculus family + "calculus-2": "calculus", + "calculus-3": "calculus", + "calculus-iii": "calculus", + "integration": "calculus", + "fourier analysis": "calculus", + "fourier-analysis": "calculus", + "fourier_analysis": "calculus", + "fourier_series": "calculus", + "Fourier Analysis": "calculus", + "parametric equations": "calculus", + "parametric-equations": "calculus", + "numerical-methods": "calculus", + # differential equations + "differential equations": "differential_equations", + "differential-equations": "differential_equations", + "ordinary-differential-equations": "differential_equations", + "ordinary_differential_equations": "differential_equations", + "partial differential equations": "differential_equations", + "partial-differential-equations": "differential_equations", + "partial_differential_equations": "differential_equations", + "laplace-transforms": "differential_equations", + "laplace_transforms": "differential_equations", + # number theory + "number theory": "number_theory", + "number-theory": "number_theory", + # linear algebra + "linear algebra": "linear_algebra", + "linear-algebra": "linear_algebra", + # multivariable calculus + "multivariable-calculus": "multivariable_calculus", + "3-dimensional space": "multivariable_calculus", + "coordinate-systems": "multivariable_calculus", + "vector-calculus": "multivariable_calculus", + "vector_calculus": "multivariable_calculus", + "vector-functions": "multivariable_calculus", + # statistics family + "nonparametric statistics": "statistics", + "nonparametric_statistics": "statistics", + "hypothesis_testing": "statistics", + "hypothesis testing": "statistics", + "descriptive statistics": "statistics", + "descriptive_statistics": "statistics", + "inferential statistics": "statistics", + "inferential_statistics": "statistics", + "bayesian statistics": "statistics", + "bayesian_statistics": "statistics", + # gap-fill topic aliases + "radicals": "radical_expressions", + "radical": "radical_expressions", + "inequalities": "inequalities_optimization", + "optimization": "inequalities_optimization", + "functions": "functions_and_graphs", + "graphing": "functions_and_graphs", + "absolute-value": "absolute_value", + "nonlinear": "nonlinear_systems", + "polynomials": "polynomial_techniques", + # probability family + "stochastic processes": "probability", + "stochastic_processes": "probability", + "random variables": "probability", + "random_variables": "probability", + # geometry family + "analytic geometry": "geometry", + "analytic-geometry": "geometry", + "analytic_geometry": "geometry", + "projective geometry": "geometry", + "projective-geometry": "geometry", + "projective_geometry": "geometry", + "euclidean geometry": "geometry", + "euclidean-geometry": "geometry", + "euclidean_geometry": "geometry", + "triangle-geometry": "geometry", + "triangle_geometry": "geometry", + "solid geometry": "geometry", + "solid-geometry": "geometry", + "solid_geometry": "geometry", +} + +BLOOM_LEVELS: dict[int, str] = { + 0: "untagged", + 1: "remember", # recall facts, formulas, definitions + 2: "understand", # explain, interpret, restate + 3: "apply", # execute known procedure on new instance + 4: "analyze", # decompose, derive, find structure + 5: "evaluate", # assess correctness, judge against criteria + 6: "create", # synthesize new objects from constraints +} + +# Maps Vietnamese question verb → Bloom's level (used in ingest + classifier) +BLOOM_VERBS: dict[str, int] = { + # L1 Remember + "nhớ": 1, "kể": 1, "liệt kê": 1, "nêu": 1, "định nghĩa": 1, + "công thức": 1, "phát biểu": 1, + # L2 Understand + "giải thích": 2, "tại sao": 2, "ý nghĩa": 2, "mô tả": 2, + "phân biệt": 2, "so sánh": 2, + # L3 Apply + "tính": 3, "giải": 3, "tìm": 3, "xác định": 3, "áp dụng": 3, + "sử dụng": 3, "vẽ": 3, + # L4 Analyze + "phân tích": 4, "tìm tất cả": 4, "cực trị": 4, "biến thiên": 4, + "phân tích nhân tử": 4, "bảng biến thiên": 4, + # L5 Evaluate + "kiểm tra": 5, "đánh giá": 5, "chứng tỏ": 5, "khẳng định": 5, + "xét xem": 5, "đúng hay sai": 5, + # L6 Create + "xây dựng": 6, "dựng": 6, "thiết kế": 6, "tạo": 6, + "viết phương trình": 6, "lập": 6, +} + +CANONICAL_TYPES: frozenset[str] = frozenset({ + "procedure", + "concept", + "theorem", + "definition", + "fact", +}) + +TYPE_MAP: dict[str, str] = { + "application": "concept", + "overview": "concept", + "strategy": "concept", + "principle": "concept", + "property": "concept", + "constraint": "concept", + "convention": "concept", + "warning": "concept", + "reference": "concept", + "problem": "concept", + "worked_example": "procedure", + "example": "procedure", + "identity": "fact", + "formula": "fact", + "rule": "fact", + "proof": "theorem", + "derivation": "theorem", + "result": "theorem", +} diff --git a/backend/app/math_wiki/tutor_agent.py b/backend/app/math_wiki/tutor_agent.py new file mode 100644 index 0000000000000000000000000000000000000000..a0067271fc8d3a7839b8568e6b8e235a88fb5306 --- /dev/null +++ b/backend/app/math_wiki/tutor_agent.py @@ -0,0 +1,66 @@ +"""V-Math style personalized tutor recommendations. + +After solving a problem, reads the student's concept mastery and recommends +2-3 follow-up problems targeting their weakest concepts. +""" +import logging +from openai import AsyncOpenAI + +logger = logging.getLogger(__name__) + + +async def get_tutor_recommendations( + pool, + client: AsyncOpenAI, + user_id: int, + solved_topic: str, + was_correct: bool, +) -> list[dict]: + """Return 2-3 recommended follow-up problems based on concept mastery.""" + if pool is None: + return [] + try: + # Get weak concepts from concept_mastery table (or concept_elo as fallback) + mastery_rows = await pool.fetch( + """SELECT concept_id, mastery_score FROM concept_mastery + WHERE user_id = ? ORDER BY mastery_score ASC LIMIT 5""", + user_id, + ) + if not mastery_rows: + mastery_rows = await pool.fetch( + """SELECT concept_id, elo_score / 1000.0 as mastery_score FROM concept_elo + WHERE user_id = ? ORDER BY elo_score ASC LIMIT 5""", + user_id, + ) + if not mastery_rows: + return [] + + weak_concepts = [r["concept_id"] for r in mastery_rows[:3]] + + # Find problems matching weak concepts + recs = [] + for concept_id in weak_concepts: + rows = await pool.fetch( + """SELECT problem_id, problem_text, topic, difficulty FROM problems + WHERE topic = ? AND problem_id NOT IN ( + SELECT problem_id FROM solution_logs WHERE user_id = ? LIMIT 100 + ) + ORDER BY RANDOM() LIMIT 1""", + concept_id, user_id, + ) + for row in rows: + mastery = next((r["mastery_score"] for r in mastery_rows if r["concept_id"] == concept_id), 0.5) + recs.append({ + "problem_id": row["problem_id"], + "topic": row["topic"], + "difficulty": row["difficulty"], + "reason": f"Khái niệm {concept_id} — độ thành thạo {mastery:.0%}", + "mastery": round(mastery, 2), + }) + if len(recs) >= 3: + break + + return recs[:3] + except Exception as exc: + logger.debug("get_tutor_recommendations failed: %s", exc) + return [] diff --git a/backend/app/math_wiki/utils.py b/backend/app/math_wiki/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..2ea8d59ea138dfffe61281eea7e8040afd30ec1b --- /dev/null +++ b/backend/app/math_wiki/utils.py @@ -0,0 +1,76 @@ +import re + + +class InsufficientKnowledgeError(ValueError): + pass + + +VALID_LABELS: frozenset[str] = frozenset({ + "algebra", + "geometry", + "statistics", + "probability", + "calculus", + "trigonometry", + "combinatorics", + "number_theory", + "complex_numbers", + "sequences", + "vectors", + "functions", + "differential_equations", + "linear_algebra", + "multivariable_calculus", +}) + +VALID_CONFIDENCE: frozenset[str] = frozenset({"high", "medium", "low"}) + + +def _strip_code_fence(text: str) -> str: + if text.startswith("```"): + parts = text.split("```") + text = parts[1] if len(parts) > 1 else text + if text.startswith("json"): + text = text[4:] + return text.strip() + + +def _fix_backslashes(text: str) -> str: + """Replace bare LaTeX backslashes (e.g. \frac) with escaped form so JSON parses.""" + # Only fix backslashes that are NOT already valid JSON escape sequences + return re.sub(r'\\(?!["\\/bfnrtu])', r'\\\\', text) + + +def _extract_json(text: str) -> str: + text = re.sub(r'^```(?:json)?\s*', '', text.strip()) + text = re.sub(r'\s*```$', '', text) + # Walk characters to find the last well-formed top-level {…} span. + # Greedy regex would match first-{ to last-}, capturing invalid multi-object spans. + last_start = last_end = -1 + depth = 0 + in_string = False + escape = False + start = -1 + for i, ch in enumerate(text): + if escape: + escape = False + continue + if ch == '\\' and in_string: + escape = True + continue + if ch == '"': + in_string = not in_string + continue + if in_string: + continue + if ch == '{': + if depth == 0: + start = i + depth += 1 + elif ch == '}': + depth -= 1 + if depth == 0 and start != -1: + last_start, last_end = start, i + 1 + if last_start != -1: + return _fix_backslashes(text[last_start:last_end]) + return _fix_backslashes(text.strip()) diff --git a/backend/app/math_wiki/vector_store.py b/backend/app/math_wiki/vector_store.py new file mode 100644 index 0000000000000000000000000000000000000000..d30f5298719b5226079b0bf639f9edd5f3192b69 --- /dev/null +++ b/backend/app/math_wiki/vector_store.py @@ -0,0 +1,90 @@ +"""sqlite-vec HNSW vector index wrapper. + +Falls back to NumPy cosine search if sqlite-vec is unavailable. +Controlled by settings.use_sqlite_vec (default True). +""" +import json +import logging +import numpy as np +from app.config import get_settings + +logger = logging.getLogger(__name__) +_vec_available = False + +try: + import sqlite_vec # noqa: F401 + _vec_available = True +except ImportError: + logger.warning("sqlite-vec not available — using NumPy fallback for vector search") + + +async def vector_search(pool, query_embedding: list[float], top_k: int = 20) -> list[str]: + """Return top_k wiki_unit IDs by cosine similarity to query_embedding.""" + settings = get_settings() + if _vec_available and settings.use_sqlite_vec: + return await _sqlite_vec_search(pool, query_embedding, top_k) + return await _numpy_search(pool, query_embedding, top_k) + + +async def _sqlite_vec_search(pool, query_embedding: list[float], top_k: int) -> list[str]: + try: + import struct + blob = struct.pack(f"{len(query_embedding)}f", *query_embedding) + rows = await pool.fetch( + "SELECT unit_id, distance FROM vec_wiki_units WHERE embedding MATCH ? ORDER BY distance LIMIT ?", + blob, top_k, + ) + return [r["unit_id"] for r in rows] + except Exception as exc: + logger.warning("sqlite-vec search failed (%s), falling back to NumPy", exc) + return await _numpy_search(pool, query_embedding, top_k) + + +async def _numpy_search(pool, query_embedding: list[float], top_k: int) -> list[str]: + rows = await pool.fetch( + "SELECT id, embedding FROM wiki_units WHERE deleted = 0 AND embedding IS NOT NULL" + ) + if not rows: + return [] + q = np.array(query_embedding, dtype=np.float32) + q_norm = q / (np.linalg.norm(q) + 1e-9) + scores = [] + for row in rows: + try: + emb = np.array(json.loads(row["embedding"]), dtype=np.float32) + emb_norm = emb / (np.linalg.norm(emb) + 1e-9) + scores.append((float(np.dot(q_norm, emb_norm)), row["id"])) + except Exception: + continue + scores.sort(reverse=True) + return [uid for _, uid in scores[:top_k]] + + +async def migrate_embeddings_to_vec(pool) -> None: + """One-time migration: copy wiki_units.embedding JSON → vec_wiki_units virtual table.""" + if not _vec_available: + return + try: + count_existing = await pool.fetchval("SELECT COUNT(*) FROM vec_wiki_units") or 0 + count_units = await pool.fetchval( + "SELECT COUNT(*) FROM wiki_units WHERE deleted = 0 AND embedding IS NOT NULL" + ) or 0 + if count_existing >= count_units: + return # already migrated + rows = await pool.fetch( + "SELECT id, embedding FROM wiki_units WHERE deleted = 0 AND embedding IS NOT NULL" + ) + import struct + for row in rows: + try: + emb = json.loads(row["embedding"]) + blob = struct.pack(f"{len(emb)}f", *emb) + await pool.execute( + "INSERT OR REPLACE INTO vec_wiki_units(unit_id, embedding) VALUES (?, ?)", + row["id"], blob, + ) + except Exception as exc: + logger.debug("Skipping embedding migration for %s: %s", row["id"], exc) + logger.info("Migrated %d embeddings to vec_wiki_units", len(rows)) + except Exception as exc: + logger.warning("Embedding migration failed (non-fatal): %s", exc) diff --git a/backend/app/metrics.py b/backend/app/metrics.py new file mode 100644 index 0000000000000000000000000000000000000000..147e02e6420d443f19e105e2eb5950b347922f29 --- /dev/null +++ b/backend/app/metrics.py @@ -0,0 +1,62 @@ +import logging +import time +from collections import deque +from threading import Lock +from typing import Optional + +logger = logging.getLogger(__name__) + +_counters = { + "wiki_units_added": 0, + "feedback_submitted": 0, + "bm25_rebuild_count": 0, + "bm25_rebuild_duration_s": 0.0, +} +_lock = Lock() + +# Rolling window for validation rate (last 100 solutions) +_validation_window: deque[bool] = deque(maxlen=100) +_validation_lock = Lock() + + +def inc_wiki_units_added(n: int = 1) -> None: + with _lock: + _counters["wiki_units_added"] += n + + +def inc_feedback() -> None: + with _lock: + _counters["feedback_submitted"] += 1 + + +def inc_bm25_rebuild(duration_s: float) -> None: + with _lock: + _counters["bm25_rebuild_count"] += 1 + _counters["bm25_rebuild_duration_s"] += duration_s + + +def record_validation(valid: bool) -> None: + with _validation_lock: + _validation_window.append(valid) + + +def get_metrics() -> dict: + with _lock: + data = _counters.copy() + with _validation_lock: + window_len = len(_validation_window) + if window_len > 0: + valid_count = sum(_validation_window) + data["validation_rate_last_100"] = round(valid_count / window_len, 4) + else: + data["validation_rate_last_100"] = None + + # Alert condition + if window_len >= 100 and data["validation_rate_last_100"] is not None and data["validation_rate_last_100"] < 0.80: + logger.warning( + "ALERT: Validation rate below 80%% over last %d solutions: %.2f%%", + window_len, + data["validation_rate_last_100"] * 100, + ) + + return data diff --git a/backend/app/middleware.py b/backend/app/middleware.py new file mode 100644 index 0000000000000000000000000000000000000000..d07e09e583b6a0466388ab25f626d5434c4979a1 --- /dev/null +++ b/backend/app/middleware.py @@ -0,0 +1,129 @@ +import time +from collections import defaultdict, deque +from fastapi import Request, Response +from starlette.middleware.base import BaseHTTPMiddleware + +_AI_PATHS = { + "/analyze", "/hint", "/study-plan", "/explain", + "/chat", "/compress", "/math-solve", "/math-review", "/math-ingest", "/math-upload", + "/study-plan-quiz", +} +_AUTH_PATHS = {"/auth/google"} +_WINDOW = 60 # seconds +_IP_LIMIT = 20 # requests per window per IP +_AUTH_LIMIT = 10 # tighter limit for auth endpoints +_USER_LIMIT = 60 # requests per minute per authenticated user +_HINT_RAPID_WINDOW = 10 # seconds +_HINT_RAPID_LIMIT = 5 # max hint requests per user in rapid window + +_ADMIN_PATHS_PREFIX = "/admin" +_ADMIN_IP_LIMIT = 10 # 10 requests/min per IP to any /admin/* path +_ADMIN_FAIL_LIMIT = 5 # block IP after 5 failed key attempts +_ADMIN_FAIL_WINDOW = 900 # 15-minute lockout window + + +def _extract_user_id(request: Request) -> str | None: + """Decode JWT from Authorization header to get user_id (no DB lookup).""" + auth = request.headers.get("authorization", "") + if not auth.lower().startswith("bearer "): + return None + token = auth[7:] + try: + from app.auth import decode_jwt + payload = decode_jwt(token) + return str(payload.get("sub", "")) + except Exception: + return None + + +class RateLimitMiddleware(BaseHTTPMiddleware): + def __init__(self, app): + super().__init__(app) + self._ip_buckets: dict[str, deque] = defaultdict(deque) + self._auth_buckets: dict[str, deque] = defaultdict(deque) + self._user_buckets: dict[str, deque] = defaultdict(deque) + self._hint_buckets: dict[str, deque] = defaultdict(deque) + self._admin_ip_buckets: dict[str, deque] = defaultdict(deque) + self._admin_fail_counts: dict[str, list] = defaultdict(list) + + async def dispatch(self, request: Request, call_next): + ip = request.client.host if request.client else "unknown" + now = time.monotonic() + + # Admin endpoints — tight IP rate limit + failed-attempt lockout + if request.url.path.startswith(_ADMIN_PATHS_PREFIX): + # IP rate limit + ab = self._admin_ip_buckets[ip] + while ab and ab[0] < now - _WINDOW: + ab.popleft() + if len(ab) >= _ADMIN_IP_LIMIT: + return Response('{"detail":"Too many requests"}', status_code=429, media_type="application/json") + ab.append(now) + # Failed-attempt lockout + self._admin_fail_counts[ip] = [t for t in self._admin_fail_counts[ip] if t > now - _ADMIN_FAIL_WINDOW] + if len(self._admin_fail_counts[ip]) >= _ADMIN_FAIL_LIMIT: + return Response('{"detail":"Too many requests"}', status_code=429, media_type="application/json") + # Call next and record failure if key was bad + response = await call_next(request) + if getattr(request.state, "admin_key_failed", False): + self._admin_fail_counts[ip].append(now) + return response + + # Auth endpoint — tight IP-based limit + if request.url.path in _AUTH_PATHS: + bucket = self._auth_buckets[ip] + while bucket and bucket[0] < now - _WINDOW: + bucket.popleft() + if len(bucket) >= _AUTH_LIMIT: + return Response( + content='{"detail":"Quá nhiều yêu cầu, vui lòng thử lại sau."}', + status_code=429, + media_type="application/json", + ) + bucket.append(now) + return await call_next(request) + + if request.url.path not in _AI_PATHS: + return await call_next(request) + + # IP-based limit (catches unauthenticated bursts) + ip_bucket = self._ip_buckets[ip] + while ip_bucket and ip_bucket[0] < now - _WINDOW: + ip_bucket.popleft() + if len(ip_bucket) >= _IP_LIMIT: + return Response( + content='{"detail":"Quá nhiều yêu cầu, vui lòng thử lại sau."}', + status_code=429, + media_type="application/json", + ) + ip_bucket.append(now) + + # Per-user limits (authenticated users) + user_id = _extract_user_id(request) + if user_id: + # 60 req/min per user across all AI paths + u_bucket = self._user_buckets[user_id] + while u_bucket and u_bucket[0] < now - _WINDOW: + u_bucket.popleft() + if len(u_bucket) >= _USER_LIMIT: + return Response( + content='{"detail":"Vui lòng chờ một chút trước khi tiếp tục."}', + status_code=429, + media_type="application/json", + ) + u_bucket.append(now) + + # Rapid-fire hint detection: >5 hint requests in 10s + if request.url.path == "/hint": + h_bucket = self._hint_buckets[user_id] + while h_bucket and h_bucket[0] < now - _HINT_RAPID_WINDOW: + h_bucket.popleft() + if len(h_bucket) >= _HINT_RAPID_LIMIT: + return Response( + content='{"detail":"Vui lòng chờ trước khi yêu cầu gợi ý tiếp theo."}', + status_code=429, + media_type="application/json", + ) + h_bucket.append(now) + + return await call_next(request) diff --git a/backend/migrations/add_node_type_edges.sql b/backend/migrations/add_node_type_edges.sql new file mode 100644 index 0000000000000000000000000000000000000000..a4a4699301c2e6f3b329ddfb723555af57078b05 --- /dev/null +++ b/backend/migrations/add_node_type_edges.sql @@ -0,0 +1,8 @@ +-- P1: Add node_type column to wiki_units and document new edge_type values +-- Run once at startup if node_type column does not exist + +ALTER TABLE wiki_units ADD COLUMN IF NOT EXISTS node_type TEXT; + +-- Valid node_type values: theorem, formula, example, pattern, procedure, concept, mistake +-- Valid edge_type values (concept_edges): requires, illustrates, applies, generalizes, similar_to, common_mistake +-- (existing: prerequisite edges remain valid) diff --git a/backend/scripts/ingest/http_client.py b/backend/scripts/ingest/http_client.py new file mode 100644 index 0000000000000000000000000000000000000000..a185a86b6630e9651f5798bfccb1226aa8b59f13 --- /dev/null +++ b/backend/scripts/ingest/http_client.py @@ -0,0 +1,31 @@ +"""HTTP client for POST /math-ingest with retry + exponential backoff.""" +import time +import urllib.request +import urllib.error +import json + + +def ingest_chunk(text: str, backend_url: str) -> dict: + url = f"{backend_url.rstrip('/')}/math-ingest" + payload = json.dumps({"text": text}).encode() + delay = 1.0 + for attempt in range(1, 4): + req = urllib.request.Request( + url, data=payload, + headers={"Content-Type": "application/json"}, + method="POST", + ) + try: + with urllib.request.urlopen(req, timeout=60) as resp: + body = json.loads(resp.read()) + print(f" → {len(text)} chars → {body['problems']} problems, {body['wiki_units']} wiki_units") + return body + except urllib.error.HTTPError as e: + if 400 <= e.code < 500: + raise RuntimeError(f"4xx {e.code}: {e.read().decode()}") from e + print(f" [attempt {attempt}/3] {e.code} — retrying in {delay:.0f}s") + except (urllib.error.URLError, OSError) as e: + print(f" [attempt {attempt}/3] connection error: {e} — retrying in {delay:.0f}s") + time.sleep(delay) + delay *= 2 + raise RuntimeError(f"Failed after 3 retries ({len(text)} chars)") diff --git a/backend/scripts/ingest/ingest_pdf.py b/backend/scripts/ingest/ingest_pdf.py new file mode 100644 index 0000000000000000000000000000000000000000..929dcc4be9cefdd5844eb1a263f6c00cb9e6e628 --- /dev/null +++ b/backend/scripts/ingest/ingest_pdf.py @@ -0,0 +1,99 @@ +"""Ingest PDF files (or images) into the math wiki via /math-ingest.""" +from __future__ import annotations +import argparse +import hashlib +import os +import sys + +from pdf_extractor import extract_pdf +from ocr_extractor import ocr_image, ocr_pdf +from http_client import ingest_chunk +from ingest_state import is_ingested, mark_ingested + +IMAGE_EXTS = {'.png', '.jpg', '.jpeg'} +PDF_EXT = '.pdf' +OCR_THRESHOLD = 50 # avg chars/page below this triggers OCR fallback + + +def _source_key(path: str) -> str: + stat = os.stat(path) + digest = hashlib.sha256(f"{path}:{stat.st_mtime}".encode()).hexdigest()[:8] + return f"pdf_{digest}" + + +def _extract(path: str) -> list[str]: + ext = os.path.splitext(path)[1].lower() + if ext in IMAGE_EXTS: + text = ocr_image(path) + return [text] if text else [] + + # PDF: try text layer first + chunks = extract_pdf(path) + if chunks: + avg_chars = sum(len(c) for c in chunks) / len(chunks) + if avg_chars >= OCR_THRESHOLD: + return chunks + print(f"[OCR fallback] {path} — avg {avg_chars:.0f} chars/chunk below threshold") + + # OCR fallback for image-PDFs + pages = ocr_pdf(path) + if not pages: + return [] + full_text = "\n\n".join(pages) + return [full_text[i:i + 3000] for i in range(0, len(full_text), 2800)] + + +def _process_file(path: str, backend_url: str, dry_run: bool) -> None: + key = _source_key(path) + if is_ingested(key): + print(f"[skip] {path} already ingested ({key})") + return + + chunks = _extract(path) + if not chunks: + print(f"[warn] No content extracted from {path}") + return + + print(f"{path} → {len(chunks)} chunks (key={key})") + if dry_run: + return + + total_problems = total_wiki = 0 + try: + for i, chunk in enumerate(chunks, 1): + res = ingest_chunk(chunk, backend_url) + total_problems += res['problems'] + total_wiki += res['wiki_units'] + print(f" chunk {i}/{len(chunks)} → {res['problems']} problems, {res['wiki_units']} wiki_units") + mark_ingested(key) + print(f" done: {total_problems} problems, {total_wiki} wiki_units total") + except Exception as e: + print(f" [ERROR] {path}: {e}", file=sys.stderr) + + +def main() -> None: + parser = argparse.ArgumentParser(description="Ingest PDF or image into math wiki") + parser.add_argument("path", help="PDF/image file or directory") + parser.add_argument("--backend-url", default="http://localhost:8000") + parser.add_argument("--dry-run", action="store_true") + args = parser.parse_args() + + target = args.path + if os.path.isdir(target): + files = [ + os.path.join(target, f) + for f in os.listdir(target) + if os.path.splitext(f)[1].lower() in IMAGE_EXTS | {PDF_EXT} + ] + elif os.path.isfile(target): + files = [target] + else: + print(f"[ERROR] Path not found: {target}", file=sys.stderr) + sys.exit(1) + + for f in sorted(files): + _process_file(f, args.backend_url, args.dry_run) + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/ingest/ingest_state.py b/backend/scripts/ingest/ingest_state.py new file mode 100644 index 0000000000000000000000000000000000000000..05d5bd6e2d5fa622560521b84fc4bdaf2635fe90 --- /dev/null +++ b/backend/scripts/ingest/ingest_state.py @@ -0,0 +1,35 @@ +"""Tracks which source keys have already been ingested (prevents re-runs).""" +import json +import os + +_STATE_PATH = os.path.join(os.path.dirname(__file__), 'ingest_state.json') + + +def _load() -> dict: + if not os.path.exists(_STATE_PATH): + return {} + try: + with open(_STATE_PATH) as f: + return json.load(f) + except (json.JSONDecodeError, OSError): + return {} + + +def _save(state: dict) -> None: + with open(_STATE_PATH, 'w') as f: + json.dump(state, f, indent=2) + + +def is_ingested(source_key: str) -> bool: + return bool(_load().get(source_key)) + + +def mark_ingested(source_key: str) -> None: + state = _load() + state[source_key] = True + _save(state) + + +def list_pending(all_keys: list[str]) -> list[str]: + state = _load() + return [k for k in all_keys if not state.get(k)] diff --git a/backend/scripts/ingest/ocr_extractor.py b/backend/scripts/ingest/ocr_extractor.py new file mode 100644 index 0000000000000000000000000000000000000000..193febfb5c2bdc0455590eb9022cd8039c161dbb --- /dev/null +++ b/backend/scripts/ingest/ocr_extractor.py @@ -0,0 +1,33 @@ +"""OCR text extraction using Tesseract (Vietnamese language model required).""" +from __future__ import annotations +import sys + + +def ocr_image(path: str) -> str: + try: + import pytesseract + from PIL import Image + except ImportError: + print("[WARN] pytesseract/Pillow not installed", file=sys.stderr) + return "" + try: + img = Image.open(path) + return pytesseract.image_to_string(img, lang="vie") + except Exception as e: + print(f"[WARN] OCR failed for {path}: {e}", file=sys.stderr) + return "" + + +def ocr_pdf(path: str) -> list[str]: + try: + import pytesseract + from pdf2image import convert_from_path + except ImportError: + print("[WARN] pytesseract/pdf2image not installed", file=sys.stderr) + return [] + try: + images = convert_from_path(path) + return [pytesseract.image_to_string(img, lang="vie") for img in images] + except Exception as e: + print(f"[WARN] OCR PDF failed for {path}: {e}", file=sys.stderr) + return [] diff --git a/backend/scripts/ingest/pdf_extractor.py b/backend/scripts/ingest/pdf_extractor.py new file mode 100644 index 0000000000000000000000000000000000000000..d0fba3b7ef85a3ce6a895b18d38e2b5952223532 --- /dev/null +++ b/backend/scripts/ingest/pdf_extractor.py @@ -0,0 +1,32 @@ +"""Extract text from PDF files, chunked with optional overlap.""" +from __future__ import annotations +import sys + + +def extract_pdf(path: str, chunk_size: int = 3000, overlap: int = 200) -> list[str]: + try: + from pypdf import PdfReader + except ImportError: + print("[WARN] pypdf not installed — run: pip install pypdf", file=sys.stderr) + return [] + + reader = PdfReader(path) + pages = [] + for page in reader.pages: + text = page.extract_text() or "" + text = text.strip() + if text: + pages.append(text) + + if not pages: + print(f"[WARN] No text extracted from {path} — may be image-only PDF", file=sys.stderr) + return [] + + full_text = "\n\n".join(pages) + chunks = [] + start = 0 + while start < len(full_text): + end = start + chunk_size + chunks.append(full_text[start:end]) + start = end - overlap if end < len(full_text) else len(full_text) + return chunks diff --git a/backend/scripts/migrate_sqlite_to_pg.py b/backend/scripts/migrate_sqlite_to_pg.py new file mode 100644 index 0000000000000000000000000000000000000000..588aa04e6abd9c6b1850e35664b502466d1c21ba --- /dev/null +++ b/backend/scripts/migrate_sqlite_to_pg.py @@ -0,0 +1,192 @@ +"""One-shot migration: local SQLite → Neon PostgreSQL. + +Usage: + python backend/scripts/migrate_sqlite_to_pg.py \ + --sqlite-path backend/math_wiki.db \ + --database-url "postgresql://user:pass@host/dbname?sslmode=require" +""" +import argparse +import asyncio +import json +import sqlite3 +import sys +from pathlib import Path + + +async def _register_codecs(conn): + import pgvector.asyncpg + await pgvector.asyncpg.register_vector(conn) + + +def _embed_batch(texts: list[str]) -> list[list[float]]: + from FlagEmbedding import BGEM3FlagModel + model = BGEM3FlagModel("BAAI/bge-m3", use_fp16=False) + prefixed = [f"passage: {t}" for t in texts] + out = model.encode(prefixed, return_dense=True, return_sparse=False, return_colbert_vecs=False) + return out["dense_vecs"].tolist() + + +async def migrate(sqlite_path: str, database_url: str) -> None: + import asyncpg + + sq = sqlite3.connect(sqlite_path) + sq.row_factory = sqlite3.Row + + pool = await asyncpg.create_pool( + database_url, + min_size=1, + max_size=5, + statement_cache_size=0, + init=_register_codecs, + ) + + try: + await _migrate_wiki_units(sq, pool) + await _migrate_problems(sq, pool) + await _migrate_table(sq, pool, "wiki_unit_history", + "INSERT INTO wiki_unit_history (id,unit_id,version,content,edited_by,reason,edited_at) " + "VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (id) DO NOTHING", + lambda r: (r["id"], r["unit_id"], r["version"], r["content"], + r["edited_by"], r["reason"], r["edited_at"])) + await _migrate_table(sq, pool, "unit_feedback", + "INSERT INTO unit_feedback (id,unit_id,problem_text,feedback_type,comment,resolved,created_at) " + "VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (id) DO NOTHING", + lambda r: (r["id"], r["unit_id"], r["problem_text"], r["feedback_type"], + r["comment"], bool(r["resolved"]), r["created_at"])) + await _migrate_table(sq, pool, "flagged_solutions", + "INSERT INTO flagged_solutions (id,problem_text,problem_hash,solver_output,flag_reason,reviewed,flagged_at) " + "VALUES ($1,$2,$3,$4,$5,$6,$7) ON CONFLICT (id) DO NOTHING", + lambda r: (r["id"], r["problem_text"], r["problem_hash"], r["solver_output"], + r["flag_reason"], bool(r["reviewed"]), r["flagged_at"])) + await _migrate_table(sq, pool, "wiki_drafts", + "INSERT INTO wiki_drafts (draft_id,source_url,source_text,proposed_units_json," + "final_units_json,topic_hint,status,reviewed_by,reviewed_at,created_at) " + "VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT (draft_id) DO NOTHING", + lambda r: (r["draft_id"], r["source_url"], r["source_text"], r["proposed_units_json"], + r["final_units_json"], r["topic_hint"], r["status"], + r["reviewed_by"], r["reviewed_at"], r["created_at"])) + await _migrate_table(sq, pool, "solution_logs", + "INSERT INTO solution_logs (id,problem_text,problem_hash,classified_topic,retrieved_ids," + "used_knowledge_ids,solver_confidence,validation_valid,validation_issues,wiki_assisted,created_at) " + "VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) ON CONFLICT (id) DO NOTHING", + lambda r: (r["id"], r["problem_text"], r["problem_hash"], r["classified_topic"], + r["retrieved_ids"], r["used_knowledge_ids"], r["solver_confidence"], + bool(r["validation_valid"]), r["validation_issues"], + bool(r["wiki_assisted"]), r["created_at"])) + await _migrate_table(sq, pool, "staged_wiki_units", + "INSERT INTO staged_wiki_units (staged_id,unit_data,source,source_url,status," + "proposed_by,created_at,updated_at) " + "VALUES ($1,$2,$3,$4,$5,$6,$7,$8) ON CONFLICT (staged_id) DO NOTHING", + lambda r: (r["staged_id"], r["unit_data"], r["source"], r["source_url"], + r["status"], r["proposed_by"], r["created_at"], r["updated_at"])) + finally: + sq.close() + await pool.close() + + +async def _migrate_wiki_units(sq: sqlite3.Connection, pool) -> None: + rows = sq.execute("SELECT * FROM wiki_units").fetchall() + if not rows: + print("wiki_units: 0 rows — skipping") + return + + print(f"wiki_units: computing embeddings for {len(rows)} rows…") + loop = asyncio.get_event_loop() + texts = [r["content"] for r in rows] + # Run CPU-bound embed in executor + embeddings = await loop.run_in_executor(None, _embed_batch, texts) + + inserted = skipped = 0 + async with pool.acquire() as conn: + for row, emb in zip(rows, embeddings): + result = await conn.execute( + "INSERT INTO wiki_units " + "(id,type,topic,subtopic,content,problem_ids,source,source_url," + "deleted,version,last_edited_by,created_at,updated_at,embedding) " + "VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) " + "ON CONFLICT (id) DO NOTHING", + row["id"], row["type"], row["topic"], row["subtopic"], row["content"], + row["problem_ids"], row["source"] or "manual", row["source_url"], + bool(row["deleted"]), row["version"], row["last_edited_by"], + row["created_at"], row["updated_at"], emb, + ) + if result.split()[-1] == "1": + inserted += 1 + else: + skipped += 1 + + print(f"wiki_units: {inserted} → Neon, {skipped} skipped (duplicates)") + + +async def _migrate_problems(sq: sqlite3.Connection, pool) -> None: + # Check if figure_svg column exists (added via migration in SQLite) + cols = {r[1] for r in sq.execute("PRAGMA table_info(problems)").fetchall()} + rows = sq.execute("SELECT * FROM problems").fetchall() + if not rows: + print("problems: 0 rows — skipping") + return + + inserted = skipped = 0 + async with pool.acquire() as conn: + for row in rows: + result = await conn.execute( + "INSERT INTO problems " + "(problem_id,problem_text,choices,correct_answer,topic,subtopic," + "difficulty,problem_type,figure_svg,problem_hash,figure_type) " + "VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) " + "ON CONFLICT (problem_id) DO NOTHING", + row["problem_id"], row["problem_text"], + row["choices"] if "choices" in cols else None, + row["correct_answer"] if "correct_answer" in cols else None, + row["topic"], row["subtopic"], row["difficulty"], row["problem_type"], + row["figure_svg"] if "figure_svg" in cols else None, + row["problem_hash"] if "problem_hash" in cols else None, + row["figure_type"] if "figure_type" in cols else "svg", + ) + if result.split()[-1] == "1": + inserted += 1 + else: + skipped += 1 + + print(f"problems: {inserted} → Neon, {skipped} skipped (duplicates)") + + +async def _migrate_table(sq, pool, table, query, row_fn) -> None: + try: + rows = sq.execute(f"SELECT * FROM {table}").fetchall() + except sqlite3.OperationalError: + print(f"{table}: table not found in SQLite — skipping") + return + + if not rows: + print(f"{table}: 0 rows — skipping") + return + + inserted = skipped = 0 + async with pool.acquire() as conn: + for row in rows: + result = await conn.execute(query, *row_fn(row)) + if result.split()[-1] == "1": + inserted += 1 + else: + skipped += 1 + + print(f"{table}: {inserted} → Neon, {skipped} skipped (duplicates)") + + +def main(): + parser = argparse.ArgumentParser(description="Migrate math_wiki SQLite → Neon PostgreSQL") + parser.add_argument("--sqlite-path", required=True, help="Path to math_wiki.db") + parser.add_argument("--database-url", required=True, help="Neon PostgreSQL DSN") + args = parser.parse_args() + + if not Path(args.sqlite_path).exists(): + print(f"ERROR: SQLite file not found: {args.sqlite_path}", file=sys.stderr) + sys.exit(1) + + asyncio.run(migrate(args.sqlite_path, args.database_url)) + print("Migration complete.") + + +if __name__ == "__main__": + main() diff --git a/backend/scripts/migrations/001_schema_update.sql b/backend/scripts/migrations/001_schema_update.sql new file mode 100644 index 0000000000000000000000000000000000000000..5fbafbfc3a01e66bfe2eb0aab3ffb6451f28d5b9 --- /dev/null +++ b/backend/scripts/migrations/001_schema_update.sql @@ -0,0 +1,99 @@ +-- 001_schema_update.sql +-- Comprehensive schema update for Math Wiki System maturation (Phase 1-4) +-- Designed to be idempotent: safe to run multiple times. + +-- 1. Create new tables (IF NOT EXISTS) + +CREATE TABLE IF NOT EXISTS unit_feedback ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + wiki_unit_id TEXT, + problem_text TEXT NOT NULL, + issue_type TEXT CHECK(issue_type IN ('wrong', 'unclear', 'outdated', 'other')), + user_comment TEXT, + solver_output_json TEXT, + validation_result_json TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + resolved BOOLEAN DEFAULT FALSE +); +CREATE INDEX IF NOT EXISTS idx_feedback_unit ON unit_feedback(wiki_unit_id, resolved); + +CREATE TABLE IF NOT EXISTS flagged_solutions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + problem_text TEXT NOT NULL, + solver_output_json TEXT NOT NULL, + validation_result_json TEXT NOT NULL, + used_knowledge_ids TEXT NOT NULL, + flagged_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + reviewed BOOLEAN DEFAULT FALSE +); + +CREATE TABLE IF NOT EXISTS wiki_unit_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + wiki_unit_id TEXT NOT NULL, + version INTEGER NOT NULL, + content TEXT NOT NULL, + source TEXT NOT NULL, + edited_by TEXT DEFAULT 'system', + edited_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + change_reason TEXT, + FOREIGN KEY(wiki_unit_id) REFERENCES wiki_units(id) +); +CREATE INDEX IF NOT EXISTS idx_history_unit ON wiki_unit_history(wiki_unit_id, version); + +CREATE TABLE IF NOT EXISTS solution_logs ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + problem_text TEXT NOT NULL, + problem_hash TEXT NOT NULL, + classified_topic TEXT NOT NULL, + retrieved_ids TEXT NOT NULL, + used_knowledge_ids TEXT NOT NULL, + solver_confidence TEXT NOT NULL, + validation_valid BOOLEAN NOT NULL, + validation_issues TEXT, + wiki_assisted BOOLEAN NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX IF NOT EXISTS idx_logs_created ON solution_logs(created_at); +CREATE INDEX IF NOT EXISTS idx_logs_unit_usage ON solution_logs(used_knowledge_ids); + +CREATE TABLE IF NOT EXISTS wiki_drafts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + draft_id TEXT UNIQUE NOT NULL, + source_url TEXT, + source_text TEXT NOT NULL, + proposed_units_json TEXT NOT NULL, + topic_hint TEXT, + status TEXT DEFAULT 'pending', + reviewed_by TEXT, + reviewed_at TIMESTAMP, + final_units_json TEXT, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP +); + +-- 2. Add new columns to wiki_units (conditional) +-- Note: SQLite does not support IF NOT EXISTS on ADD COLUMN. +-- Applications should check PRAGMA table_info before executing each ALTER. +-- Alternatively, wrap each in a BEGIN/EXCEPTION block if using sqlite3 CLI. + +-- Columns to add: source (already present in running system after initial migration), created_at, updated_at, deleted, version, last_edited_by + +-- For manual execution, uncomment and run if column missing: +/* +ALTER TABLE wiki_units ADD COLUMN created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP; +ALTER TABLE wiki_units ADD COLUMN updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP; +ALTER TABLE wiki_units ADD COLUMN deleted BOOLEAN DEFAULT FALSE; +ALTER TABLE wiki_units ADD COLUMN version INTEGER DEFAULT 1; +ALTER TABLE wiki_units ADD COLUMN last_edited_by TEXT DEFAULT 'system'; +*/ + +-- 3. Create indexes for new columns if needed +CREATE INDEX IF NOT EXISTS idx_active_units ON wiki_units(deleted); +CREATE INDEX IF NOT EXISTS idx_unit_version ON wiki_units(version); + +-- 4. Backfill existing rows (if columns just added) +-- Ensure no NULL values break queries: +-- UPDATE wiki_units SET deleted = FALSE WHERE deleted IS NULL; +-- UPDATE wiki_units SET version = 1 WHERE version IS NULL; +-- UPDATE wiki_units SET last_edited_by = 'system' WHERE last_edited_by IS NULL; + +-- Migration complete. diff --git a/backend/scripts/migrations/002_add_source_url.sql b/backend/scripts/migrations/002_add_source_url.sql new file mode 100644 index 0000000000000000000000000000000000000000..4c3ad1cec4f9996051046de961758c2a50bf3262 --- /dev/null +++ b/backend/scripts/migrations/002_add_source_url.sql @@ -0,0 +1,9 @@ +-- 002_add_source_url.sql +-- Adds nullable source_url TEXT column to wiki_units. +-- Idempotent: no-ops silently if column already exists. + +SELECT CASE + WHEN (SELECT COUNT(*) FROM pragma_table_info('wiki_units') WHERE name='source_url') = 0 + THEN (SELECT 1 WHERE (SELECT 1 FROM (SELECT wiki_units.* FROM wiki_units LIMIT 0)) IS NULL) +END; +-- The actual ALTER is executed conditionally by run_migration.py. diff --git a/backend/scripts/migrations/003_postgres_init.sql b/backend/scripts/migrations/003_postgres_init.sql new file mode 100644 index 0000000000000000000000000000000000000000..8acb09dd1ab550565659d6de19053d434e482c73 --- /dev/null +++ b/backend/scripts/migrations/003_postgres_init.sql @@ -0,0 +1,110 @@ +CREATE EXTENSION IF NOT EXISTS vector; + +CREATE TABLE IF NOT EXISTS wiki_units ( + id TEXT PRIMARY KEY, + type TEXT NOT NULL, + topic TEXT NOT NULL, + subtopic TEXT NOT NULL, + content TEXT NOT NULL, + problem_ids TEXT NOT NULL DEFAULT '[]', + source TEXT NOT NULL DEFAULT 'manual', + source_url TEXT, + deleted BOOLEAN NOT NULL DEFAULT FALSE, + version INTEGER NOT NULL DEFAULT 1, + last_edited_by TEXT, + embedding vector(1024), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS problems ( + problem_id TEXT PRIMARY KEY, + problem_text TEXT NOT NULL, + choices TEXT, + correct_answer TEXT, + topic TEXT NOT NULL, + subtopic TEXT NOT NULL, + difficulty TEXT NOT NULL, + problem_type TEXT NOT NULL, + figure_svg TEXT, + problem_hash TEXT, + figure_type TEXT NOT NULL DEFAULT 'svg' +); + +CREATE TABLE IF NOT EXISTS wiki_unit_history ( + id SERIAL PRIMARY KEY, + unit_id TEXT NOT NULL, + version INTEGER NOT NULL, + content TEXT NOT NULL, + edited_by TEXT, + reason TEXT, + edited_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS unit_feedback ( + id SERIAL PRIMARY KEY, + unit_id TEXT NOT NULL, + problem_text TEXT, + feedback_type TEXT NOT NULL DEFAULT 'general', + comment TEXT, + resolved BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS flagged_solutions ( + id SERIAL PRIMARY KEY, + problem_text TEXT NOT NULL, + problem_hash TEXT NOT NULL, + solver_output TEXT NOT NULL, + flag_reason TEXT, + reviewed BOOLEAN NOT NULL DEFAULT FALSE, + flagged_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS wiki_drafts ( + draft_id TEXT PRIMARY KEY, + source_url TEXT, + source_text TEXT NOT NULL, + proposed_units_json TEXT NOT NULL DEFAULT '[]', + final_units_json TEXT, + topic_hint TEXT, + status TEXT NOT NULL DEFAULT 'pending', + reviewed_by TEXT, + reviewed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS solution_logs ( + id SERIAL PRIMARY KEY, + problem_text TEXT NOT NULL, + problem_hash TEXT NOT NULL, + classified_topic TEXT NOT NULL, + retrieved_ids TEXT NOT NULL DEFAULT '[]', + used_knowledge_ids TEXT NOT NULL DEFAULT '[]', + solver_confidence TEXT NOT NULL DEFAULT 'medium', + validation_valid BOOLEAN NOT NULL DEFAULT FALSE, + validation_issues TEXT NOT NULL DEFAULT '[]', + wiki_assisted BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS staged_wiki_units ( + staged_id TEXT PRIMARY KEY, + unit_data TEXT NOT NULL, + source TEXT NOT NULL DEFAULT 'manual', + source_url TEXT, + status TEXT NOT NULL DEFAULT 'pending', + proposed_by TEXT NOT NULL DEFAULT 'system', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS wiki_units_topic_idx ON wiki_units (topic); +CREATE INDEX IF NOT EXISTS wiki_units_deleted_idx ON wiki_units (deleted); +CREATE INDEX IF NOT EXISTS problems_hash_idx ON problems (problem_hash); +CREATE INDEX IF NOT EXISTS solution_logs_created_idx ON solution_logs (created_at); +CREATE INDEX IF NOT EXISTS staged_wiki_units_status_idx ON staged_wiki_units (status); + +-- HNSW index for vector similarity search +-- Note: cannot use CONCURRENTLY inside a transaction; plain CREATE INDEX IF NOT EXISTS is used here +CREATE INDEX IF NOT EXISTS wiki_units_embedding_hnsw ON wiki_units USING hnsw (embedding vector_cosine_ops); diff --git a/backend/scripts/migrations/run_migration.py b/backend/scripts/migrations/run_migration.py new file mode 100644 index 0000000000000000000000000000000000000000..225099841bd79417b98be6d4966c212d7f9dc98c --- /dev/null +++ b/backend/scripts/migrations/run_migration.py @@ -0,0 +1,32 @@ +"""Migration runner — superseded by the auto-migration in db._ensure_tables(). + +All schema changes (tables, columns) are applied automatically when the server +starts or any DB function is first called. This script is kept for manual +on-demand use and documents what the auto-migration covers. + +Usage: PYTHONPATH=backend python3 backend/scripts/migrations/run_migration.py +""" +import sys +import os + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..")) + + +def run() -> None: + from app.math_wiki.storage.db import _get_conn, _ensure_tables + conn = _get_conn() + _ensure_tables(conn) + tables = sorted( + r[0] for r in conn.execute( + "SELECT name FROM sqlite_master WHERE type='table'" + ).fetchall() + ) + cols = [r[1] for r in conn.execute("PRAGMA table_info(wiki_units)").fetchall()] + print("Migration complete.") + print(f"Tables: {tables}") + print(f"wiki_units columns: {cols}") + conn.close() + + +if __name__ == "__main__": + run() diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000000000000000000000000000000000000..e930bd3a15a7b6fa22751c2ea28ffd1dc3502984 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,19 @@ +"""Shared pytest configuration for all backend tests.""" +import asyncio +import pytest + + +def pytest_configure(config): + config.addinivalue_line( + "markers", + "live_ai: requires a real ANTHROPIC_AUTH_TOKEN — makes live API calls", + ) + + +@pytest.fixture(scope="session") +def event_loop_for_sync(): + """Provide a persistent event loop for sync tests that call async functions.""" + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + yield loop + loop.close() diff --git a/backend/tests/fixtures/__init__.py b/backend/tests/fixtures/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/backend/tests/fixtures/math_test_cases.py b/backend/tests/fixtures/math_test_cases.py new file mode 100644 index 0000000000000000000000000000000000000000..cc313dc53668cfa75b34e6b0d5ce1416c0afd2da --- /dev/null +++ b/backend/tests/fixtures/math_test_cases.py @@ -0,0 +1,666 @@ +""" +Unified math test case bank — 70 problems covering all 9 evaluation categories. + +Each case is a dict with: + id : unique string identifier + question : problem text (Vietnamese + LaTeX $...$) + category : A–H (see test_wiki_math_system.py for descriptions) + bloom_level : 1–6 (Category A only; 0 = N/A) + topic : canonical topic label + difficulty : easy | medium | hard + expected_answer : key phrase(s) that must appear in final_answer (case-insensitive) + Can be a string or list of strings (any one must match for pass). + Empty string "" = no answer check (structure/format check only). + expected_valid : True | False | None + True = validator must return valid=True + False = validator should return valid=False (edge-case problems) + None = no validation check (proof/open-ended) + check_figure : True = a figure must be generated (non-None, non-empty data) + notes : human-readable explanation of what this test exercises +""" + +MATH_TEST_CASES: list[dict] = [ + + # ══════════════════════════════════════════════════════════════════ + # CATEGORY A — Bloom's Taxonomy (18 tests, 3 per level L1–L6) + # ══════════════════════════════════════════════════════════════════ + + # L1 Remember — pure recall of facts, formulas, definitions + { + "id": "A-L1-01", + "question": "Đạo hàm của hàm số $\\sin x$ là gì?", + "category": "A", "bloom_level": 1, "topic": "calculus", "difficulty": "easy", + "expected_answer": ["cos x", "\\cos x"], + "expected_valid": True, "check_figure": False, + "notes": "Recall: basic derivative rule", + }, + { + "id": "A-L1-02", + "question": "Công thức tính diện tích hình thang có hai đáy $a$, $b$ và chiều cao $h$ là gì?", + "category": "A", "bloom_level": 1, "topic": "geometry", "difficulty": "easy", + "expected_answer": ["(a+b)", "a + b"], + "expected_valid": True, "check_figure": False, + "notes": "Recall: trapezoid area formula", + }, + { + "id": "A-L1-03", + "question": "Nếu $a > 1$ và $a^x = a^y$ thì $x$ và $y$ có quan hệ gì?", + "category": "A", "bloom_level": 1, "topic": "algebra", "difficulty": "easy", + "expected_answer": ["x = y", "x bằng y"], + "expected_valid": True, "check_figure": False, + "notes": "Recall: exponential equality rule", + }, + + # L2 Understand — explain, interpret, restate + { + "id": "A-L2-01", + "question": "Tại sao hàm số $f(x) = x^2$ đồng biến trên khoảng $(0, +\\infty)$? Giải thích bằng đạo hàm.", + "category": "A", "bloom_level": 2, "topic": "calculus", "difficulty": "easy", + "expected_answer": ["f'(x)", "2x", "dương"], + "expected_valid": None, "check_figure": False, + "notes": "Understand: interpret monotonicity via derivative sign", + }, + { + "id": "A-L2-02", + "question": "Giải thích ý nghĩa hình học của đạo hàm $f'(x_0)$.", + "category": "A", "bloom_level": 2, "topic": "calculus", "difficulty": "easy", + "expected_answer": ["tiếp tuyến", "hệ số góc"], + "expected_valid": None, "check_figure": False, + "notes": "Understand: geometric meaning of derivative = slope of tangent", + }, + { + "id": "A-L2-03", + "question": "Tại sao phương trình $x^2 + 1 = 0$ vô nghiệm trong tập số thực?", + "category": "A", "bloom_level": 2, "topic": "algebra", "difficulty": "easy", + "expected_answer": ["x^2 \\geq 0", "x² ≥ 0", "không âm", "luôn dương"], + "expected_valid": None, "check_figure": False, + "notes": "Understand: explain why discriminant < 0 → no real roots", + }, + + # L3 Apply — execute known procedure on a new instance + { + "id": "A-L3-01", + "question": "Tính tích phân $\\int_0^1 2x \\, dx$.", + "category": "A", "bloom_level": 3, "topic": "calculus", "difficulty": "easy", + "expected_answer": ["1"], + "expected_valid": True, "check_figure": False, + "notes": "Apply: power rule antiderivative + Newton-Leibniz", + }, + { + "id": "A-L3-02", + "question": "Giải phương trình $2x + 3 = 7$.", + "category": "A", "bloom_level": 3, "topic": "algebra", "difficulty": "easy", + "expected_answer": ["x = 2"], + "expected_valid": True, "check_figure": False, + "notes": "Apply: solve linear equation — simplest possible", + }, + { + "id": "A-L3-03", + "question": "Tính đạo hàm của hàm số $f(x) = x^3 - 3x + 2$.", + "category": "A", "bloom_level": 3, "topic": "calculus", "difficulty": "easy", + "expected_answer": ["3x^2 - 3", "3x² - 3"], + "expected_valid": True, "check_figure": False, + "notes": "Apply: polynomial differentiation", + }, + + # L4 Analyze — decompose, find structure, derive + { + "id": "A-L4-01", + "question": "Tìm tất cả cực trị (cực đại, cực tiểu) của hàm số $f(x) = x^3 - 3x$.", + "category": "A", "bloom_level": 4, "topic": "calculus", "difficulty": "medium", + "expected_answer": ["cực đại", "cực tiểu", "x = -1", "x = 1"], + "expected_valid": True, "check_figure": False, + "notes": "Analyze: find critical points + classify with second derivative / sign chart", + }, + { + "id": "A-L4-02", + "question": "Phân tích đa thức $x^3 - 6x^2 + 11x - 6$ thành nhân tử.", + "category": "A", "bloom_level": 4, "topic": "algebra", "difficulty": "medium", + "expected_answer": ["(x-1)", "(x-2)", "(x-3)"], + "expected_valid": True, "check_figure": False, + "notes": "Analyze: factor cubic — requires finding rational root then long division", + }, + { + "id": "A-L4-03", + "question": "Tìm tập nghiệm của bất phương trình $x^2 - 5x + 6 < 0$.", + "category": "A", "bloom_level": 4, "topic": "algebra", "difficulty": "medium", + "expected_answer": ["(2, 3)", "2 < x < 3"], + "expected_valid": True, "check_figure": False, + "notes": "Analyze: sign of quadratic on intervals — requires parabola analysis", + }, + + # L5 Evaluate — assess correctness, judge against criteria + { + "id": "A-L5-01", + "question": "Kiểm tra xem $x = 2$ có phải là nghiệm của phương trình $x^2 - 5x + 6 = 0$ không. Trình bày lý do.", + "category": "A", "bloom_level": 5, "topic": "algebra", "difficulty": "easy", + "expected_answer": ["đúng", "là nghiệm", "thỏa mãn", "= 0"], + "expected_valid": None, "check_figure": False, + "notes": "Evaluate: verify by substitution — assessment of a claim", + }, + { + "id": "A-L5-02", + "question": "Đánh giá: bất phương trình $\\log_2(x-1) > 3$ có tập nghiệm là $x > 9$ đúng hay sai?", + "category": "A", "bloom_level": 5, "topic": "algebra", "difficulty": "medium", + "expected_answer": ["đúng", "x > 9", "x-1 > 8"], + "expected_valid": None, "check_figure": False, + "notes": "Evaluate: verify logarithmic inequality solution", + }, + { + "id": "A-L5-03", + "question": "Khẳng định: tập giá trị của hàm số $f(x) = \\sin x$ là $[-2, 2]$. Đúng hay sai? Tại sao?", + "category": "A", "bloom_level": 5, "topic": "algebra", "difficulty": "easy", + "expected_answer": ["sai", "[-1, 1]", "−1"], + "expected_valid": None, "check_figure": False, + "notes": "Evaluate: detect incorrect claim about range of sine function", + }, + + # L6 Create — synthesize new objects from constraints + { + "id": "A-L6-01", + "question": "Xây dựng một hàm số $f(x)$ thỏa mãn đồng thời: $f'(x) = 3x^2$ và $f(0) = 5$.", + "category": "A", "bloom_level": 6, "topic": "calculus", "difficulty": "medium", + "expected_answer": ["x^3 + 5", "x³ + 5"], + "expected_valid": True, "check_figure": False, + "notes": "Create: construct function from derivative + initial condition", + }, + { + "id": "A-L6-02", + "question": "Viết phương trình đường thẳng đi qua hai điểm $A(1, 2)$ và $B(3, 6)$.", + "category": "A", "bloom_level": 6, "topic": "geometry", "difficulty": "easy", + "expected_answer": ["y = 2x", "2x"], + "expected_valid": True, "check_figure": False, + "notes": "Create: construct line equation from two points", + }, + { + "id": "A-L6-03", + "question": "Dựng phương trình bậc hai có hai nghiệm là $x_1 = 2$ và $x_2 = -3$.", + "category": "A", "bloom_level": 6, "topic": "algebra", "difficulty": "medium", + "expected_answer": ["x^2 + x - 6", "x² + x - 6"], + "expected_valid": True, "check_figure": False, + "notes": "Create: synthesize quadratic from roots via Vieta's formulas", + }, + + # ══════════════════════════════════════════════════════════════════ + # CATEGORY B — THPT Domain Parity (18 tests, 3 per domain) + # ══════════════════════════════════════════════════════════════════ + + # Functions & Derivatives + { + "id": "B-FUNC-01", + "question": "Tìm khoảng đồng biến của hàm số $f(x) = x^2 - 4x + 3$.", + "category": "B", "bloom_level": 0, "topic": "calculus", "difficulty": "easy", + "expected_answer": ["(2, +∞)", "(2; +∞)", "x > 2"], + "expected_valid": True, "check_figure": False, + "notes": "THPT Functions easy: monotone increasing interval", + }, + { + "id": "B-FUNC-02", + "question": "Tìm giá trị cực đại của hàm số $f(x) = -x^3 + 3x + 2$.", + "category": "B", "bloom_level": 0, "topic": "calculus", "difficulty": "medium", + "expected_answer": ["4", "f(-1) = 4"], + "expected_valid": True, "check_figure": False, + "notes": "THPT Functions medium: local maximum value", + }, + { + "id": "B-FUNC-03", + "question": "Tìm giá trị lớn nhất và giá trị nhỏ nhất của hàm số $f(x) = x^3 - 3x$ trên đoạn $[-2, 2]$.", + "category": "B", "bloom_level": 0, "topic": "calculus", "difficulty": "hard", + "expected_answer": ["2", "-2", "gtln", "gtnn", "lớn nhất", "nhỏ nhất"], + "expected_valid": True, "check_figure": False, + "notes": "THPT Functions hard: global extrema on closed interval", + }, + + # Exponential & Logarithm + { + "id": "B-LOG-01", + "question": "Giải phương trình $2^x = 8$.", + "category": "B", "bloom_level": 0, "topic": "algebra", "difficulty": "easy", + "expected_answer": ["x = 3"], + "expected_valid": True, "check_figure": False, + "notes": "THPT Exponential easy: direct exponential equation", + }, + { + "id": "B-LOG-02", + "question": "Giải phương trình $\\log_2(x-1) + \\log_2(x+1) = 3$.", + "category": "B", "bloom_level": 0, "topic": "algebra", "difficulty": "medium", + "expected_answer": ["x = 3"], + "expected_valid": True, "check_figure": False, + "notes": "THPT Logarithm medium: combine logs, solve, check domain", + }, + { + "id": "B-LOG-03", + "question": "Tìm $x$ biết $4^x - 3 \\cdot 2^x - 4 = 0$.", + "category": "B", "bloom_level": 0, "topic": "algebra", "difficulty": "hard", + "expected_answer": ["x = 2"], + "expected_valid": True, "check_figure": False, + "notes": "THPT Exponential hard: substitution t=2^x reduces to quadratic", + }, + + # Integrals & Applications + { + "id": "B-INT-01", + "question": "Tính tích phân $\\int_0^2 x \\, dx$.", + "category": "B", "bloom_level": 0, "topic": "calculus", "difficulty": "easy", + "expected_answer": ["2"], + "expected_valid": True, "check_figure": False, + "notes": "THPT Integrals easy: basic definite integral", + }, + { + "id": "B-INT-02", + "question": "Tính diện tích hình phẳng giới hạn bởi đường cong $y = x^2$, trục hoành, đường thẳng $x = 0$ và $x = 1$.", + "category": "B", "bloom_level": 0, "topic": "calculus", "difficulty": "medium", + "expected_answer": ["1/3", "\\frac{1}{3}"], + "expected_valid": True, "check_figure": False, + "notes": "THPT Integrals medium: area under a curve", + }, + { + "id": "B-INT-03", + "question": "Tính tích phân $\\int_0^{\\pi} x \\sin x \\, dx$.", + "category": "B", "bloom_level": 0, "topic": "calculus", "difficulty": "hard", + "expected_answer": ["π", "\\pi", "pi"], + "expected_valid": True, "check_figure": False, + "notes": "THPT Integrals hard: integration by parts", + }, + + # Probability & Statistics + { + "id": "B-PROB-01", + "question": "Có 4 bóng đỏ và 3 bóng xanh trong hộp. Chọn ngẫu nhiên 1 bóng. Tính xác suất chọn được bóng đỏ.", + "category": "B", "bloom_level": 0, "topic": "probability", "difficulty": "easy", + "expected_answer": ["4/7", "\\frac{4}{7}"], + "expected_valid": True, "check_figure": False, + "notes": "THPT Probability easy: classical probability", + }, + { + "id": "B-PROB-02", + "question": "Gieo một xúc xắc cân đối hai lần. Tính xác suất để cả hai lần đều ra mặt chẵn.", + "category": "B", "bloom_level": 0, "topic": "probability", "difficulty": "medium", + "expected_answer": ["1/4", "0,25", "0.25", "\\frac{1}{4}"], + "expected_valid": True, "check_figure": False, + "notes": "THPT Probability medium: independent events", + }, + { + "id": "B-PROB-03", + "question": "Từ 5 học sinh nam và 3 học sinh nữ, chọn ngẫu nhiên 3 học sinh. Tính xác suất để chọn được đúng 1 học sinh nữ.", + "category": "B", "bloom_level": 0, "topic": "probability", "difficulty": "hard", + "expected_answer": ["15/28", "\\frac{15}{28}"], + "expected_valid": True, "check_figure": False, + "notes": "THPT Probability hard: combinations — C(3,1)*C(5,2)/C(8,3)", + }, + + # Spatial Geometry (3D) + { + "id": "B-GEO3D-01", + "question": "Trong không gian $Oxyz$, tính khoảng cách từ điểm $A(1, 2, 3)$ đến gốc tọa độ $O$.", + "category": "B", "bloom_level": 0, "topic": "geometry", "difficulty": "easy", + "expected_answer": ["√14", "\\sqrt{14}"], + "expected_valid": True, "check_figure": False, + "notes": "THPT 3D Geometry easy: distance formula in Oxyz", + }, + { + "id": "B-GEO3D-02", + "question": "Viết phương trình mặt phẳng đi qua ba điểm $A(1,0,0)$, $B(0,2,0)$, $C(0,0,3)$.", + "category": "B", "bloom_level": 0, "topic": "geometry", "difficulty": "medium", + "expected_answer": ["6x + 3y + 2z", "x/1 + y/2 + z/3 = 1"], + "expected_valid": True, "check_figure": False, + "notes": "THPT 3D Geometry medium: plane through 3 points", + }, + { + "id": "B-GEO3D-03", + "question": "Tính khoảng cách từ điểm $M(1, 1, 1)$ đến mặt phẳng $(P): 2x + 2y + z = 9$.", + "category": "B", "bloom_level": 0, "topic": "geometry", "difficulty": "hard", + "expected_answer": ["4/3", "\\frac{4}{3}"], + "expected_valid": True, "check_figure": False, + "notes": "THPT 3D Geometry hard: point-to-plane distance formula", + }, + + # Trigonometry + { + "id": "B-TRIG-01", + "question": "Tính giá trị của biểu thức $\\sin 30° + \\cos 60°$.", + "category": "B", "bloom_level": 0, "topic": "trigonometry", "difficulty": "easy", + "expected_answer": ["1"], + "expected_valid": True, "check_figure": False, + "notes": "THPT Trig easy: standard angle values", + }, + { + "id": "B-TRIG-02", + "question": "Giải phương trình $2\\sin x - \\sqrt{3} = 0$ trên đoạn $[0, 2\\pi]$.", + "category": "B", "bloom_level": 0, "topic": "trigonometry", "difficulty": "medium", + "expected_answer": ["π/3", "\\frac{\\pi}{3}", "2π/3", "\\frac{2\\pi}{3}"], + "expected_valid": True, "check_figure": False, + "notes": "THPT Trig medium: basic trig equation with two solutions", + }, + { + "id": "B-TRIG-03", + "question": "Rút gọn biểu thức $\\dfrac{\\sin 3x}{\\sin x} - \\dfrac{\\cos 3x}{\\cos x}$ (với $\\sin x \\neq 0$, $\\cos x \\neq 0$).", + "category": "B", "bloom_level": 0, "topic": "trigonometry", "difficulty": "hard", + "expected_answer": ["2"], + "expected_valid": True, "check_figure": False, + "notes": "THPT Trig hard: simplify using sine subtraction formula → sin(2x)/sinx·cosx = 2", + }, + + # ══════════════════════════════════════════════════════════════════ + # CATEGORY C — Retrieval Quality (10 tests) + # These tests require a populated wiki DB — skipped when pool=None. + # ══════════════════════════════════════════════════════════════════ + + { + "id": "C-RET-01", + "question": "Giải phương trình bậc hai $x^2 - 5x + 6 = 0$.", + "category": "C", "bloom_level": 0, "topic": "algebra", "difficulty": "easy", + "expected_answer": ["x = 2", "x = 3"], + "expected_valid": True, "check_figure": False, + "notes": "Retrieval: should retrieve quadratic-formula wiki unit", + }, + { + "id": "C-RET-02", + "question": "Tính đạo hàm của $f(x) = e^{2x}$.", + "category": "C", "bloom_level": 0, "topic": "calculus", "difficulty": "easy", + "expected_answer": ["2e^{2x}", "2e^2x"], + "expected_valid": True, "check_figure": False, + "notes": "Retrieval: should retrieve chain rule wiki unit", + }, + { + "id": "C-RET-03", + "question": "Tính $\\int x e^x \\, dx$.", + "category": "C", "bloom_level": 0, "topic": "calculus", "difficulty": "medium", + "expected_answer": ["xe^x - e^x", "e^x(x-1)"], + "expected_valid": True, "check_figure": False, + "notes": "Retrieval: should retrieve integration-by-parts wiki unit", + }, + { + "id": "C-RET-04", + "question": "Tìm các tiệm cận của hàm số $f(x) = \\frac{x+1}{x-2}$.", + "category": "C", "bloom_level": 0, "topic": "calculus", "difficulty": "medium", + "expected_answer": ["x = 2", "y = 1"], + "expected_valid": True, "check_figure": False, + "notes": "Retrieval: should retrieve asymptotes wiki unit", + }, + { + "id": "C-RET-05", + "question": "Chứng minh $\\sin^2 x + \\cos^2 x = 1$.", + "category": "C", "bloom_level": 0, "topic": "trigonometry", "difficulty": "easy", + "expected_answer": ["1"], + "expected_valid": None, "check_figure": False, + "notes": "Retrieval: should retrieve Pythagorean identity wiki unit", + }, + { + "id": "C-RET-06", + "question": "Giải hệ phương trình $x + y = 5$ và $2x - y = 1$.", + "category": "C", "bloom_level": 0, "topic": "algebra", "difficulty": "easy", + "expected_answer": ["x = 2", "y = 3"], + "expected_valid": True, "check_figure": False, + "notes": "Retrieval: should retrieve linear-systems wiki unit", + }, + { + "id": "C-RET-07", + "question": "Tính số chỉnh hợp chập 2 của 5 phần tử.", + "category": "C", "bloom_level": 0, "topic": "combinatorics", "difficulty": "easy", + "expected_answer": ["20"], + "expected_valid": True, "check_figure": False, + "notes": "Retrieval: should retrieve permutation formula wiki unit", + }, + { + "id": "C-RET-08", + "question": "Tính $\\lim_{x \\to 0} \\dfrac{\\sin x}{x}$.", + "category": "C", "bloom_level": 0, "topic": "calculus", "difficulty": "medium", + "expected_answer": ["1"], + "expected_valid": True, "check_figure": False, + "notes": "Retrieval: should retrieve standard limit wiki unit", + }, + { + "id": "C-RET-09", + "question": "Tìm số hạng tổng quát của cấp số nhân có $u_1 = 2$ và công bội $q = 3$.", + "category": "C", "bloom_level": 0, "topic": "algebra", "difficulty": "easy", + "expected_answer": ["2 \\cdot 3^{n-1}", "2·3^(n-1)"], + "expected_valid": True, "check_figure": False, + "notes": "Retrieval: should retrieve geometric sequence wiki unit", + }, + { + "id": "C-RET-10", + "question": "Tính thể tích khối cầu có bán kính $R = 3$.", + "category": "C", "bloom_level": 0, "topic": "geometry", "difficulty": "easy", + "expected_answer": ["36π", "36\\pi"], + "expected_valid": True, "check_figure": False, + "notes": "Retrieval: should retrieve sphere volume formula wiki unit", + }, + + # ══════════════════════════════════════════════════════════════════ + # CATEGORY D — Multi-Domain Reasoning (5 tests) + # These require combining two distinct THPT topics in one problem. + # ══════════════════════════════════════════════════════════════════ + + { + "id": "D-MULTI-01", + "question": "Cho đường tròn $(C): x^2 + y^2 = 4$. Tìm tọa độ các điểm trên đường tròn có hoành độ bằng $\\sqrt{3}$.", + "category": "D", "bloom_level": 0, "topic": "geometry", "difficulty": "medium", + "expected_answer": ["(√3, 1)", "(√3, -1)", "\\sqrt{3}"], + "expected_valid": True, "check_figure": False, + "notes": "Multi-domain: algebra (substitution) + geometry (circle equation)", + }, + { + "id": "D-MULTI-02", + "question": "Tìm giá trị lớn nhất của hàm số $f(x) = \\ln x - x$ trên khoảng $(0, +\\infty)$.", + "category": "D", "bloom_level": 0, "topic": "calculus", "difficulty": "hard", + "expected_answer": ["-1", "f(1) = -1"], + "expected_valid": True, "check_figure": False, + "notes": "Multi-domain: calculus (derivative) + logarithm (ln domain/properties)", + }, + { + "id": "D-MULTI-03", + "question": "Giải phương trình $\\log_3(x^2 - 2x) = 1$.", + "category": "D", "bloom_level": 0, "topic": "algebra", "difficulty": "medium", + "expected_answer": ["x = 3", "x = -1"], + "expected_valid": True, "check_figure": False, + "notes": "Multi-domain: logarithm (log equation) + algebra (quadratic from exponential form)", + }, + { + "id": "D-MULTI-04", + "question": "Tính diện tích hình phẳng giới hạn bởi đường cong $y = x^2$ và đường thẳng $y = x$.", + "category": "D", "bloom_level": 0, "topic": "calculus", "difficulty": "medium", + "expected_answer": ["1/6", "\\frac{1}{6}"], + "expected_valid": True, "check_figure": False, + "notes": "Multi-domain: integration (area) + algebra (intersection of parabola and line)", + }, + { + "id": "D-MULTI-05", + "question": "Từ 5 bạn nam và 3 bạn nữ, chọn ngẫu nhiên 3 người. Tính xác suất chọn được đúng 1 bạn nữ.", + "category": "D", "bloom_level": 0, "topic": "probability", "difficulty": "hard", + "expected_answer": ["15/28", "\\frac{15}{28}"], + "expected_valid": True, "check_figure": False, + "notes": "Multi-domain: combinatorics (C_3^1 * C_5^2 / C_8^3) + probability", + }, + + # ══════════════════════════════════════════════════════════════════ + # CATEGORY E — Proof & Deduction (5 tests) + # Tests formal reasoning chains — expect many failures here. + # ══════════════════════════════════════════════════════════════════ + + { + "id": "E-PROOF-01", + "question": "Chứng minh rằng tích $n(n+1)$ chia hết cho 2 với mọi số nguyên dương $n$.", + "category": "E", "bloom_level": 0, "topic": "algebra", "difficulty": "easy", + "expected_answer": ["chẵn", "chia hết cho 2", "n và n+1"], + "expected_valid": None, "check_figure": False, + "notes": "Proof easy: consecutive integers — one must be even", + }, + { + "id": "E-PROOF-02", + "question": "Chứng minh bất đẳng thức $a^2 + b^2 \\geq 2ab$ với mọi $a, b \\in \\mathbb{R}$.", + "category": "E", "bloom_level": 0, "topic": "algebra", "difficulty": "easy", + "expected_answer": ["(a-b)^2", "(a - b)^2", "\\geq 0"], + "expected_valid": None, "check_figure": False, + "notes": "Proof easy: AM-GM via completing the square → (a-b)² ≥ 0", + }, + { + "id": "E-PROOF-03", + "question": "Chứng minh rằng $\\sqrt{2}$ là số vô tỉ.", + "category": "E", "bloom_level": 0, "topic": "algebra", "difficulty": "medium", + "expected_answer": ["vô tỉ", "phản chứng", "mâu thuẫn"], + "expected_valid": None, "check_figure": False, + "notes": "Proof medium: classic irrationality proof by contradiction", + }, + { + "id": "E-PROOF-04", + "question": "Bằng quy nạp toán học, chứng minh $1 + 2 + 3 + \\ldots + n = \\dfrac{n(n+1)}{2}$ với mọi $n \\geq 1$.", + "category": "E", "bloom_level": 0, "topic": "algebra", "difficulty": "medium", + "expected_answer": ["quy nạp", "n=1", "k+1", "n(n+1)/2"], + "expected_valid": None, "check_figure": False, + "notes": "Proof medium: mathematical induction — base case + inductive step", + }, + { + "id": "E-PROOF-05", + "question": "Chứng minh rằng trong tam giác bất kỳ, tổng hai cạnh bất kỳ luôn lớn hơn cạnh còn lại.", + "category": "E", "bloom_level": 0, "topic": "geometry", "difficulty": "medium", + "expected_answer": ["bất đẳng thức tam giác", "lớn hơn"], + "expected_valid": None, "check_figure": False, + "notes": "Proof medium: triangle inequality — geometric proof", + }, + + # ══════════════════════════════════════════════════════════════════ + # CATEGORY F — Figure & Visual (4 tests) + # A figure (GeoGebra or SVG) must be generated. + # ══════════════════════════════════════════════════════════════════ + + { + "id": "F-FIG-01", + "question": "Tìm giao điểm của đồ thị hàm số $y = x^2 - 4$ với trục $Ox$ và trục $Oy$.", + "category": "F", "bloom_level": 0, "topic": "geometry", "difficulty": "easy", + "expected_answer": ["x = 2", "x = -2", "y = -4", "±2", "x = ±", "(-2, 0)", "(2, 0)", "(0, -4)"], + "expected_valid": True, "check_figure": True, + "notes": "Figure: parabola intersections — should generate function graph", + }, + { + "id": "F-FIG-02", + "question": "Cho tam giác $ABC$ với $A(0,0)$, $B(4,0)$, $C(2,3)$. Vẽ và tính diện tích tam giác.", + "category": "F", "bloom_level": 0, "topic": "geometry", "difficulty": "easy", + "expected_answer": ["6"], + "expected_valid": True, "check_figure": True, + "notes": "Figure: coordinate geometry — should generate triangle diagram", + }, + { + "id": "F-FIG-03", + "question": "Tìm tọa độ đỉnh và vẽ đồ thị của Parabol $y = x^2 - 2x + 3$.", + "category": "F", "bloom_level": 0, "topic": "geometry", "difficulty": "medium", + "expected_answer": ["(1, 2)", "đỉnh"], + "expected_valid": True, "check_figure": True, + "notes": "Figure: parabola vertex + graph — should generate GeoGebra plot", + }, + { + "id": "F-FIG-04", + "question": "Cho hình lập phương $ABCD.A'B'C'D'$ cạnh $a = 2$. Tính thể tích và vẽ hình.", + "category": "F", "bloom_level": 0, "topic": "geometry", "difficulty": "easy", + "expected_answer": ["8"], + "expected_valid": True, "check_figure": True, + "notes": "Figure: 3D cube — should attempt 3D figure generation", + }, + + # ══════════════════════════════════════════════════════════════════ + # CATEGORY G — Edge Cases & Adversarial (6 tests) + # The system must NOT hallucinate answers for impossible/undefined problems. + # ══════════════════════════════════════════════════════════════════ + + { + "id": "G-EDGE-01", + "question": "Tính $\\log_1(5)$.", + "category": "G", "bloom_level": 0, "topic": "algebra", "difficulty": "easy", + "expected_answer": ["không xác định", "undefined", "không tồn tại", "vô nghĩa"], + "expected_valid": False, "check_figure": False, + "notes": "Edge: log base 1 is undefined — must not return a numeric answer", + }, + { + "id": "G-EDGE-02", + "question": "Giải phương trình $\\sqrt{x} = -2$.", + "category": "G", "bloom_level": 0, "topic": "algebra", "difficulty": "easy", + "expected_answer": ["vô nghiệm", "không có nghiệm", "∅"], + "expected_valid": False, "check_figure": False, + "notes": "Edge: sqrt ≥ 0 so no real solution — must not return x=4", + }, + { + "id": "G-EDGE-03", + "question": "Tìm một tam giác có cả ba góc đều bằng $90°$.", + "category": "G", "bloom_level": 0, "topic": "geometry", "difficulty": "easy", + "expected_answer": ["không tồn tại", "vô lý", "180°", "mâu thuẫn"], + "expected_valid": False, "check_figure": False, + "notes": "Edge: impossible — sum of angles = 180°, not 270°", + }, + { + "id": "G-EDGE-04", + "question": "Giải phương trình $\\sqrt{x-1} + \\sqrt{1-x} = 2$.", + "category": "G", "bloom_level": 0, "topic": "algebra", "difficulty": "medium", + "expected_answer": ["vô nghiệm", "không có nghiệm"], + "expected_valid": False, "check_figure": False, + "notes": "Edge: domain forces x=1, substituting gives 0 ≠ 2 — no solution", + }, + { + "id": "G-EDGE-05", + "question": "Giải phương trình $\\dfrac{x+1}{x-1} + \\dfrac{x-1}{x+1} = \\dfrac{x^2+3}{x^2-1}$.", + "category": "G", "bloom_level": 0, "topic": "algebra", "difficulty": "medium", + "expected_answer": ["vô nghiệm", "không có nghiệm", "điều kiện"], + "expected_valid": False, "check_figure": False, + "notes": "Edge: extraneous roots — algebraic solution yields x=±1 but both excluded by denominator", + }, + { + "id": "G-EDGE-06", + "question": "Tính chu vi của Mặt Trăng bằng cách nào?", + "category": "G", "bloom_level": 0, "topic": "other", "difficulty": "easy", + "expected_answer": ["ngoài phạm vi", "không hỗ trợ", "toán lớp", "không thuộc", "mặt trăng"], + "expected_valid": False, "check_figure": False, + "notes": "Edge: out-of-scope — astronomy/geography, not THPT math", + }, + + # ══════════════════════════════════════════════════════════════════ + # CATEGORY H — Language & Format Quality (4 tests) + # Regex-checked: Vietnamese output, $...$ LaTeX, Bước N: format + # ══════════════════════════════════════════════════════════════════ + + { + "id": "H-LANG-01", + "question": "Giải phương trình $x + 2 = 5$.", + "category": "H", "bloom_level": 0, "topic": "algebra", "difficulty": "easy", + "expected_answer": ["x = 3"], + "expected_valid": True, "check_figure": False, + "notes": "Language: trivial problem — check format discipline on simple case", + }, + { + "id": "H-LANG-02", + "question": "Tính $f'(x)$ nếu $f(x) = x^2$.", + "category": "H", "bloom_level": 0, "topic": "calculus", "difficulty": "easy", + "expected_answer": ["2x"], + "expected_valid": True, "check_figure": False, + "notes": "Language: derivative — check step format on LaTeX-heavy output", + }, + { + "id": "H-LANG-03", + "question": "Một đồng xu cân đối được tung lên. Xác suất xuất hiện mặt ngửa là bao nhiêu?", + "category": "H", "bloom_level": 0, "topic": "probability", "difficulty": "easy", + "expected_answer": ["1/2", "0,5", "0.5"], + "expected_valid": True, "check_figure": False, + "notes": "Language: pure text problem — check no English words in output", + }, + { + "id": "H-LANG-04", + "question": "Tính diện tích hình vuông có cạnh bằng $3$.", + "category": "H", "bloom_level": 0, "topic": "geometry", "difficulty": "easy", + "expected_answer": ["9"], + "expected_valid": True, "check_figure": False, + "notes": "Language: geometry — check final_answer appears in last step", + }, +] + +# ── Sanity checks ────────────────────────────────────────────────────────────── + +_EXPECTED_COUNTS = {"A": 18, "B": 18, "C": 10, "D": 5, "E": 5, "F": 4, "G": 6, "H": 4} + +def _validate_cases(): + from collections import Counter + counts = Counter(c["category"] for c in MATH_TEST_CASES) + for cat, expected in _EXPECTED_COUNTS.items(): + actual = counts.get(cat, 0) + assert actual == expected, f"Category {cat}: expected {expected} cases, got {actual}" + ids = [c["id"] for c in MATH_TEST_CASES] + assert len(ids) == len(set(ids)), "Duplicate IDs found in test cases" + +_validate_cases() diff --git a/backend/tests/math_wiki_gap_report.json b/backend/tests/math_wiki_gap_report.json new file mode 100644 index 0000000000000000000000000000000000000000..d23959262b3925b16064d369d09f4cf1b298af27 --- /dev/null +++ b/backend/tests/math_wiki_gap_report.json @@ -0,0 +1,41 @@ +{ + "summary": { + "total": 1, + "passed": 1, + "pass_rate": 1.0, + "high_confidence_wrong_count": 0, + "high_confidence_correct_rate": 1.0, + "calibration_target": 0.85, + "calibration_ok": true + }, + "by_category": { + "G": { + "total": 1, + "passed": 1, + "pass_rate": 1.0, + "high_confidence_wrong": 0, + "failures": [] + } + }, + "all_results": [ + { + "id": "G-EDGE-06", + "category": "G", + "bloom_level": 0, + "topic": "other", + "difficulty": "easy", + "question": "Tính chu vi của Mặt Trăng bằng cách nào?", + "notes": "Edge: out-of-scope — astronomy/geography, not THPT math", + "passed": true, + "error": null, + "confidence": "high", + "valid": false, + "answer_correct": true, + "valid_ok": true, + "language_issues": [], + "figure_generated": false, + "figure_required": false, + "final_answer": "Câu hỏi này nằm ngoài phạm vi toán học THPT. Mình chỉ hỗ trợ các bài toán thuộc chương trình lớp 9–12 nhé!" + } + ] +} \ No newline at end of file diff --git a/backend/tests/test_admin.py b/backend/tests/test_admin.py new file mode 100644 index 0000000000000000000000000000000000000000..04ad36d1a9b84ec4a218c24b453410bb6fb6b426 --- /dev/null +++ b/backend/tests/test_admin.py @@ -0,0 +1,44 @@ +"""Tests for static admin key validation.""" +import pytest +from unittest.mock import AsyncMock +from fastapi.testclient import TestClient + + +@pytest.fixture +def client(): + import os + os.environ["ANTHROPIC_AUTH_TOKEN"] = "test-token" + os.environ["JWT_SECRET"] = "x" * 32 + os.environ["ADMIN_KEY"] = "test-admin-key-static" + # Override to empty so HMAC path is inactive; env var takes priority over .env file + os.environ["ADMIN_MASTER_SECRET"] = "" + from app.config import get_settings + get_settings.cache_clear() + from app.main import app + return TestClient(app) + + +class TestAdminKeyEndpoints: + def test_list_users_valid_key(self, client): + from app.main import app as _app, get_pool as _get_pool + mock_pool = AsyncMock() + mock_pool.fetchrow.return_value = {"cnt": 0} + mock_pool.fetch.return_value = [] + + async def _override(): + return mock_pool + + _app.dependency_overrides[_get_pool] = _override + try: + resp = client.get("/admin/users", headers={"x-admin-key": "test-admin-key-static"}) + finally: + _app.dependency_overrides.pop(_get_pool, None) + assert resp.status_code != 401 + + def test_list_users_invalid_key(self, client): + resp = client.get("/admin/users", headers={"x-admin-key": "wrong"}) + assert resp.status_code == 401 + + def test_list_users_no_key(self, client): + resp = client.get("/admin/users") + assert resp.status_code == 401 diff --git a/backend/tests/test_ai_endpoints.py b/backend/tests/test_ai_endpoints.py new file mode 100644 index 0000000000000000000000000000000000000000..8547d7fda5f7c7dc24f3dce3cbd529893c32c6b9 --- /dev/null +++ b/backend/tests/test_ai_endpoints.py @@ -0,0 +1,134 @@ +"""Integration tests for exam AI endpoints (LLM calls are mocked).""" +import json +from unittest.mock import AsyncMock, MagicMock, patch +import pytest +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + +MOCK_RESULT = { + "score": 7.5, + "accuracy": 0.75, + "topicBreakdown": { + "algebra": {"correct": 5, "total": 8, "accuracy": 0.625}, + "geometry": {"correct": 2, "total": 6, "accuracy": 0.333}, + }, + "examId": "test_exam", + "timeSpent": 1800, +} + +MOCK_QUESTION = { + "id": "q_001", + "topic": "algebra", + "difficulty": "medium", + "question": "Giải phương trình 2x + 3 = 7", + "choices": ["x = 1", "x = 2", "x = 3", "x = 4"], + "correct": 1, +} + + +def _mock_completion(content: str): + msg = MagicMock() + msg.content = content + choice = MagicMock() + choice.message = msg + choice.finish_reason = "stop" + response = MagicMock() + response.choices = [choice] + return response + + +# ── /health ────────────────────────────────────────────────────────────────── + +def test_health(): + r = client.get("/health") + assert r.status_code == 200 + assert r.json() == {"status": "ok"} + + +# ── /analyze ───────────────────────────────────────────────────────────────── + +def test_analyze_happy_path(): + ai_json = json.dumps({ + "insights": "Điểm tốt, cần cải thiện hình học.", + "weak_topics": ["geometry"], + "recommendations": ["Ôn tập hình học", "Làm thêm đề thử"], + }) + with patch("app.agent.exam_analyzer.call_with_retry", new_callable=AsyncMock) as mock_retry: + mock_retry.return_value = _mock_completion(ai_json) + r = client.post("/analyze", json={"result": MOCK_RESULT, "history": []}) + assert r.status_code == 200 + body = r.json() + assert "insights" in body + assert "weak_topics" in body + assert "recommendations" in body + + +def test_analyze_bad_json_returns_502(): + with patch("app.agent.exam_analyzer.call_with_retry", new_callable=AsyncMock) as mock_retry: + mock_retry.return_value = _mock_completion("not json at all") + r = client.post("/analyze", json={"result": MOCK_RESULT, "history": []}) + assert r.status_code == 502 + + +# ── /hint ───────────────────────────────────────────────────────────────────── + +def test_hint_happy_path(): + ai_json = json.dumps({"hint": "Hãy suy nghĩ về tính đối xứng.", "difficulty_note": ""}) + with patch("app.agent.hint_generator.call_with_retry", new_callable=AsyncMock) as mock_retry: + mock_retry.return_value = _mock_completion(ai_json) + r = client.post("/hint", json={"question": MOCK_QUESTION, "attempt_count": 1}) + assert r.status_code == 200 + assert "hint" in r.json() + + +def test_hint_bad_json_returns_502(): + with patch("app.agent.hint_generator.call_with_retry", new_callable=AsyncMock) as mock_retry: + mock_retry.return_value = _mock_completion("oops") + r = client.post("/hint", json={"question": MOCK_QUESTION, "attempt_count": 1}) + assert r.status_code == 502 + + +# ── /study-plan ─────────────────────────────────────────────────────────────── + +def test_study_plan_happy_path(): + ai_json = json.dumps({ + "plan": "## Kế hoạch\n- Ôn tập đại số", + "weekly_schedule": [ + {"week": 1, "focus": "Đại số", "tasks": ["Bài tập 1"]}, + ], + }) + with patch("app.agent.study_planner.call_with_retry", new_callable=AsyncMock) as mock_retry: + mock_retry.return_value = _mock_completion(ai_json) + r = client.post("/study-plan", json={"result": MOCK_RESULT, "history": []}) + assert r.status_code == 200 + body = r.json() + assert "plan" in body + assert "weekly_schedule" in body + assert len(body["weekly_schedule"]) >= 1 + + +def test_study_plan_llm_error_returns_default(): + with patch("app.agent.study_planner.call_with_retry", new_callable=AsyncMock) as mock_retry: + mock_retry.side_effect = Exception("network error") + r = client.post("/study-plan", json={"result": MOCK_RESULT, "history": []}) + assert r.status_code == 200 + body = r.json() + assert len(body["weekly_schedule"]) == 4 + + +# ── Rate limiter ────────────────────────────────────────────────────────────── + +def test_rate_limit_triggered(monkeypatch): + from app.middleware import RateLimitMiddleware, _LIMIT + monkeypatch.setattr("app.middleware._LIMIT", 2) + + ai_json = json.dumps({"hint": "test", "difficulty_note": ""}) + with patch("app.agent.hint_generator.call_with_retry", new_callable=AsyncMock) as mock_retry: + mock_retry.return_value = _mock_completion(ai_json) + for _ in range(2): + client.post("/hint", json={"question": MOCK_QUESTION, "attempt_count": 1}) + r = client.post("/hint", json={"question": MOCK_QUESTION, "attempt_count": 1}) + assert r.status_code == 429 diff --git a/backend/tests/test_auth.py b/backend/tests/test_auth.py new file mode 100644 index 0000000000000000000000000000000000000000..4600f2adc6330962f8dd44b81099d3086307970a --- /dev/null +++ b/backend/tests/test_auth.py @@ -0,0 +1,57 @@ +"""Unit tests for auth.py — no network calls.""" +import asyncio +from unittest.mock import AsyncMock, patch, MagicMock +import pytest +import jwt as pyjwt + +from app.auth import create_jwt, decode_jwt + + +def test_create_jwt_structure(): + token = create_jwt(42) + assert isinstance(token, str) + from app.config import get_settings + payload = pyjwt.decode(token, get_settings().jwt_secret, algorithms=["HS256"]) + assert payload["sub"] == "42" + assert "exp" in payload + assert "iat" in payload + + +def test_decode_jwt_roundtrip(): + token = create_jwt(7) + payload = decode_jwt(token) + assert payload["sub"] == "7" + + +def test_decode_jwt_invalid(): + with pytest.raises(pyjwt.InvalidTokenError): + decode_jwt("garbage.token.value") + + +def test_decode_jwt_tampered(): + token = create_jwt(1) + parts = token.split(".") + tampered = parts[0] + "." + parts[1] + ".invalidsignature" + with pytest.raises(pyjwt.InvalidTokenError): + decode_jwt(tampered) + + +@pytest.mark.asyncio +async def test_verify_google_token_success(): + from app.auth import verify_google_token + fake_payload = {"sub": "12345", "email": "user@example.com", "name": "Test User"} + with patch("app.auth.asyncio.to_thread", new=AsyncMock(return_value=fake_payload)): + result = await verify_google_token("fake-id-token") + assert result["sub"] == "12345" + + +@pytest.mark.asyncio +async def test_verify_google_token_invalid(): + from app.auth import verify_google_token + import google.auth.exceptions + with patch( + "app.auth.asyncio.to_thread", + new=AsyncMock(side_effect=google.auth.exceptions.GoogleAuthError("bad token")), + ): + with pytest.raises(ValueError, match="Invalid or expired Google token"): + await verify_google_token("bad-token") diff --git a/backend/tests/test_auth_endpoint.py b/backend/tests/test_auth_endpoint.py new file mode 100644 index 0000000000000000000000000000000000000000..101fa3ea2f3c96aeccb56618102e1b2a126d1df2 --- /dev/null +++ b/backend/tests/test_auth_endpoint.py @@ -0,0 +1,93 @@ +"""Integration-style tests for /auth/google and get_current_user — DB + google mocked.""" +import pytest +from unittest.mock import AsyncMock, patch, MagicMock +from httpx import AsyncClient, ASGITransport +import jwt as pyjwt + +from app.auth import create_jwt +from app.config import get_settings + + +@pytest.fixture +def fake_google_payload(): + return { + "sub": "google-sub-123", + "email": "user@example.com", + "name": "Test User", + "picture": "https://example.com/avatar.jpg", + } + + +@pytest.fixture +def mock_pool(): + pool = MagicMock() + row = {"id": 1, "email": "user@example.com", "display_name": "Test User", "avatar_url": "https://example.com/avatar.jpg"} + pool.fetchrow = AsyncMock(return_value=row) + return pool + + +@pytest.fixture +def app_with_pool(mock_pool): + from app.main import app + app.state.pool = mock_pool + return app + + +@pytest.mark.asyncio +async def test_auth_google_success(app_with_pool, fake_google_payload): + with patch("app.main.verify_google_token", new=AsyncMock(return_value=fake_google_payload)): + async with AsyncClient(transport=ASGITransport(app=app_with_pool), base_url="http://test") as client: + resp = await client.post("/auth/google", json={"id_token": "valid-token"}) + + assert resp.status_code == 200 + data = resp.json() + assert "access_token" in data + assert data["user"]["email"] == "user@example.com" + settings = get_settings() + payload = pyjwt.decode(data["access_token"], settings.jwt_secret, algorithms=["HS256"]) + assert payload["sub"] == "1" + + +@pytest.mark.asyncio +async def test_auth_google_invalid_token(app_with_pool): + with patch("app.main.verify_google_token", new=AsyncMock(side_effect=ValueError("bad token"))): + async with AsyncClient(transport=ASGITransport(app=app_with_pool), base_url="http://test") as client: + resp = await client.post("/auth/google", json={"id_token": "bad-token"}) + + assert resp.status_code == 401 + assert resp.json()["detail"] == "Invalid or expired Google token" + + +@pytest.mark.asyncio +async def test_get_me_no_auth(app_with_pool): + async with AsyncClient(transport=ASGITransport(app=app_with_pool), base_url="http://test") as client: + resp = await client.get("/users/me") + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_get_me_expired_token(app_with_pool): + import time + settings = get_settings() + expired = pyjwt.encode( + {"sub": "1", "iat": int(time.time()) - 100, "exp": int(time.time()) - 1}, + settings.jwt_secret, + algorithm="HS256", + ) + async with AsyncClient(transport=ASGITransport(app=app_with_pool), base_url="http://test") as client: + resp = await client.get("/users/me", headers={"Authorization": f"Bearer {expired}"}) + assert resp.status_code == 401 + assert "expired" in resp.json()["detail"].lower() + + +@pytest.mark.asyncio +async def test_get_me_valid_token(app_with_pool, mock_pool): + token = create_jwt(1) + mock_pool.fetchrow = AsyncMock(return_value={ + "id": 1, "email": "user@example.com", + "display_name": "Test User", "avatar_url": "https://example.com/avatar.jpg" + }) + async with AsyncClient(transport=ASGITransport(app=app_with_pool), base_url="http://test") as client: + resp = await client.get("/users/me", headers={"Authorization": f"Bearer {token}"}) + assert resp.status_code == 200 + assert resp.json()["email"] == "user@example.com" diff --git a/backend/tests/test_math_wiki.py b/backend/tests/test_math_wiki.py new file mode 100644 index 0000000000000000000000000000000000000000..ebb092f269ad99072d0f7c77407bca32e3719c40 --- /dev/null +++ b/backend/tests/test_math_wiki.py @@ -0,0 +1,529 @@ +"""Tests for math wiki system — storage, agents, pipeline, routes.""" +import json +from unittest.mock import AsyncMock, MagicMock, patch +import pytest +from fastapi.testclient import TestClient + +from app.main import app + +client = TestClient(app) + + +def _mock_completion(content: str): + msg = MagicMock() + msg.content = content + choice = MagicMock() + choice.message = msg + choice.finish_reason = "stop" + response = MagicMock() + response.choices = [choice] + return response + + +# ── test_bm25 ───────────────────────────────────────────────────────────────── + +def test_bm25_empty_returns_empty(): + from app.math_wiki.storage.bm25 import build_bm25_index, query_bm25 + idx, id_map = build_bm25_index([]) + result = query_bm25(idx, id_map, "algebra", top_k=5) + assert result == [] + + +def test_bm25_ranking(): + from app.math_wiki.schemas import WikiUnit + from app.math_wiki.storage.bm25 import build_bm25_index, query_bm25 + + units = [ + WikiUnit(id="u1", type="concept", topic="algebra", subtopic="x", + content="linear equation solving", problem_ids=[]), + WikiUnit(id="u2", type="concept", topic="geometry", subtopic="y", + content="triangle area formula", problem_ids=[]), + WikiUnit(id="u3", type="concept", topic="algebra", subtopic="z", + content="quadratic equation roots", problem_ids=[]), + WikiUnit(id="u4", type="concept", topic="algebra", subtopic="a", + content="linear algebra matrix", problem_ids=[]), + WikiUnit(id="u5", type="concept", topic="geometry", subtopic="b", + content="circle circumference", problem_ids=[]), + ] + idx, id_map = build_bm25_index(units) + result = query_bm25(idx, id_map, "linear equation", top_k=2) + assert "u1" in result + + +# ── test_retriever ──────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_retriever_no_pool_returns_empty(): + from app.math_wiki.storage.retriever import vector_retrieve + result = await vector_retrieve(None, "algebra") + assert result == [] + + +@pytest.mark.asyncio +async def test_retriever_with_pool(): + from app.math_wiki.storage.retriever import vector_retrieve + mock_pool = MagicMock() + with patch("app.math_wiki.storage.retriever.query_pgvector", new_callable=AsyncMock, + return_value=["u1", "u2"]) as mock_qpg: + result = await vector_retrieve(mock_pool, "algebra", top_k=5) + assert result == ["u1", "u2"] + mock_qpg.assert_called_once_with(mock_pool, "algebra", top_k=5) + + +# ── test_classifier ─────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_classifier_valid_label(): + from app.math_wiki.agents.classifier import classify_problem + with patch("app.math_wiki.agents.classifier.call_with_retry", new_callable=AsyncMock) as mock: + mock.return_value = _mock_completion('{"label": "algebra"}') + result = await classify_problem(MagicMock(), "solve 2x+3=7") + assert result == "algebra" + + +@pytest.mark.asyncio +async def test_classifier_invalid_label_falls_back(): + from app.math_wiki.agents.classifier import classify_problem + with patch("app.math_wiki.agents.classifier.call_with_retry", new_callable=AsyncMock) as mock: + mock.return_value = _mock_completion('{"label": "unknown_topic"}') + result = await classify_problem(MagicMock(), "test problem") + assert result == "algebra" + + +# ── test_reranker ───────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_reranker_returns_top5(): + from app.math_wiki.schemas import WikiUnit + from app.math_wiki.agents.reranker import rerank + + candidates = [ + WikiUnit(id=f"u{i}", type="concept", topic="algebra", subtopic="x", + content=f"content {i}", problem_ids=[]) + for i in range(10) + ] + top_ids = [f"u{i}" for i in range(5)] + with patch("app.math_wiki.agents.reranker.call_with_retry", new_callable=AsyncMock) as mock: + mock.return_value = _mock_completion(json.dumps({"top_ids": top_ids})) + result = await rerank(MagicMock(), "algebra query", candidates) + assert len(result) <= 5 + assert all(uid in {c.id for c in candidates} for uid in result) + + +@pytest.mark.asyncio +async def test_reranker_hallucinated_id_filtered(): + from app.math_wiki.schemas import WikiUnit + from app.math_wiki.agents.reranker import rerank + + candidates = [ + WikiUnit(id="u1", type="concept", topic="algebra", subtopic="x", + content="content", problem_ids=[]) + ] + with patch("app.math_wiki.agents.reranker.call_with_retry", new_callable=AsyncMock) as mock: + mock.return_value = _mock_completion(json.dumps({"top_ids": ["hallucinated_id"]})) + result = await rerank(MagicMock(), "query", candidates) + assert result == [] + + +# ── test_solver ─────────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_solver_valid(): + from app.math_wiki.schemas import WikiUnit + from app.math_wiki.agents.solver import solve + + context = [WikiUnit(id="u1", type="concept", topic="algebra", subtopic="x", + content="linear equations", problem_ids=[])] + output_data = { + "problem_type": "linear_equation", + "used_knowledge_ids": ["u1"], + "steps": ["step1", "step2"], + "final_answer": "x=2", + "confidence": "high", + } + with patch("app.math_wiki.agents.solver.call_with_retry", new_callable=AsyncMock) as mock: + mock.return_value = _mock_completion(json.dumps(output_data)) + result = await solve(MagicMock(), "solve 2x=4", context) + assert result.final_answer == "x=2" + assert result.confidence == "high" + + +@pytest.mark.asyncio +async def test_solver_insufficient_knowledge(): + from app.math_wiki.schemas import WikiUnit + from app.math_wiki.agents.solver import solve + from app.math_wiki.utils import InsufficientKnowledgeError + + context = [WikiUnit(id="u1", type="concept", topic="algebra", subtopic="x", + content="content", problem_ids=[])] + with patch("app.math_wiki.agents.solver.call_with_retry", new_callable=AsyncMock) as mock: + mock.return_value = _mock_completion('{"result": "INSUFFICIENT_KNOWLEDGE"}') + with pytest.raises(InsufficientKnowledgeError): + await solve(MagicMock(), "impossible problem", context) + + +@pytest.mark.asyncio +async def test_solver_hallucinated_knowledge_id_filtered(): + from app.math_wiki.schemas import WikiUnit + from app.math_wiki.agents.solver import solve + + context = [WikiUnit(id="u1", type="concept", topic="algebra", subtopic="x", + content="content", problem_ids=[])] + output_data = { + "problem_type": "linear_equation", + "used_knowledge_ids": ["hallucinated_id"], + "steps": ["step1"], + "final_answer": "x=2", + "confidence": "high", + } + with patch("app.math_wiki.agents.solver.call_with_retry", new_callable=AsyncMock) as mock: + mock.return_value = _mock_completion(json.dumps(output_data)) + result = await solve(MagicMock(), "solve 2x=4", context) + assert result.used_knowledge_ids == [] + + +# ── test_validator ──────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_validator_valid(): + from app.math_wiki.schemas import SolverOutput + from app.math_wiki.agents.validator import validate + + solver_out = SolverOutput( + problem_type="linear", used_knowledge_ids=[], steps=["step1"], + final_answer="x=2", confidence="high" + ) + with patch("app.math_wiki.agents.validator.call_with_retry", new_callable=AsyncMock) as mock: + mock.return_value = _mock_completion('{"valid": true, "issues": []}') + result = await validate(MagicMock(), solver_out, []) + assert result.valid is True + assert result.issues == [] + + +@pytest.mark.asyncio +async def test_validator_invalid(): + from app.math_wiki.schemas import SolverOutput + from app.math_wiki.agents.validator import validate + + solver_out = SolverOutput( + problem_type="linear", used_knowledge_ids=[], steps=["step1"], + final_answer="wrong", confidence="low" + ) + with patch("app.math_wiki.agents.validator.call_with_retry", new_callable=AsyncMock) as mock: + mock.return_value = _mock_completion('{"valid": false, "issues": ["Wrong answer"]}') + result = await validate(MagicMock(), solver_out, []) + assert result.valid is False + assert len(result.issues) > 0 + + +# ── test_pipeline ───────────────────────────────────────────────────────────── + +@pytest.mark.asyncio +async def test_pipeline_end_to_end(): + from app.math_wiki.pipeline import run_pipeline + import app.math_wiki.pipeline as pipeline_mod + + pipeline_mod._bm25_ready_event.set() + + with patch("app.math_wiki.pipeline.classify_problem", new_callable=AsyncMock, return_value="algebra"), \ + patch("app.math_wiki.pipeline._retrieve_rerank_context", new_callable=AsyncMock, return_value=([], [])), \ + patch("app.math_wiki.pipeline.solve", new_callable=AsyncMock) as mock_solve, \ + patch("app.math_wiki.pipeline.validate", new_callable=AsyncMock) as mock_validate, \ + patch("app.math_wiki.pipeline.generate_figure", new_callable=AsyncMock, return_value=None), \ + patch("app.math_wiki.pipeline.log_solution", new_callable=AsyncMock): + + from app.math_wiki.schemas import SolverOutput, ValidationResult + mock_solve.return_value = SolverOutput( + problem_type="linear", used_knowledge_ids=[], + steps=["step1"], final_answer="x=2", confidence="medium" + ) + mock_validate.return_value = ValidationResult(valid=True, issues=[]) + + result = await run_pipeline(MagicMock(), MagicMock(), "solve 2x=4") + + assert "label" in result + assert "answer" in result + assert "validation" in result + assert result["label"] == "algebra" + + +@pytest.mark.asyncio +async def test_pipeline_insufficient_knowledge(): + from app.math_wiki.pipeline import run_pipeline + from app.math_wiki.utils import InsufficientKnowledgeError + import app.math_wiki.pipeline as pipeline_mod + + pipeline_mod._bm25_ready_event.set() + + with patch("app.math_wiki.pipeline.classify_problem", new_callable=AsyncMock, return_value="algebra"), \ + patch("app.math_wiki.pipeline._retrieve_rerank_context", new_callable=AsyncMock, return_value=([], [])), \ + patch("app.math_wiki.pipeline.solve", new_callable=AsyncMock, side_effect=InsufficientKnowledgeError()): + + result = await run_pipeline(MagicMock(), MagicMock(), "impossible problem") + + assert result == {"error": "INSUFFICIENT_KNOWLEDGE"} + + +# ── test_routes ──────────────────────────────────────────────────────────────── + +def test_math_ingest_happy_path(): + from app.math_wiki.schemas import IngestOutput, Problem, WikiUnit + + output = IngestOutput( + problems=[Problem( + problem_id="p1", problem_text="Solve 2x=4", topic="algebra", + subtopic="linear", difficulty="easy", problem_type="equation" + )], + wiki_units=[ + WikiUnit(id="u1", type="concept", topic="algebra", subtopic="linear", + content="linear equations", problem_ids=["p1"]), + WikiUnit(id="u2", type="procedure", topic="algebra", subtopic="linear", + content="solving steps", problem_ids=["p1"]), + ], + ) + with patch("app.math_wiki.agents.ingest.ingest_exam", new_callable=AsyncMock, return_value=output): + r = client.post("/math-ingest", json={"text": "Solve 2x=4"}) + assert r.status_code == 200 + body = r.json() + assert "problems" in body + assert "wiki_units" in body + + +def test_math_solve_happy_path(): + expected = { + "label": "algebra", + "answer": {"problem_type": "linear", "used_knowledge_ids": [], + "steps": ["step1"], "final_answer": "x=2", "confidence": "high"}, + "validation": {"valid": True, "issues": []}, + "retrieved_ids": [], + } + with patch("app.math_wiki.pipeline.run_pipeline", new_callable=AsyncMock, return_value=expected): + r = client.post("/math-solve", json={"question": "solve 2x=4"}) + assert r.status_code == 200 + body = r.json() + assert body["label"] == "algebra" + + +def test_math_solve_insufficient_knowledge(): + with patch("app.math_wiki.pipeline.run_pipeline", new_callable=AsyncMock, + return_value={"error": "INSUFFICIENT_KNOWLEDGE"}): + r = client.post("/math-solve", json={"question": "impossible"}) + assert r.status_code == 200 + assert r.json()["error"] == "INSUFFICIENT_KNOWLEDGE" + + +# ── test_stats ───────────────────────────────────────────────────────────────── + +def test_math_stats_empty(): + with patch("app.math_wiki.storage.pg_db.count_problems", new_callable=AsyncMock, return_value=0), \ + patch("app.math_wiki.storage.pg_db.count_wiki_units", new_callable=AsyncMock, return_value=0), \ + patch("app.math_wiki.storage.pg_db.count_wiki_units_by_topic", new_callable=AsyncMock, return_value={}): + r = client.get("/math-stats") + assert r.status_code == 200 + body = r.json() + assert body["problems"] == 0 + assert body["wiki_units"] == 0 + assert body["topics"] == {} + + +def test_math_stats_with_data(): + with patch("app.math_wiki.storage.pg_db.count_problems", new_callable=AsyncMock, return_value=10), \ + patch("app.math_wiki.storage.pg_db.count_wiki_units", new_callable=AsyncMock, return_value=25), \ + patch("app.math_wiki.storage.pg_db.count_wiki_units_by_topic", new_callable=AsyncMock, + return_value={"algebra": 15, "geometry": 10}): + r = client.get("/math-stats") + assert r.status_code == 200 + body = r.json() + assert body["problems"] == 10 + assert body["wiki_units"] == 25 + assert body["topics"]["algebra"] == 15 + + +def test_math_upload_txt(): + from app.math_wiki.schemas import IngestOutput, Problem, WikiUnit + + output = IngestOutput( + problems=[Problem( + problem_id="p1", problem_text="Solve x=1", topic="algebra", + subtopic="linear", difficulty="easy", problem_type="equation" + )], + wiki_units=[ + WikiUnit(id="u1", type="concept", topic="algebra", subtopic="linear", + content="linear equations", problem_ids=["p1"]), + WikiUnit(id="u2", type="procedure", topic="algebra", subtopic="linear", + content="steps", problem_ids=["p1"]), + ], + ) + with patch("app.math_wiki.agents.ingest.ingest_exam", new_callable=AsyncMock, return_value=output): + r = client.post("/math-upload", files={"file": ("test.txt", b"Cau 1. x = 1?", "text/plain")}) + assert r.status_code == 200 + body = r.json() + assert "chunks_ingested" in body + assert "problems" in body + assert "wiki_units" in body + + +def test_math_upload_too_large(): + big = b"x" * (10 * 1024 * 1024 + 1) + r = client.post("/math-upload", files={"file": ("big.txt", big, "text/plain")}) + assert r.status_code == 413 + + +# ── test_math_ocr ────────────────────────────────────────────────────────────── + +def _ocr_auth_override(): + """Minimal auth stub for /math-ocr — endpoint requires a logged-in user.""" + m = MagicMock() + m.user_id = "test-uid" + return m + + +def test_math_ocr_success(): + from app.dependencies import get_current_user + app.dependency_overrides[get_current_user] = _ocr_auth_override + try: + with patch("app.math_wiki.agents.ocr.extract_math_from_image", + new_callable=AsyncMock, return_value="x^2 + 1 = 0"): + r = client.post("/math-ocr", files={"file": ("test.jpg", b"fakejpeg", "image/jpeg")}) + assert r.status_code == 200 + assert r.json()["text"] == "x^2 + 1 = 0" + finally: + app.dependency_overrides.pop(get_current_user, None) + + +def test_math_ocr_too_large(): + from app.dependencies import get_current_user + app.dependency_overrides[get_current_user] = _ocr_auth_override + try: + big = b"x" * (5 * 1024 * 1024 + 1) + r = client.post("/math-ocr", files={"file": ("big.jpg", big, "image/jpeg")}) + assert r.status_code == 413 + finally: + app.dependency_overrides.pop(get_current_user, None) + + +def test_math_ocr_unsupported_type(): + from app.dependencies import get_current_user + app.dependency_overrides[get_current_user] = _ocr_auth_override + try: + r = client.post("/math-ocr", files={"file": ("malware.exe", b"MZ", "application/octet-stream")}) + assert r.status_code == 415 + finally: + app.dependency_overrides.pop(get_current_user, None) + + +def test_math_ocr_no_image_response_raises_502(): + """Router that strips image content causes model to reply 'no image' — must surface as 502.""" + from app.dependencies import get_current_user + app.dependency_overrides[get_current_user] = _ocr_auth_override + try: + no_image_reply = "Bạn chưa đính kèm hình ảnh nào. Vui lòng gửi hình ảnh để tôi có thể trích xuất nội dung toán học." + with patch("app.math_wiki.agents.ocr.extract_math_from_image", + new_callable=AsyncMock, side_effect=ValueError(no_image_reply)): + r = client.post("/math-ocr", files={"file": ("test.jpg", b"fakejpeg", "image/jpeg")}) + assert r.status_code == 502 + finally: + app.dependency_overrides.pop(get_current_user, None) + + +def test_math_ocr_vision_unsupported_detected(): + """extract_math_from_image raises ValueError when model says it sees no image.""" + import asyncio + from unittest.mock import AsyncMock, patch + from app.math_wiki.agents.ocr import extract_math_from_image + + no_image_text = "Tôi không thấy hình ảnh nào được đính kèm trong tin nhắn của bạn." + mock_client = MagicMock() + mock_client.chat = MagicMock() + mock_client.chat.completions = MagicMock() + mock_client.chat.completions.create = AsyncMock(return_value=_mock_completion(no_image_text)) + + with pytest.raises(ValueError, match="Vision"): + asyncio.get_event_loop().run_until_complete( + extract_math_from_image(mock_client, b"fakejpeg", "image/jpeg") + ) + + +def test_reviewer_inconsistent_response_retries(): + """Reviewer retries once when model returns non-correct verdict with empty errors+feedback.""" + import asyncio + from app.math_wiki.agents.reviewer import review_solution + from app.math_wiki.schemas import WikiUnit + + empty_verdict = json.dumps({"verdict": "incorrect", "score": "0/10", + "correct_steps": [], "errors": [], "feedback": "", + "correct_approach": ""}) + good_verdict = json.dumps({"verdict": "correct", "score": "9/10", + "correct_steps": ["(x-2)(x-3)=0", "x=2 or x=3"], + "errors": [], "feedback": "Đúng rồi.", "correct_approach": ""}) + + mock_client = MagicMock() + mock_client.chat = MagicMock() + mock_client.chat.completions = MagicMock() + mock_client.chat.completions.create = AsyncMock( + side_effect=[_mock_completion(empty_verdict), _mock_completion(good_verdict)] + ) + + result = asyncio.get_event_loop().run_until_complete( + review_solution(mock_client, "x^2 - 5x + 6 = 0", "(x-2)(x-3)=0 nên x=2 hoặc x=3", []) + ) + assert result.verdict == "correct" + assert result.score == "9/10" + assert mock_client.chat.completions.create.call_count == 2 + + +def test_validator_parse_error_hides_from_ui(): + """JSON parse failure in validator must not surface 'validation parse error' to users.""" + import asyncio + from app.math_wiki.agents.validator import validate + from app.math_wiki.agents.solver import SolverOutput + + bad_json = "```json\n{invalid json\n```" + mock_client = MagicMock() + mock_client.chat = MagicMock() + mock_client.chat.completions = MagicMock() + mock_client.chat.completions.create = AsyncMock(return_value=_mock_completion(bad_json)) + + solver_out = SolverOutput( + problem_type="algebra", + used_knowledge_ids=[], + steps=["x=2 or x=3"], + final_answer="x=2 or x=3", + confidence="high", + ) + result = asyncio.get_event_loop().run_until_complete( + validate(mock_client, solver_out, [], problem_text="x^2 - 5x + 6 = 0") + ) + assert result.valid is False + assert result.issues == [] + + +# ── test_bge_m3 ─────────────────────────────────────────────────────────────── + +def test_embed_dim(): + import numpy as np + mock_model = MagicMock() + mock_model.encode.return_value = {"dense_vecs": np.zeros((1, 1024), dtype=np.float32)} + + with patch("app.math_wiki.storage.vectors._local_model", mock_model): + from app.math_wiki.storage.vectors import embed_texts + result = embed_texts(["test"]) + assert len(result) == 1 + assert len(result[0]) == 1024 + + +def test_prefix_distinction(): + import numpy as np + mock_model = MagicMock() + + def _fake_encode(texts, **kwargs): + val = 1.0 if texts[0].startswith("query:") else 0.0 + return {"dense_vecs": np.full((len(texts), 1024), val, dtype=np.float32)} + + mock_model.encode.side_effect = _fake_encode + + with patch("app.math_wiki.storage.vectors._local_model", mock_model): + from app.math_wiki.storage.vectors import embed_texts + q_vec = embed_texts(["x"], prefix="query")[0] + p_vec = embed_texts(["x"], prefix="passage")[0] + assert q_vec[0] != p_vec[0] diff --git a/backend/tests/test_sympy_verifier.py b/backend/tests/test_sympy_verifier.py new file mode 100644 index 0000000000000000000000000000000000000000..88f3d6da65a93802581c92f6d4bf418ff698b817 --- /dev/null +++ b/backend/tests/test_sympy_verifier.py @@ -0,0 +1,138 @@ +"""Tests for the SymPy symbolic verifier (T10).""" +import pytest +from app.math_wiki.agents.sympy_verifier import sympy_verify + + +# ── skip cases ──────────────────────────────────────────────────────────────── + +@pytest.mark.parametrize("problem_type", [ + "proof", "chứng minh", "geometry", "hình học", + "combinatorics", "tổ hợp", "statistics", "thống kê", + "probability", "xác suất", "number_theory", "số học", +]) +def test_skip_types_return_inconclusive(problem_type): + valid, issues = sympy_verify("x = 2", "x = 3", problem_type=problem_type) + assert valid is None + assert issues == [] + + +# ── equation verification ───────────────────────────────────────────────────── + +def test_correct_single_root(): + valid, issues = sympy_verify( + problem_text="Giải phương trình x^2 - 4 = 0", + final_answer="x = 2", + ) + assert valid is True + assert issues == [] + + +def test_correct_multi_root_hoac(): + valid, issues = sympy_verify( + problem_text="Giải x^2 - 5*x + 6 = 0", + final_answer="x = 2 hoặc x = 3", + ) + assert valid is True + assert issues == [] + + +def test_wrong_root_flagged(): + valid, issues = sympy_verify( + problem_text="Giải x^2 - 4 = 0", + final_answer="x = 3", + ) + assert valid is False + assert len(issues) > 0 + + +def test_correct_negative_root(): + valid, issues = sympy_verify( + problem_text="Giải x + 5 = 0", + final_answer="x = -5", + ) + assert valid is True + + +def test_correct_fraction_root(): + valid, issues = sympy_verify( + problem_text="Giải 2*x - 1 = 0", + final_answer="x = 1/2", + ) + assert valid is True + + +# ── system verification ─────────────────────────────────────────────────────── + +def test_correct_system(): + valid, issues = sympy_verify( + problem_text="Giải hệ: x + y = 5 và x - y = 1", + final_answer="x = 3, y = 2", + ) + assert valid is True + assert issues == [] + + +def test_wrong_system_flagged(): + valid, issues = sympy_verify( + problem_text="Giải hệ: x + y = 5 và x - y = 1", + final_answer="x = 1, y = 1", + ) + assert valid is False + assert len(issues) > 0 + + +# ── ODE verification ────────────────────────────────────────────────────────── + +def test_ode_correct_solution(): + valid, issues = sympy_verify( + problem_text="Giải phương trình vi phân y' = y", + final_answer="y = C1*exp(x)", + ) + # Should be True or inconclusive (ODE string parsing may not always succeed) + assert valid in (True, None) + + +def test_ode_wrong_solution(): + valid, issues = sympy_verify( + problem_text="Giải phương trình vi phân y' = y", + final_answer="y = C1*x", + problem_type="vi phân", + ) + # Wrong solution: derivative is C1 but y = C1*x, so residual = C1*x - C1 ≠ 0 + # SymPy may return False or None (inconclusive on parse failure) + assert valid in (False, None) + + +# ── never-raises contract ───────────────────────────────────────────────────── + +def test_garbage_problem_text_is_inconclusive(): + valid, issues = sympy_verify( + problem_text="!!!@@@###", + final_answer="x = 2", + ) + assert valid is None + assert issues == [] + + +def test_garbage_answer_is_inconclusive(): + valid, issues = sympy_verify( + problem_text="Giải x^2 - 4 = 0", + final_answer="blah blah no equation", + ) + assert valid is None + assert issues == [] + + +def test_empty_inputs_inconclusive(): + valid, issues = sympy_verify("", "") + assert valid is None + assert issues == [] + + +def test_no_equation_in_problem_inconclusive(): + valid, issues = sympy_verify( + problem_text="Hãy tính diện tích hình thang", + final_answer="x = 5", + ) + # No parseable equation — should be inconclusive (None) or True if accidentally parses + assert valid in (None, True) diff --git a/backend/tests/test_user_endpoints.py b/backend/tests/test_user_endpoints.py new file mode 100644 index 0000000000000000000000000000000000000000..91a87ca273e014c89728b3ca53ef6071b7fe21b5 --- /dev/null +++ b/backend/tests/test_user_endpoints.py @@ -0,0 +1,81 @@ +"""Tests for /users/me/history endpoints.""" +import json +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from httpx import AsyncClient, ASGITransport +from datetime import datetime, timezone + +from app.auth import create_jwt + + +@pytest.fixture +def mock_pool(): + pool = MagicMock() + pool.acquire = MagicMock() + conn = AsyncMock() + conn.__aenter__ = AsyncMock(return_value=conn) + conn.__aexit__ = AsyncMock(return_value=False) + pool.acquire.return_value = conn + pool.fetchrow = AsyncMock() + pool.fetch = AsyncMock(return_value=[]) + return pool, conn + + +@pytest.fixture +def app_with_pool(mock_pool): + pool, _ = mock_pool + from app.main import app + app.state.pool = pool + return app + + +@pytest.mark.asyncio +async def test_post_history_requires_auth(app_with_pool): + async with AsyncClient(transport=ASGITransport(app=app_with_pool), base_url="http://test") as client: + resp = await client.post("/users/me/history", json=[]) + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_get_history_requires_auth(app_with_pool): + async with AsyncClient(transport=ASGITransport(app=app_with_pool), base_url="http://test") as client: + resp = await client.get("/users/me/history") + assert resp.status_code == 401 + + +@pytest.mark.asyncio +async def test_post_history_idempotent(app_with_pool, mock_pool): + _, conn = mock_pool + token = create_jwt(1) + entries = [{"result_id": "r1", "exam_id": "e1", "score": 0.9, "payload": {"q": 1}, "created_at": None}] + async with AsyncClient(transport=ASGITransport(app=app_with_pool), base_url="http://test") as client: + resp = await client.post( + "/users/me/history", + json=entries, + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 204 + conn.execute.assert_awaited_once() + # ON CONFLICT DO NOTHING — second identical call is safe + call_sql = conn.execute.call_args[0][0] + assert "ON CONFLICT" in call_sql + + +@pytest.mark.asyncio +async def test_get_history_returns_user_rows(app_with_pool, mock_pool): + pool, _ = mock_pool + ts = datetime(2025, 1, 1, tzinfo=timezone.utc) + pool.fetch = AsyncMock(return_value=[ + {"result_id": "r1", "exam_id": "e1", "score": 0.8, "payload": None, "created_at": ts} + ]) + token = create_jwt(1) + async with AsyncClient(transport=ASGITransport(app=app_with_pool), base_url="http://test") as client: + resp = await client.get( + "/users/me/history", + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200 + data = resp.json() + assert len(data) == 1 + assert data[0]["result_id"] == "r1" + assert "2025" in data[0]["created_at"] diff --git a/backend/tests/test_wiki_math_system.py b/backend/tests/test_wiki_math_system.py new file mode 100644 index 0000000000000000000000000000000000000000..3eba84d761d338b5d1dc2ce82d6c4725230c4e74 --- /dev/null +++ b/backend/tests/test_wiki_math_system.py @@ -0,0 +1,433 @@ +""" +Unified math wiki capability test suite (Phase 0). + +Evaluates the live pipeline against 70 structured problems across 9 categories: + + A Bloom's Taxonomy (L1–L6, 3 tests each) — measures reasoning depth + B THPT Domain Parity (6 domains × 3 difficulties) — checks curriculum coverage + C Retrieval Quality (10 tests) — skipped when pool=None (no wiki DB) + D Multi-Domain Reasoning (5 tests) — cross-topic problems + E Proof & Deduction (5 tests) — formal reasoning chains + F Figure & Visual (4 tests) — GeoGebra/SVG generation + G Edge Cases & Adversarial (6 tests) — undefined, impossible, out-of-scope + H Language & Format Quality (4 tests) — Vietnamese + LaTeX discipline + +Tests are marked @pytest.mark.live_ai and require ANTHROPIC_AUTH_TOKEN to be set. +Run with: PYTHONPATH=backend python3 -m pytest backend/tests/test_wiki_math_system.py -v + +A JSON gap report is written to backend/tests/math_wiki_gap_report.json after the session. +""" +import asyncio +import json +import os +import re +import sys +from pathlib import Path +from typing import Any + +import pytest + +# ── fixtures ────────────────────────────────────────────────────────────────── + +def _token_is_real() -> bool: + # Try shell env first; fall back to pydantic-settings (.env file) + token = os.environ.get("ANTHROPIC_AUTH_TOKEN", "") + if not token: + try: + from app.config import get_settings + token = get_settings().anthropic_auth_token + except Exception: + return False + return bool(token) and token not in ("your_token_here", "sk-...") + +@pytest.fixture(scope="session") +def ai_client(): + if not _token_is_real(): + pytest.skip("ANTHROPIC_AUTH_TOKEN not set — skipping live_ai tests") + from openai import AsyncOpenAI + from app.config import get_settings + settings = get_settings() + return AsyncOpenAI( + base_url=settings.anthropic_base_url.rstrip("/") + "/v2", + api_key=settings.anthropic_auth_token, + ) + + +@pytest.fixture(scope="session", autouse=True) +def bm25_ready(): + """Ensure BM25 event is set so pipeline doesn't block waiting for DB load.""" + import app.math_wiki.pipeline as pm + pm._bm25_ready_event.set() + + +@pytest.fixture(scope="session") +def gap_report(): + """Accumulates per-test results; written to disk at session end.""" + results: list[dict] = [] + yield results + _write_gap_report(results) + + +# ── answer checker ──────────────────────────────────────────────────────────── + +_LATEX_STRIP_RE = re.compile(r'[$\\{}]|\\[a-zA-Z]+') +_SPACE_RE = re.compile(r'\s+') + +def _normalize(text: str) -> str: + """Strip LaTeX markup and collapse whitespace for loose comparison.""" + # Convert common LaTeX math symbols to text equivalents BEFORE stripping markup + # so that e.g. \pi → pi survives the strip and can be matched. + text = (text + .replace('\\pi', 'pi').replace('\\alpha', 'alpha').replace('\\beta', 'beta') + .replace('\\sqrt', 'sqrt').replace('\\infty', 'infty').replace('\\frac', 'frac') + .replace('\\cdot', '.').replace('\\times', 'x').replace('\\pm', '+-') + ) + text = _LATEX_STRIP_RE.sub(' ', text) + text = _SPACE_RE.sub(' ', text).strip().lower() + # Normalize common Vietnamese number representations and Unicode symbols + text = text.replace(',', '.').replace('−', '-').replace('π', 'pi') + text = text.replace('±', '±') # preserve ± as-is for matching + return text + + +def _answer_matches(final_answer: str, expected: str | list[str]) -> bool: + """Return True if any expected phrase (after normalization) appears in final_answer.""" + if not expected: + return True + norm_actual = _normalize(final_answer) + candidates = [expected] if isinstance(expected, str) else expected + for cand in candidates: + if _normalize(cand) in norm_actual: + return True + return False + + +# ── language / format checkers ──────────────────────────────────────────────── + +_ENGLISH_WORD_RE = re.compile( + r'\b(the|and|so|or|we|have|that|this|step|since|because|where|let|note|' + r'first|then|finally|therefore|thus|hence|but|also|if|for|is|are|was|were|' + r'with|from|of|to|in|on|at|by|as)\b', + re.IGNORECASE +) +_LATEX_MATH_RE = re.compile(r'\$[^$]+\$') +_UNICODE_MATH_RE = re.compile(r'[∫∑∏√±×÷≠≤≥∞→←↔∈∉⊂⊃∪∩∀∃]') +_BUOC_RE = re.compile(r'Bước\s+\d+\s*:', re.UNICODE) + + +def _check_language(steps: list[str]) -> list[str]: + """Return list of format violation descriptions (empty = clean).""" + issues: list[str] = [] + joined = ' '.join(steps) + + # Check for leaked English words + eng_matches = _ENGLISH_WORD_RE.findall(joined) + if eng_matches: + issues.append(f"English words leaked: {set(eng_matches)}") + + # Math must be inside $...$; bare Unicode math symbols are a violation + outside_latex = _LATEX_MATH_RE.sub('', joined) + unicode_math = _UNICODE_MATH_RE.findall(outside_latex) + if unicode_math: + issues.append(f"Unicode math outside $...$: {set(unicode_math)}") + + # Each step must start with "Bước N:" + for i, step in enumerate(steps): + if step.strip() and not _BUOC_RE.match(step.strip()): + issues.append(f"Step {i+1} missing 'Bước N:' prefix: {step[:60]!r}") + break # Report only the first violation to keep output clean + + return issues + + +def _final_answer_in_last_step(final_answer: str, steps: list[str]) -> bool: + """Check that the final answer text appears (normalized) in the last step.""" + if not steps or not final_answer: + return False + last = _normalize(steps[-1]) + norm_ans = _normalize(final_answer) + if not norm_ans: + return True + # Direct substring check after normalization (LaTeX stripped from both sides) + return norm_ans in last + + +# ── core test runner ────────────────────────────────────────────────────────── + +def _run_pipeline_sync(client, question: str) -> dict: + """Run run_pipeline with pool=None (no retrieval, LLM-only).""" + import time + from app.math_wiki.pipeline import run_pipeline + + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + try: + return loop.run_until_complete(run_pipeline(None, client, question)) + except Exception as exc: + # Unwrap tenacity RetryError → check root cause + cause = getattr(exc, "__cause__", None) or exc + cause_str = f"{type(cause).__name__}: {cause}" + full_str = f"{type(exc).__name__}: {exc} | cause: {cause_str}" + is_transient = ( + "429" in full_str + or "503" in full_str + or "RateLimitError" in full_str + or "InternalServerError" in full_str + or "cooling down" in full_str + or "auth_unavailable" in full_str + or "rate_limit" in full_str.lower() + or "no auth available" in full_str + ) + if is_transient: + pytest.skip(f"Upstream API unavailable — retry later ({cause_str[:120]})") + raise + finally: + loop.close() + + +def _evaluate(tc: dict, result: dict) -> dict[str, Any]: + """Return a structured evaluation record for one test case.""" + rec: dict[str, Any] = { + "id": tc["id"], + "category": tc["category"], + "bloom_level": tc["bloom_level"], + "topic": tc["topic"], + "difficulty": tc["difficulty"], + "question": tc["question"][:80], + "notes": tc["notes"], + } + + # Pipeline error / insufficient knowledge + if "error" in result: + rec.update({ + "passed": False, + "error": result["error"], + "confidence": None, + "valid": None, + "answer_correct": False, + "language_issues": [], + "figure_generated": False, + }) + return rec + + answer: dict = result.get("answer", {}) + final_ans: str = answer.get("final_answer", "") + steps: list[str] = answer.get("steps", []) + confidence: str = answer.get("confidence", "unknown") + validation: dict = result.get("validation", {}) + is_valid: bool = validation.get("valid", False) + figure = answer.get("figure") + figure_generated = bool(figure and figure.get("data")) + + # Answer correctness + answer_correct = _answer_matches(final_ans, tc["expected_answer"]) + + # Validation alignment + # Only mark valid_ok=False when expected_valid=False (edge-case rejection). + # expected_valid=True is informational — validator JSON parse failures are common. + ev = tc["expected_valid"] + valid_ok = True + if ev is False: + refusal_keywords = [ + "vô nghiệm", "không tồn tại", "không xác định", "undefined", + "ngoài phạm vi", "không hỗ trợ", "vô lý", "mâu thuẫn", "∅" + ] + refusal = any(kw in final_ans.lower() for kw in refusal_keywords) + valid_ok = (not is_valid) or refusal + + # Language / format checks (only meaningful for Vietnamese steps) + lang_issues: list[str] = [] + if steps: + lang_issues = _check_language(steps) + if not _final_answer_in_last_step(final_ans, steps): + lang_issues.append("final_answer not echoed in last step") + + # Figure check + figure_ok = True + if tc["check_figure"]: + figure_ok = figure_generated + + # Format only counts against pass for Category H (explicit format test) + format_ok = (not lang_issues) if tc["category"] == "H" else True + passed = answer_correct and valid_ok and format_ok and figure_ok + + rec.update({ + "passed": passed, + "error": None, + "confidence": confidence, + "valid": is_valid, + "answer_correct": answer_correct, + "valid_ok": valid_ok, + "language_issues": lang_issues, + "figure_generated": figure_generated, + "figure_required": tc["check_figure"], + "final_answer": final_ans[:120], + }) + return rec + + +# ── gap report writer ───────────────────────────────────────────────────────── + +def _write_gap_report(results: list[dict]) -> None: + if not results: + return + + by_cat: dict[str, list[dict]] = {} + for r in results: + by_cat.setdefault(r["category"], []).append(r) + + cat_summary: dict[str, dict] = {} + for cat, recs in sorted(by_cat.items()): + total = len(recs) + passed = sum(1 for r in recs if r.get("passed")) + high_conf_wrong = [ + r for r in recs if r.get("confidence") == "high" and not r.get("answer_correct") + ] + cat_summary[cat] = { + "total": total, + "passed": passed, + "pass_rate": round(passed / total, 2) if total else 0, + "high_confidence_wrong": len(high_conf_wrong), + "failures": [r["id"] for r in recs if not r.get("passed")], + } + + all_passed = sum(v["passed"] for v in cat_summary.values()) + all_total = len(results) + all_high_conf_wrong = sum(v["high_confidence_wrong"] for v in cat_summary.values()) + + # Confidence calibration (Category I) + all_recs_with_conf = [r for r in results if r.get("confidence") and r.get("answer_correct") is not None] + high_conf = [r for r in all_recs_with_conf if r.get("confidence") == "high"] + high_conf_correct_rate = ( + round(sum(1 for r in high_conf if r["answer_correct"]) / len(high_conf), 2) + if high_conf else None + ) + + report = { + "summary": { + "total": all_total, + "passed": all_passed, + "pass_rate": round(all_passed / all_total, 2) if all_total else 0, + "high_confidence_wrong_count": all_high_conf_wrong, + "high_confidence_correct_rate": high_conf_correct_rate, + "calibration_target": 0.85, + "calibration_ok": (high_conf_correct_rate or 0) >= 0.85 if high_conf else None, + }, + "by_category": cat_summary, + "all_results": results, + } + + report_path = Path(__file__).parent / "math_wiki_gap_report.json" + report_path.write_text(json.dumps(report, ensure_ascii=False, indent=2)) + print(f"\n📊 Gap report written → {report_path}") + print(f" Overall: {all_passed}/{all_total} passed ({round(all_passed/all_total*100)}%)") + for cat, s in sorted(cat_summary.items()): + bar = "✓" if s["pass_rate"] >= 0.70 else ("⚠" if s["pass_rate"] >= 0.40 else "✗") + print(f" {bar} Cat {cat}: {s['passed']}/{s['total']} ({int(s['pass_rate']*100)}%) " + f"| high-conf wrong: {s['high_confidence_wrong']}") + if all_high_conf_wrong: + print(f"\n ⚠ HIGH-CONFIDENCE WRONG ANSWERS: {all_high_conf_wrong} (critical failures)") + + +# ── parametrized test ───────────────────────────────────────────────────────── + +from tests.fixtures.math_test_cases import MATH_TEST_CASES + +def _test_id(tc: dict) -> str: + return tc["id"] + + +@pytest.mark.live_ai +@pytest.mark.parametrize("tc", MATH_TEST_CASES, ids=_test_id) +def test_pipeline_problem(tc: dict, ai_client, gap_report): + """Run one problem through run_pipeline() and evaluate the result.""" + import time + + # Category C requires a real pool — skip when running without DB + if tc["category"] == "C": + pytest.skip("Category C requires populated wiki DB (pool=None in CI)") + + # Throttle: small delay between tests to respect API rate limits + time.sleep(1.5) + + result = _run_pipeline_sync(ai_client, tc["question"]) + rec = _evaluate(tc, result) + gap_report.append(rec) + + # ── Assertions ────────────────────────────────────────────────── + + # The pipeline must always return a structured response (never crash) + assert "error" in result or "answer" in result, ( + f"[{tc['id']}] Pipeline returned neither 'answer' nor 'error' key" + ) + + if "error" in result: + # INSUFFICIENT_KNOWLEDGE is acceptable for Category E (proofs) and G (impossible) + if tc["category"] not in ("E", "G"): + pytest.fail(f"[{tc['id']}] Unexpected pipeline error: {result['error']}") + return + + answer = result.get("answer", {}) + final_ans = answer.get("final_answer", "") + steps = answer.get("steps", []) + confidence = answer.get("confidence", "") + + # Must always produce a non-empty answer and at least one step + assert final_ans, f"[{tc['id']}] final_answer is empty" + assert steps, f"[{tc['id']}] steps list is empty" + assert confidence in ("high", "medium", "low"), ( + f"[{tc['id']}] confidence must be high/medium/low, got {confidence!r}" + ) + + # ── Language / format ── + # Category H: hard-fail on any format violation (format is the explicit test goal). + # All other categories: record violations in the gap report but don't block pass/fail — + # we want to measure math correctness separately from format compliance. + lang_issues = _check_language(steps) + if tc["category"] == "H": + assert not lang_issues, ( + f"[{tc['id']}] Language/format violations:\n" + "\n".join(f" • {i}" for i in lang_issues) + ) + + # ── Answer correctness ── + if tc["expected_answer"]: + assert _answer_matches(final_ans, tc["expected_answer"]), ( + f"[{tc['id']}] Wrong answer.\n" + f" Expected to contain: {tc['expected_answer']}\n" + f" Got: {final_ans!r}" + ) + + # ── Validation alignment ── + # expected_valid=False (edge cases): hard-fail if the system accepts something impossible. + # expected_valid=True: informational only — validator JSON parse failures are common and + # should not mask correctness signal. Recorded in gap report via valid_ok field. + validation = result.get("validation", {}) + is_valid = validation.get("valid", False) + ev = tc["expected_valid"] + if ev is False: + refusal_keywords = [ + "vô nghiệm", "không tồn tại", "không xác định", "undefined", + "ngoài phạm vi", "không hỗ trợ", "vô lý", "mâu thuẫn", + ] + refusal = any(kw in final_ans.lower() for kw in refusal_keywords) + assert (not is_valid) or refusal, ( + f"[{tc['id']}] Edge case: expected refusal or invalid=False.\n" + f" final_answer={final_ans!r}\n" + f" valid={is_valid}" + ) + + # ── Figure check ── + if tc["check_figure"]: + fig = answer.get("figure") + assert fig and fig.get("data"), ( + f"[{tc['id']}] Expected a figure to be generated but got None/empty" + ) + + # ── Critical failure guard: high-confidence wrong answer ── + if confidence == "high" and tc["expected_answer"]: + if not _answer_matches(final_ans, tc["expected_answer"]): + pytest.fail( + f"[{tc['id']}] CRITICAL: high-confidence WRONG answer.\n" + f" Expected: {tc['expected_answer']}\n" + f" Got: {final_ans!r}" + ) diff --git a/deploy-hf.sh b/deploy-hf.sh new file mode 100644 index 0000000000000000000000000000000000000000..dc2436cf93e2d53f2c681127b81a9f7bd05d856b --- /dev/null +++ b/deploy-hf.sh @@ -0,0 +1,33 @@ +#!/usr/bin/env bash +# Deploy latest master to Hugging Face Spaces +# Usage: ./deploy-hf.sh + +set -e + +SPACE_REMOTE="space" +SPACE_URL="https://huggingface.co/spaces/MinhTai/ai-agent-app" +HEALTH_URL="https://minhtai-ai-agent-app.hf.space/health" + +echo "==> Checking remote..." +if ! git remote get-url "$SPACE_REMOTE" &>/dev/null; then + echo "Adding '$SPACE_REMOTE' remote..." + git remote add "$SPACE_REMOTE" "$SPACE_URL" +fi + +CURRENT_BRANCH=$(git branch --show-current) + +echo "==> Switching to hf-deploy branch..." +git checkout hf-deploy + +echo "==> Merging master..." +git merge master --no-edit + +echo "==> Pushing to HF Space..." +git push --force "$SPACE_REMOTE" hf-deploy:main + +echo "==> Switching back to $CURRENT_BRANCH..." +git checkout "$CURRENT_BRANCH" + +echo "" +echo "Deployed. Build logs: $SPACE_URL" +echo "Health check (wait ~30s for rebuild): curl $HEALTH_URL" diff --git a/exam-app/.env.example b/exam-app/.env.example new file mode 100644 index 0000000000000000000000000000000000000000..4eac1cd3581a8096046715daac67f9a072f0c2e4 --- /dev/null +++ b/exam-app/.env.example @@ -0,0 +1,10 @@ +# Local dev +# VITE_API_BASE_URL=http://localhost:8000 + +# Production (Hugging Face Spaces) +VITE_API_BASE_URL=https://minhtai-ai-agent-app.hf.space + +# Google OAuth Client ID — same value as GOOGLE_CLIENT_ID on the backend. +# One OAuth client, multiple Authorised JavaScript Origins in Google Cloud Console: +# http://localhost:5173 and your HF Space URL +VITE_GOOGLE_CLIENT_ID=your_google_client_id_here diff --git a/exam-app/.gitignore b/exam-app/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..e4109c449d875514ec1de96c58ecbd9d59b63b7d --- /dev/null +++ b/exam-app/.gitignore @@ -0,0 +1,34 @@ +# Dependencies +node_modules/ + +# Build output +dist/ +dist-ssr/ + +# Vite cache +.vite/ + +# Crawler output (raw scraped data — not source) +scripts/crawl/output/ + +# Env files +.env +.env.local +.env.*.local + +# Editor +.vscode/ +.idea/ +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# OS +.DS_Store +Thumbs.db + +# Logs +*.log +npm-debug.log* diff --git a/exam-app/MOTION_DOCTRINE.md b/exam-app/MOTION_DOCTRINE.md new file mode 100644 index 0000000000000000000000000000000000000000..57144301f9007c4651ed41efb81c5f97e897a2c6 --- /dev/null +++ b/exam-app/MOTION_DOCTRINE.md @@ -0,0 +1,99 @@ +# Zenith Motion Doctrine v2 — Cognitive Science Edition + +Grounded in Mayer's Cognitive Theory of Multimedia Learning (CTML) and behavioral research +from Duolingo and Brilliant.org. Animations serve three distinct cognitive functions mapped +to three tiers. Everything else is forbidden. + +--- + +## Three-Tier Architecture + +### Tier 1 — FEEDBACK (100–300ms, always on) +**Purpose:** Knowledge of Results — the student must know immediately whether an action succeeded. +**Principle:** Temporal contiguity. Cannot be disabled by the user. + +| Animation | File | Duration | +|---|---|---| +| Correct answer flash (green) | `index.css` `z-correct-flash` | 500ms | +| Wrong answer shake | `index.css` `z-wrong-shake` | 280ms | +| Button press ripple | `index.css` `ripple-btn` | 400ms | +| Loading skeleton shimmer | `index.css` `shimmer` | 1.4s loop | +| Toast notification entrance | `index.css` `toast-in` | opacity only | + +### Tier 2 — REVEAL (300–500ms, content-entry only, opacity only) +**Purpose:** Schema formation — chunk complex content into digestible pieces. +**Principle:** Stagger ≤ 5 items, ≤ 80ms between items, opacity only (no y-translate), fires once per session. + +| Pattern | Where applied | Spec | +|---|---|---| +| Page transition | App.jsx `AnimatePresence` | 280ms enter, 180ms exit | +| Card list stagger | `listVariants` + `itemVariants` | 80ms stagger, opacity only | +| Score bar fill | Results.jsx | 250ms linear | +| Concept stage fade | ReviewSession.jsx | 200ms opacity | +| Scroll reveal (useInView) | StudyPlan, AdaptiveStudyPlan | 400ms, fires once | +| Checkpoint bar fill | StudyPlan.jsx `CheckpointBar` | 250ms linear | + +### Tier 3 — CEREMONY (500ms–1.5s, milestone events only, spring physics) +**Purpose:** Dopamine reinforcement — reward meaningful achievement. +**Principle:** Spring physics (natural, interruptible). Infrequent (≤ once per session). Skippable. + +| Event | Where | Component | +|---|---|---| +| Focus area resolved (✓ badge) | StudyPlan.jsx `FocusCard` | `AchievementCeremony` | +| Spaced repetition stage advance | ReviewSession.jsx `stageLabel` | spring scale pop | +| Score ≥ 9.0 ring pop | Results.jsx score SVG | `AchievementCeremony` | +| Mastery rank badge appear | Account.jsx profile | `AchievementCeremony` | +| Daily streak on completion | DailyChallenge.jsx | `AchievementCeremony` | +| Oracle celebrating response | OracleBubble.jsx | CSS `oracle-celebrating` keyframe | + +--- + +## Oracle State Machine + +The Oracle bubble uses CSS `data-oracle-state` attribute (not Framer Motion) for four states: + +| State | Trigger | Animation | +|---|---|---| +| `idle` | Default | Slow 2.5s glow pulse | +| `thinking` | `setOracleStatus(THINKING)` on solve start | Fast 0.7s pulse loop | +| `celebrating` | High-confidence valid solve | 0.7s spring scale pop (plays once) | +| `error` | Solve error or timeout | 0.35s shake (plays once) | + +State is managed via `OracleContext.oracleStatus` and `setOracleStatus`. +`ORACLE_STATUS` constants are exported from `OracleContext.jsx`. + +--- + +## Forbidden (unchanged from v1) + +- ✗ AmbientGlows floating orbs on non-landing pages +- ✗ react-countup number animations +- ✗ canvas-confetti decorative explosions (confetti is allowed on score ≥ 7 in Results.jsx, which is a product decision not a doctrine decoration) +- ✗ Spring/bounce on page transitions +- ✗ `whileHover` scale on text, icons, or inline elements +- ✗ `height`/`width` animations (triggers reflow, causes jank) +- ✗ Stagger > 5 items +- ✗ Ceremonies on every correct answer — Tier 3 only on genuine milestones +- ✗ y-translate in `itemVariants` or card variants + +--- + +## Accessibility + +`` is set at the App root (App.jsx). +When the user has `prefers-reduced-motion: reduce` enabled, all Framer Motion animations +resolve to their final state instantly. The Oracle CSS keyframes do NOT automatically +respect this — they are exempted because they are status indicators, not content animations. + +--- + +## Key Files + +| File | Role | +|---|---| +| `src/utils/animations.js` | `pageVariants`, `listVariants`, `itemVariants` | +| `src/components/AchievementCeremony.jsx` | Reusable Tier 3 spring wrapper | +| `src/hooks/useRevealOnScroll.js` | `useInView` wrapper for scroll reveals | +| `src/components/OracleBubble.jsx` | Oracle state machine consumer | +| `src/context/OracleContext.jsx` | `oracleStatus`, `setOracleStatus`, `ORACLE_STATUS` | +| `src/index.css` | All CSS keyframes + Oracle state selectors | diff --git a/exam-app/index.html b/exam-app/index.html new file mode 100644 index 0000000000000000000000000000000000000000..fc2cc25b57958e8413fe045f8f67434edd20e9d6 --- /dev/null +++ b/exam-app/index.html @@ -0,0 +1,110 @@ + + + + + + Zenith · Ôn thi Toán THPT & Lớp 10 cùng AI + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + diff --git a/exam-app/package-lock.json b/exam-app/package-lock.json new file mode 100644 index 0000000000000000000000000000000000000000..b26d40acebc4e58b343d25c3b34161518f82fd07 --- /dev/null +++ b/exam-app/package-lock.json @@ -0,0 +1,5994 @@ +{ + "name": "exam-app", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "exam-app", + "version": "0.1.0", + "dependencies": { + "@react-oauth/google": "^0.13.5", + "axios": "^1.7.7", + "canvas-confetti": "^1.9.4", + "dompurify": "^3.4.2", + "framer-motion": "^12.38.0", + "html-to-image": "^1.11.13", + "katex": "^0.16.45", + "mathlive": "^0.109.1", + "react": "^18.3.1", + "react-canvas-confetti": "^2.0.7", + "react-countup": "^6.5.3", + "react-dom": "^18.3.1", + "react-markdown": "^10.1.0", + "react-router-dom": "^6.27.0", + "recharts": "^2.13.3", + "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.3", + "autoprefixer": "^10.4.20", + "cheerio": "^1.0.0", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.14", + "vite": "^5.4.10", + "vitest": "^2.1.4" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@arnog/colors": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@arnog/colors/-/colors-0.3.0.tgz", + "integrity": "sha512-CDZFMVWeE3HqcrzvacY2Y8257RS9c0f8+D+MWjbjmb5IWpOZPPeJSqWyxkVcFleCjA+x5aq6foc57cVaP+AMQg==", + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-compilation-targets": "^7.28.6", + "@babel/helper-module-transforms": "^7.28.6", + "@babel/helpers": "^7.28.6", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/traverse": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.0", + "@babel/types": "^7.29.0", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.28.6", + "@babel/helper-validator-option": "^7.27.1", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.28.6.tgz", + "integrity": "sha512-S9gzZ/bz83GRysI7gAD4wPT/AI3uCnY+9xn+Mx/KPs2JwHJIz1W8PZkg2cqyt3RNOBM8ejcXhV6y8Og7ly/Dug==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.27.1.tgz", + "integrity": "sha512-6UzkCs+ejGdZ5mFFC/OCUrv028ab2fp1znZmCZjAOBKiBK2jXD1O+BPSfX8X2qjJ75fZBMSnQn3Rq2mrBJK2mw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.27.1.tgz", + "integrity": "sha512-zbwoTsBruTeKB9hSq73ha66iFeJHuaFkUbwvqElnygoNbj/jHRsSeokowZFN3CZ64IvEqcmmkVe89OPXc7ldAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.27.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.0", + "@babel/generator": "^7.29.0", + "@babel/helper-globals": "^7.28.0", + "@babel/parser": "^7.29.0", + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@cortex-js/compute-engine": { + "version": "0.55.6", + "resolved": "https://registry.npmjs.org/@cortex-js/compute-engine/-/compute-engine-0.55.6.tgz", + "integrity": "sha512-lWnZ34gtFpUDpFmEMdsL+5HJuh7hyj0DoaZhVTFnVtGX2Rf7qFyD+zgTs1vY9h2qhcpKymiakE6evvWzI6kwtA==", + "license": "MIT", + "dependencies": { + "@arnog/colors": "^0.3.0", + "complex-esm": "^2.1.1-esm1", + "decimal.js": "^10.6.0" + }, + "engines": { + "node": ">=21.7.3", + "npm": ">=10.5.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@react-oauth/google": { + "version": "0.13.5", + "resolved": "https://registry.npmjs.org/@react-oauth/google/-/google-0.13.5.tgz", + "integrity": "sha512-xQWri2s/3nNekZJ4uuov2aAfQYu83bN3864KcFqw2pK1nNbFurQIjPFDXhWaKH3IjYJ2r/9yyIIpsn5lMqrheQ==", + "license": "MIT", + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.2", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.2.tgz", + "integrity": "sha512-Ic6m2U/rMjTkhERIa/0ZtXJP17QUi2CbWE7cqx4J58M8aA3QTfW+2UlQ4psvTX9IO1RfNVhK3pcpdjej7L+t2w==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.2.tgz", + "integrity": "sha512-dnlp69efPPg6Uaw2dVqzWRfAWRnYVb1XJ8CyyhIbZeaq4CA5/mLeZ1IEt9QqQxmbdvagjLIm2ZL8BxXv5lH4Yw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.2.tgz", + "integrity": "sha512-OqZTwDRDchGRHHm/hwLOL7uVPB9aUvI0am/eQuWMNyFHf5PSEQmyEeYYheA0EPPKUO/l0uigCp+iaTjoLjVoHg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.2.tgz", + "integrity": "sha512-UwRE7CGpvSVEQS8gUMBe1uADWjNnVgP3Iusyda1nSRwNDCsRjnGc7w6El6WLQsXmZTbLZx9cecegumcitNfpmA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.2.tgz", + "integrity": "sha512-gjEtURKLCC5VXm1I+2i1u9OhxFsKAQJKTVB8WvDAHF+oZlq0GTVFOlTlO1q3AlCTE/DF32c16ESvfgqR7343/g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.2.tgz", + "integrity": "sha512-Bcl6CYDeAgE70cqZaMojOi/eK63h5Me97ZqAQoh77VPjMysA/4ORQBRGo3rRy45x4MzVlU9uZxs8Uwy7ZaKnBw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.2.tgz", + "integrity": "sha512-LU+TPda3mAE2QB0/Hp5VyeKJivpC6+tlOXd1VMoXV/YFMvk/MNk5iXeBfB4MQGRWyOYVJ01625vjkr0Az98OJQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.2.tgz", + "integrity": "sha512-2QxQrM+KQ7DAW4o22j+XZ6RKdxjLD7BOWTP0Bv0tmjdyhXSsr2Ul1oJDQqh9Zf5qOwTuTc7Ek83mOFaKnodPjg==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.2.tgz", + "integrity": "sha512-TbziEu2DVsTEOPif2mKWkMeDMLoYjx95oESa9fkQQK7r/Orta0gnkcDpzwufEcAO2BLBsD7mZkXGFqEdMRRwfw==", + "cpu": [ + "arm" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.2.tgz", + "integrity": "sha512-bO/rVDiDUuM2YfuCUwZ1t1cP+/yqjqz+Xf2VtkdppefuOFS2OSeAfgafaHNkFn0t02hEyXngZkxtGqXcXwO8Rg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.2.tgz", + "integrity": "sha512-hr26p7e93Rl0Za+JwW7EAnwAvKkehh12BU1Llm9Ykiibg4uIr2rbpxG9WCf56GuvidlTG9KiiQT/TXT1yAWxTA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.2.tgz", + "integrity": "sha512-pOjB/uSIyDt+ow3k/RcLvUAOGpysT2phDn7TTUB3n75SlIgZzM6NKAqlErPhoFU+npgY3/n+2HYIQVbF70P9/A==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.2.tgz", + "integrity": "sha512-2/w+q8jszv9Ww1c+6uJT3OwqhdmGP2/4T17cu8WuwyUuuaCDDJ2ojdyYwZzCxx0GcsZBhzi3HmH+J5pZNXnd+Q==", + "cpu": [ + "loong64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.2.tgz", + "integrity": "sha512-11+aL5vKheYgczxtPVVRhdptAM2H7fcDR5Gw4/bTcteuZBlH4oP9f5s9zYO9aGZvoGeBpqXI/9TZZihZ609wKw==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.2.tgz", + "integrity": "sha512-i16fokAGK46IVZuV8LIIwMdtqhin9hfYkCh8pf8iC3QU3LpwL+1FSFGej+O7l3E/AoknL6Dclh2oTdnRMpTzFQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.2.tgz", + "integrity": "sha512-49FkKS6RGQoriDSK/6E2GkAsAuU5kETFCh7pG4yD/ylj9rKhTmO3elsnmBvRD4PgJPds5W2PkhC82aVwmUcJ7A==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.2.tgz", + "integrity": "sha512-mjYNkHPfGpUR00DuM1ZZIgs64Hpf4bWcz9Z41+4Q+pgDx73UwWdAYyf6EG/lRFldmdHHzgrYyge5akFUW0D3mQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.2.tgz", + "integrity": "sha512-ALyvJz965BQk8E9Al/JDKKDLH2kfKFLTGMlgkAbbYtZuJt9LU8DW3ZoDMCtQpXAltZxwBHevXz5u+gf0yA0YoA==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.2.tgz", + "integrity": "sha512-UQjrkIdWrKI626Du8lCQ6MJp/6V1LAo2bOK9OTu4mSn8GGXIkPXk/Vsp4bLHCd9Z9Iz2OTEaokUE90VweJgIYQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.2.tgz", + "integrity": "sha512-bTsRGj6VlSdn/XD4CGyzMnzaBs9bsRxy79eTqTCBsA8TMIEky7qg48aPkvJvFe1HyzQ5oMZdg7AnVlWQSKLTnw==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.2.tgz", + "integrity": "sha512-6d4Z3534xitaA1FcMWP7mQPq5zGwBmGbhphh2DwaA1aNIXUu3KTOfwrWpbwI4/Gr0uANo7NTtaykFyO2hPuFLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.2.tgz", + "integrity": "sha512-NetAg5iO2uN7eB8zE5qrZ3CSil+7IJt4WDFLcC75Ymywq1VZVD6qJ6EvNLjZ3rEm6gB7XW5JdT60c6MN35Z85Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.2.tgz", + "integrity": "sha512-NCYhOotpgWZ5kdxCZsv6Iudx0wX8980Q/oW4pNFNihpBKsDbEA1zpkfxJGC0yugsUuyDZ7gL37dbzwhR0VI7pQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.2.tgz", + "integrity": "sha512-RXsaOqXxfoUBQoOgvmmijVxJnW2IGB0eoMO7F8FAjaj0UTywUO/luSqimWBJn04WNgUkeNhh7fs7pESXajWmkg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.2.tgz", + "integrity": "sha512-qdAzEULD+/hzObedtmV6iBpdL5TIbKVztGiK7O3/KYSf+HIzU257+MX1EXJcyIiDbMAqmbwaufcYPvyRryeZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.2.tgz", + "integrity": "sha512-Nd/SgG27WoA9e+/TdK74KnHz852TLa94ovOYySo/yMPuTmpckK/jIF2jSwS3g7ELSKXK13/cVdmg1Z/DaCWKxA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/canvas-confetti": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@types/canvas-confetti/-/canvas-confetti-1.9.0.tgz", + "integrity": "sha512-aBGj/dULrimR1XDZLtG9JwxX1b4HPRF6CX9Yfwh3NvstZEm1ZL7RBnel4keCPSqs1ANRu1u2Aoz9R+VmtjYuTg==", + "license": "MIT" + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.1.8.tgz", + "integrity": "sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/debug": { + "version": "4.1.13", + "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.13.tgz", + "integrity": "sha512-KSVgmQmzMwPlmtljOomayoR89W4FynCAi3E8PPs7vmDVPe84hT+vGPKkJfThkmXs0x0jAaa9U8uW8bbfyS2fWw==", + "license": "MIT", + "dependencies": { + "@types/ms": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", + "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", + "license": "MIT" + }, + "node_modules/@types/estree-jsx": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@types/estree-jsx/-/estree-jsx-1.0.5.tgz", + "integrity": "sha512-52CcUVNFyfb1A2ALocQw/Dd1BQFNmSdkuC3BkZ6iqhdMfQz7JWOFRuJFloOzjk+6WijU56m9oKXFAXc7o3Towg==", + "license": "MIT", + "dependencies": { + "@types/estree": "*" + } + }, + "node_modules/@types/hast": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", + "integrity": "sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/katex": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@types/katex/-/katex-0.16.8.tgz", + "integrity": "sha512-trgaNyfU+Xh2Tc+ABIb44a5AYUpicB3uwirOioeOkNPPbmgRNtcWyDeeFRzjPZENO9Vq8gvVqfhaaXWLlevVwg==", + "license": "MIT" + }, + "node_modules/@types/mdast": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/@types/mdast/-/mdast-4.0.4.tgz", + "integrity": "sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==", + "license": "MIT", + "dependencies": { + "@types/unist": "*" + } + }, + "node_modules/@types/ms": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@types/ms/-/ms-2.1.0.tgz", + "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", + "license": "MIT" + }, + "node_modules/@types/react": { + "version": "19.2.14", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", + "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", + "license": "MIT", + "peer": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "license": "MIT", + "optional": true + }, + "node_modules/@types/unist": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", + "integrity": "sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==", + "license": "MIT" + }, + "node_modules/@ungap/structured-clone": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.0.tgz", + "integrity": "sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==", + "license": "ISC" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/any-promise": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/any-promise/-/any-promise-1.3.0.tgz", + "integrity": "sha512-7UvmKalWRt1wgjL1RrGxoSJW/0QZFIegpeGvZG9kjp8vrRu55XTHbwnqq2GpXm9uLbcuhxm3IqX9OB4MZR1b2A==", + "dev": true, + "license": "MIT" + }, + "node_modules/anymatch": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz", + "integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==", + "dev": true, + "license": "ISC", + "dependencies": { + "normalize-path": "^3.0.0", + "picomatch": "^2.0.4" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/arg": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/arg/-/arg-5.0.2.tgz", + "integrity": "sha512-PYjyFOLKQ9y57JvQ6QLo8dAgNqswh8M1RMJYdQduT6xbWSgK36P/Z/v+p888pM69jMMfS8Xd8F6I1kQ/I9HUGg==", + "dev": true, + "license": "MIT" + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/autoprefixer": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/autoprefixer/-/autoprefixer-10.5.0.tgz", + "integrity": "sha512-FMhOoZV4+qR6aTUALKX2rEqGG+oyATvwBt9IIzVR5rMa2HRWPkxf+P+PAJLD1I/H5/II+HuZcBJYEFBpq39ong==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/autoprefixer" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "browserslist": "^4.28.2", + "caniuse-lite": "^1.0.30001787", + "fraction.js": "^5.3.4", + "picocolors": "^1.1.1", + "postcss-value-parser": "^4.2.0" + }, + "bin": { + "autoprefixer": "bin/autoprefixer" + }, + "engines": { + "node": "^10 || ^12 || >=14" + }, + "peerDependencies": { + "postcss": "^8.1.0" + } + }, + "node_modules/axios": { + "version": "1.15.2", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.15.2.tgz", + "integrity": "sha512-wLrXxPtcrPTsNlJmKjkPnNPK2Ihe0hn0wGSaTEiHRPxwjvJwT3hKmXF4dpqxmPO9SoNb2FsYXj/xEo0gHN+D5A==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.15.11", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/bail": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/bail/-/bail-2.0.2.tgz", + "integrity": "sha512-0xO6mYd7JB2YesxDKplafRpsiOzPt9V02ddPCLbY1xYGPOX24NTyN50qnUxgCPcSoYMhKpAuBTjQoRZCAkUDRw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.25", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.25.tgz", + "integrity": "sha512-QO/VHsXCQdnzADMfmkeOPvHdIAkoB7i0/rGjINPJEetLx75hNttVWGQ/jycHUDP9zZ9rupbm60WRxcwViB0MiA==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/binary-extensions": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", + "integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/boolbase": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", + "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", + "dev": true, + "license": "ISC" + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/camelcase-css": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/camelcase-css/-/camelcase-css-2.0.1.tgz", + "integrity": "sha512-QOSvevhslijgYwRx6Rv7zKdMF8lbRmx+uQGx2+vDc+KI/eBnsy9kit5aj23AgGu3pa4t9AgwbnXWqS+iOY+2aA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001791", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001791.tgz", + "integrity": "sha512-yk0l/YSrOnFZk3UROpDLQD9+kC1l4meK/wed583AXrzoarMGJcbRi2Q4RaUYbKxYAsZ8sWmaSa/DsLmdBeI1vQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/canvas-confetti": { + "version": "1.9.4", + "resolved": "https://registry.npmjs.org/canvas-confetti/-/canvas-confetti-1.9.4.tgz", + "integrity": "sha512-yxQbJkAVrFXWNbTUjPqjF7G+g6pDotOUHGbkZq2NELZUMDpiJ85rIEazVb8GTaAptNW2miJAXbs1BtioA251Pw==", + "license": "ISC", + "funding": { + "type": "donate", + "url": "https://www.paypal.me/kirilvatev" + } + }, + "node_modules/ccount": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/ccount/-/ccount-2.0.1.tgz", + "integrity": "sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/character-entities": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/character-entities/-/character-entities-2.0.2.tgz", + "integrity": "sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-html4": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/character-entities-html4/-/character-entities-html4-2.1.0.tgz", + "integrity": "sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-entities-legacy": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz", + "integrity": "sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/character-reference-invalid": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/character-reference-invalid/-/character-reference-invalid-2.0.1.tgz", + "integrity": "sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/cheerio": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.2.0.tgz", + "integrity": "sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cheerio-select": "^2.1.0", + "dom-serializer": "^2.0.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "encoding-sniffer": "^0.2.1", + "htmlparser2": "^10.1.0", + "parse5": "^7.3.0", + "parse5-htmlparser2-tree-adapter": "^7.1.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^7.19.0", + "whatwg-mimetype": "^4.0.0" + }, + "engines": { + "node": ">=20.18.1" + }, + "funding": { + "url": "https://github.com/cheeriojs/cheerio?sponsor=1" + } + }, + "node_modules/cheerio-select": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/cheerio-select/-/cheerio-select-2.1.0.tgz", + "integrity": "sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-select": "^5.1.0", + "css-what": "^6.1.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/chokidar": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz", + "integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "anymatch": "~3.1.2", + "braces": "~3.0.2", + "glob-parent": "~5.1.2", + "is-binary-path": "~2.1.0", + "is-glob": "~4.0.1", + "normalize-path": "~3.0.0", + "readdirp": "~3.6.0" + }, + "engines": { + "node": ">= 8.10.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + }, + "optionalDependencies": { + "fsevents": "~2.3.2" + } + }, + "node_modules/chokidar/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/comma-separated-tokens": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz", + "integrity": "sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/commander": { + "version": "8.3.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-8.3.0.tgz", + "integrity": "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/complex-esm": { + "version": "2.1.1-esm1", + "resolved": "https://registry.npmjs.org/complex-esm/-/complex-esm-2.1.1-esm1.tgz", + "integrity": "sha512-IShBEWHILB9s7MnfyevqNGxV0A1cfcSnewL/4uPFiSxkcQL4Mm3FxJ0pXMtCXuWLjYz3lRRyk6OfkeDZcjD6nw==", + "license": "MIT", + "engines": { + "node": ">=16.14.2", + "npm": ">=8.5.0" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/countup.js": { + "version": "2.10.0", + "resolved": "https://registry.npmjs.org/countup.js/-/countup.js-2.10.0.tgz", + "integrity": "sha512-QQpZx7oYxsR+OeITlZe46fY/OQjV11oBqjY8wgIXzLU2jIz8GzOrbMhqKLysGY8bWI3T1ZNrYkwGzKb4JNgyzg==", + "license": "MIT" + }, + "node_modules/css-select": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", + "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0", + "css-what": "^6.1.0", + "domhandler": "^5.0.2", + "domutils": "^3.0.1", + "nth-check": "^2.0.1" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/css-what": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", + "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">= 6" + }, + "funding": { + "url": "https://github.com/sponsors/fb55" + } + }, + "node_modules/cssesc": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/cssesc/-/cssesc-3.0.0.tgz", + "integrity": "sha512-/Tb/JcjK111nNScGob5MNtsntNM1aCNUDipB/TkwZFhyDrrE47SOx/18wF2bbjgc3ZzCSKW1T5nt5EbFoAz/Vg==", + "dev": true, + "license": "MIT", + "bin": { + "cssesc": "bin/cssesc" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "license": "MIT" + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/decode-named-character-reference": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/decode-named-character-reference/-/decode-named-character-reference-1.3.0.tgz", + "integrity": "sha512-GtpQYB283KrPp6nRw50q3U9/VfOutZOe103qlN7BPP6Ad27xYnOIWv4lPzo8HCAL+mMZofJ9KEy30fq6MfaK6Q==", + "license": "MIT", + "dependencies": { + "character-entities": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/devlop": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/devlop/-/devlop-1.1.0.tgz", + "integrity": "sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==", + "license": "MIT", + "dependencies": { + "dequal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/didyoumean": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/didyoumean/-/didyoumean-1.2.2.tgz", + "integrity": "sha512-gxtyfqMg7GKyhQmb056K7M3xszy/myH8w+B4RT+QXBQsvAOdc3XymqDDPHx1BgPgsdAA5SIifona89YtRATDzw==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/dlv": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/dlv/-/dlv-1.1.3.tgz", + "integrity": "sha512-+HlytyjlPKnIG8XuRG8WvmBP8xs8P71y+SKKS6ZXWoEgLuePxtDoUEiH7WkdePWrQ5JBpE6aoVqfZfJUQkjXwA==", + "dev": true, + "license": "MIT" + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/dom-serializer": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", + "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.2", + "entities": "^4.2.0" + }, + "funding": { + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", + "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause" + }, + "node_modules/domhandler": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", + "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^2.3.0" + }, + "engines": { + "node": ">= 4" + }, + "funding": { + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/dompurify": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.2.tgz", + "integrity": "sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/domutils": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", + "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^2.0.0", + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3" + }, + "funding": { + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.349", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.349.tgz", + "integrity": "sha512-QsWVGyRuY07Aqb234QytTfwd5d9AJlfNIQ5wIOl1L+PZDzI9d9+Fn0FRale/QYlFxt/bUnB0/nLd1jFPGxGK1A==", + "dev": true, + "license": "ISC" + }, + "node_modules/encoding-sniffer": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.1.tgz", + "integrity": "sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" + } + }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-5.0.0.tgz", + "integrity": "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/estree-util-is-identifier-name": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/estree-util-is-identifier-name/-/estree-util-is-identifier-name-3.0.0.tgz", + "integrity": "sha512-hFtqIDZTIUZ9BXLb8y4pYGyk6+wekIivNVTcmvk8NoOh+VeRn5y6cEHzbURrWbfp1fIqdVipilzj+lfaadNZmg==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-equals": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.0.tgz", + "integrity": "sha512-jt2DW/aNFNwke7AUd+Z+e6pz39KO5rzdbbFCg2sGafS4mk13MI7Z8O5z9cADNn5lhGODIgLwug6TZO2ctf7kcw==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fast-glob": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz", + "integrity": "sha512-7MptL8U0cqcFdzIzwOTHoilX9x5BrNqye7Z/LuC7kCMRio1EMSyqRK3BEAUD7sXRq4iT4AzTVuZdhgQ2TCvYLg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "^2.0.2", + "@nodelib/fs.walk": "^1.2.3", + "glob-parent": "^5.1.2", + "merge2": "^1.3.0", + "micromatch": "^4.0.8" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "es-set-tostringtag": "^2.1.0", + "hasown": "^2.0.2", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fraction.js": { + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/fraction.js/-/fraction.js-5.3.4.tgz", + "integrity": "sha512-1X1NTtiJphryn/uLQz3whtY6jK3fTqoE3ohKs0tT+Ujr1W59oopxmoEh7Lu5p6vBaPbgoM0bzveAW4Qi5RyWDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/rawify" + } + }, + "node_modules/framer-motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", + "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "license": "MIT", + "dependencies": { + "motion-dom": "^12.38.0", + "motion-utils": "^12.36.0", + "tslib": "^2.4.0" + }, + "peerDependencies": { + "@emotion/is-prop-valid": "*", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hast-util-from-dom": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/hast-util-from-dom/-/hast-util-from-dom-5.0.1.tgz", + "integrity": "sha512-N+LqofjR2zuzTjCPzyDUdSshy4Ma6li7p/c3pA78uTwzFgENbgbUrm2ugwsOdcjI1muO+o6Dgzp9p8WHtn/39Q==", + "license": "ISC", + "dependencies": { + "@types/hast": "^3.0.0", + "hastscript": "^9.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-html/-/hast-util-from-html-2.0.3.tgz", + "integrity": "sha512-CUSRHXyKjzHov8yKsQjGOElXy/3EKpyX56ELnkHH34vDVw1N1XSQ1ZcAvTyAPtGqLTuKP/uxM+aLkSPqF/EtMw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "devlop": "^1.1.0", + "hast-util-from-parse5": "^8.0.0", + "parse5": "^7.0.0", + "vfile": "^6.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-html-isomorphic": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/hast-util-from-html-isomorphic/-/hast-util-from-html-isomorphic-2.0.0.tgz", + "integrity": "sha512-zJfpXq44yff2hmE0XmwEOzdWin5xwH+QIhMLOScpX91e/NSGPsAzNCvLQDIEPyO2TXi+lBmU6hjLIhV8MwP2kw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "hast-util-from-dom": "^5.0.0", + "hast-util-from-html": "^2.0.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-from-parse5": { + "version": "8.0.3", + "resolved": "https://registry.npmjs.org/hast-util-from-parse5/-/hast-util-from-parse5-8.0.3.tgz", + "integrity": "sha512-3kxEVkEKt0zvcZ3hCRYI8rqrgwtlIOFMWkbclACvjlDw8Li9S2hk/d51OI0nr/gIpdMHNepwgOKqZ/sy0Clpyg==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "devlop": "^1.0.0", + "hastscript": "^9.0.0", + "property-information": "^7.0.0", + "vfile": "^6.0.0", + "vfile-location": "^5.0.0", + "web-namespaces": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-is-element": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-is-element/-/hast-util-is-element-3.0.0.tgz", + "integrity": "sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-parse-selector": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/hast-util-parse-selector/-/hast-util-parse-selector-4.0.0.tgz", + "integrity": "sha512-wkQCkSYoOGCRKERFWcxMVMOcYE2K1AaNLU8DXS9arxnLOUEWbOXKXiJUNzEpqZ3JOKpnha3jkFrumEjVliDe7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-jsx-runtime": { + "version": "2.3.6", + "resolved": "https://registry.npmjs.org/hast-util-to-jsx-runtime/-/hast-util-to-jsx-runtime-2.3.6.tgz", + "integrity": "sha512-zl6s8LwNyo1P9uw+XJGvZtdFF1GdAkOg8ujOw+4Pyb76874fLps4ueHXDhXWdk6YHQ6OgUtinliG7RsYvCbbBg==", + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "devlop": "^1.0.0", + "estree-util-is-identifier-name": "^3.0.0", + "hast-util-whitespace": "^3.0.0", + "mdast-util-mdx-expression": "^2.0.0", + "mdast-util-mdx-jsx": "^3.0.0", + "mdast-util-mdxjs-esm": "^2.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0", + "style-to-js": "^1.0.0", + "unist-util-position": "^5.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-to-text": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/hast-util-to-text/-/hast-util-to-text-4.0.2.tgz", + "integrity": "sha512-KK6y/BN8lbaq654j7JgBydev7wuNMcID54lkRav1P0CaE1e47P72AWWPiGKXTJU271ooYzcvTAn/Zt0REnvc7A==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/unist": "^3.0.0", + "hast-util-is-element": "^3.0.0", + "unist-util-find-after": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hast-util-whitespace": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz", + "integrity": "sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/hastscript": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/hastscript/-/hastscript-9.0.1.tgz", + "integrity": "sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "comma-separated-tokens": "^2.0.0", + "hast-util-parse-selector": "^4.0.0", + "property-information": "^7.0.0", + "space-separated-tokens": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/html-to-image": { + "version": "1.11.13", + "resolved": "https://registry.npmjs.org/html-to-image/-/html-to-image-1.11.13.tgz", + "integrity": "sha512-cuOPoI7WApyhBElTTb9oqsawRvZ0rHhaHwghRLlTuffoD1B2aDemlCruLeZrUIIdvG7gs9xeELEPm6PhuASqrg==", + "license": "MIT" + }, + "node_modules/html-url-attributes": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/html-url-attributes/-/html-url-attributes-3.0.1.tgz", + "integrity": "sha512-ol6UPyBWqsrO6EJySPz2O7ZSr856WDrEzM5zMqp+FJJLGMW35cLYmmZnl0vztAZxRUoNZJFTCohfjuIJ8I4QBQ==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/htmlparser2": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-10.1.0.tgz", + "integrity": "sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==", + "dev": true, + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.2.2", + "entities": "^7.0.1" + } + }, + "node_modules/htmlparser2/node_modules/entities": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-7.0.1.tgz", + "integrity": "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/is-alphabetical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphabetical/-/is-alphabetical-2.0.1.tgz", + "integrity": "sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-alphanumerical": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-alphanumerical/-/is-alphanumerical-2.0.1.tgz", + "integrity": "sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==", + "license": "MIT", + "dependencies": { + "is-alphabetical": "^2.0.0", + "is-decimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-binary-path": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz", + "integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==", + "dev": true, + "license": "MIT", + "dependencies": { + "binary-extensions": "^2.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/is-core-module": { + "version": "2.16.1", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", + "integrity": "sha512-UfoeMA6fIJ8wTYFEUjelnaGI67v6+N7qXJEvQuIGa99l4xsCruSYOVSQ0uPANn4dAzm8lkYPaKLrrijLq7x23w==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-decimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-decimal/-/is-decimal-2.0.1.tgz", + "integrity": "sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-hexadecimal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/is-hexadecimal/-/is-hexadecimal-2.0.1.tgz", + "integrity": "sha512-DgZQp241c8oO6cA1SbTEWiXeoxV42vlcJxgH+B3hi1AiqqKruZR3ZGF8In3fj4+/y/7rHvlOZLZtgJ/4ttYGZg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-plain-obj": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/is-plain-obj/-/is-plain-obj-4.1.0.tgz", + "integrity": "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==", + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/jiti": { + "version": "1.21.7", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-1.21.7.tgz", + "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "bin/jiti.js" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/katex": { + "version": "0.16.45", + "resolved": "https://registry.npmjs.org/katex/-/katex-0.16.45.tgz", + "integrity": "sha512-pQpZbdBu7wCTmQUh7ufPmLr0pFoObnGUoL/yhtwJDgmmQpbkg/0HSVti25Fu4rmd1oCR6NGWe9vqTWuWv3GcNA==", + "funding": [ + "https://opencollective.com/katex", + "https://github.com/sponsors/katex" + ], + "license": "MIT", + "dependencies": { + "commander": "^8.3.0" + }, + "bin": { + "katex": "cli.js" + } + }, + "node_modules/lilconfig": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", + "integrity": "sha512-/vlFKAoH5Cgt3Ie+JLhRbwOsCQePABiU3tJ1egGvyQ+33R/vcwM2Zl2QR/LzjsBeItPt3oSVXapn+m4nQDvpzw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/antonk52" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/longest-streak": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/longest-streak/-/longest-streak-3.1.0.tgz", + "integrity": "sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/markdown-table": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/markdown-table/-/markdown-table-3.0.4.tgz", + "integrity": "sha512-wiYz4+JrLyb/DqW2hkFJxP7Vd7JuTDm77fvbM8VfEQdmSMqcImWeeRbHwZjBjIFki/VaMK2BhFi7oUUZeM5bqw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/mathlive": { + "version": "0.109.2", + "resolved": "https://registry.npmjs.org/mathlive/-/mathlive-0.109.2.tgz", + "integrity": "sha512-/2uNk8xFP8msIINwWFKv9bBLnCnaNL2wzUWaDu89Vj7sSuIUX8FFg0PY6XER0pNpHJCa/T+Ct5MK6m+zFTdPKw==", + "license": "MIT", + "dependencies": { + "@cortex-js/compute-engine": "0.55.6" + }, + "funding": { + "type": "individual", + "url": "https://paypal.me/arnogourdol" + } + }, + "node_modules/mdast-util-find-and-replace": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mdast-util-find-and-replace/-/mdast-util-find-and-replace-3.0.2.tgz", + "integrity": "sha512-Tmd1Vg/m3Xz43afeNxDIhWRtFZgM2VLyaf4vSTYwudTyeuTneoL3qtWMA5jeLyz/O1vDJmmV4QuScFCA2tBPwg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "escape-string-regexp": "^5.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-from-markdown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/mdast-util-from-markdown/-/mdast-util-from-markdown-2.0.3.tgz", + "integrity": "sha512-W4mAWTvSlKvf8L6J+VN9yLSqQ9AOAAvHuoDAmPkz4dHf553m5gVj2ejadHJhoJmcmxEnOv6Pa8XJhpxE93kb8Q==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark": "^4.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm/-/mdast-util-gfm-3.1.0.tgz", + "integrity": "sha512-0ulfdQOM3ysHhCJ1p06l0b0VKlhU0wuQs3thxZQagjcjPrlFRqY215uZGHHJan9GEAXd9MbfPjFJz+qMkVR6zQ==", + "license": "MIT", + "dependencies": { + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-gfm-autolink-literal": "^2.0.0", + "mdast-util-gfm-footnote": "^2.0.0", + "mdast-util-gfm-strikethrough": "^2.0.0", + "mdast-util-gfm-table": "^2.0.0", + "mdast-util-gfm-task-list-item": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-autolink-literal": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-autolink-literal/-/mdast-util-gfm-autolink-literal-2.0.1.tgz", + "integrity": "sha512-5HVP2MKaP6L+G6YaxPNjuL0BPrq9orG3TsrZ9YXbA3vDw/ACI4MEsnoDpn6ZNm7GnZgtAcONJyPhOP8tNJQavQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "ccount": "^2.0.0", + "devlop": "^1.0.0", + "mdast-util-find-and-replace": "^3.0.0", + "micromark-util-character": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-footnote/-/mdast-util-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-sqpDWlsHn7Ac9GNZQMeUzPQSMzR6Wv0WKRNvQRg0KqHh02fpTz69Qc1QSseNX29bhz1ROIyNyxExfawVKTm1GQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-strikethrough": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-strikethrough/-/mdast-util-gfm-strikethrough-2.0.0.tgz", + "integrity": "sha512-mKKb915TF+OC5ptj5bJ7WFRPdYtuHv0yTRxK2tJvi+BDqbkiG7h7u/9SI89nRAYcmap2xHQL9D+QG/6wSrTtXg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-table": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-table/-/mdast-util-gfm-table-2.0.0.tgz", + "integrity": "sha512-78UEvebzz/rJIxLvE7ZtDd/vIQ0RHv+3Mh5DR96p7cS7HsBhYIICDBCu8csTNWNO6tBWfqXPWekRuj2FNOGOZg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "markdown-table": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-gfm-task-list-item": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-gfm-task-list-item/-/mdast-util-gfm-task-list-item-2.0.0.tgz", + "integrity": "sha512-IrtvNvjxC1o06taBAVJznEnkiHxLFTzgonUdy8hzFVeDun0uTjxxrRGVaNFqkU1wJR3RBPEfsxmU6jDWPofrTQ==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-math": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-math/-/mdast-util-math-3.0.0.tgz", + "integrity": "sha512-Tl9GBNeG/AhJnQM221bJR2HPvLOSnLE/T9cJI9tlc6zwQk2nPk/4f0cHkOdEixQPC/j8UtKDdITswvLAy1OZ1w==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "longest-streak": "^3.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.1.0", + "unist-util-remove-position": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-expression": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-expression/-/mdast-util-mdx-expression-2.0.1.tgz", + "integrity": "sha512-J6f+9hUp+ldTZqKRSg7Vw5V6MqjATc+3E4gf3CFNcuZNWD8XdyI6zQ8GqH7f8169MM6P7hMBRDVGnn7oHB9kXQ==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdx-jsx": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/mdast-util-mdx-jsx/-/mdast-util-mdx-jsx-3.2.0.tgz", + "integrity": "sha512-lj/z8v0r6ZtsN/cGNNtemmmfoLAFZnjMbNyLzBafjzikOM+glrjNHPlf6lQDOTccj9n5b0PPihEBbhneMyGs1Q==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "ccount": "^2.0.0", + "devlop": "^1.1.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0", + "parse-entities": "^4.0.0", + "stringify-entities": "^4.0.0", + "unist-util-stringify-position": "^4.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-mdxjs-esm": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/mdast-util-mdxjs-esm/-/mdast-util-mdxjs-esm-2.0.1.tgz", + "integrity": "sha512-EcmOpxsZ96CvlP03NghtH1EsLtr0n9Tm4lPUJUBccV9RwUOneqSycg19n5HGzCf+10LozMRSObtVr3ee1WoHtg==", + "license": "MIT", + "dependencies": { + "@types/estree-jsx": "^1.0.0", + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "mdast-util-from-markdown": "^2.0.0", + "mdast-util-to-markdown": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-phrasing": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/mdast-util-phrasing/-/mdast-util-phrasing-4.1.0.tgz", + "integrity": "sha512-TqICwyvJJpBwvGAMZjj4J2n0X8QWp21b9l0o7eXyVJ25YNWYbJDVIyD1bZXE6WtV6RmKJVYmQAKWa0zWOABz2w==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-hast": { + "version": "13.2.1", + "resolved": "https://registry.npmjs.org/mdast-util-to-hast/-/mdast-util-to-hast-13.2.1.tgz", + "integrity": "sha512-cctsq2wp5vTsLIcaymblUriiTcZd0CwWtCbLvrOzYCDZoWyMNV8sZ7krj09FSnsiJi3WVsHLM4k6Dq/yaPyCXA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "@ungap/structured-clone": "^1.0.0", + "devlop": "^1.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "trim-lines": "^3.0.0", + "unist-util-position": "^5.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-markdown": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/mdast-util-to-markdown/-/mdast-util-to-markdown-2.1.2.tgz", + "integrity": "sha512-xj68wMTvGXVOKonmog6LwyJKrYXZPvlwabaryTjLh9LuvovB/KAH+kvi8Gjj+7rJjsFi23nkUxRQv1KqSroMqA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "@types/unist": "^3.0.0", + "longest-streak": "^3.0.0", + "mdast-util-phrasing": "^4.0.0", + "mdast-util-to-string": "^4.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-decode-string": "^2.0.0", + "unist-util-visit": "^5.0.0", + "zwitch": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/mdast-util-to-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/mdast-util-to-string/-/mdast-util-to-string-4.0.0.tgz", + "integrity": "sha512-0H44vDimn51F0YwvxSJSm0eCDOJTRlmN0R1yBh4HLj9wiV1Dn0QoXGbvFAWj2hSItVTlCmBF1hqKlIyUBVFLPg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromark": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/micromark/-/micromark-4.0.2.tgz", + "integrity": "sha512-zpe98Q6kvavpCr1NPVSCMebCKfD7CA2NqZ+rykeNhONIJBpc1tFKt9hucLGwha3jNTNI8lHpctWJWoimVF4PfA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "@types/debug": "^4.0.0", + "debug": "^4.0.0", + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-core-commonmark": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/micromark-core-commonmark/-/micromark-core-commonmark-2.0.3.tgz", + "integrity": "sha512-RDBrHEMSxVFLg6xvnXmb1Ayr2WzLAWjeSATAoxwKYJV94TeNavgoIdA0a9ytzDSVzBy2YKFK+emCPOEibLeCrg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "devlop": "^1.0.0", + "micromark-factory-destination": "^2.0.0", + "micromark-factory-label": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-factory-title": "^2.0.0", + "micromark-factory-whitespace": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-html-tag-name": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-subtokenize": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-extension-gfm": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm/-/micromark-extension-gfm-3.0.0.tgz", + "integrity": "sha512-vsKArQsicm7t0z2GugkCKtZehqUm31oeGBV/KVSorWSy8ZlNAv7ytjFhvaryUiCUJYqs+NoE6AFhpQvBTM6Q4w==", + "license": "MIT", + "dependencies": { + "micromark-extension-gfm-autolink-literal": "^2.0.0", + "micromark-extension-gfm-footnote": "^2.0.0", + "micromark-extension-gfm-strikethrough": "^2.0.0", + "micromark-extension-gfm-table": "^2.0.0", + "micromark-extension-gfm-tagfilter": "^2.0.0", + "micromark-extension-gfm-task-list-item": "^2.0.0", + "micromark-util-combine-extensions": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-autolink-literal": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-autolink-literal/-/micromark-extension-gfm-autolink-literal-2.1.0.tgz", + "integrity": "sha512-oOg7knzhicgQ3t4QCjCWgTmfNhvQbDDnJeVu9v81r7NltNCVmhPy1fJRX27pISafdjL+SVc4d3l48Gb6pbRypw==", + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-footnote": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-footnote/-/micromark-extension-gfm-footnote-2.1.0.tgz", + "integrity": "sha512-/yPhxI1ntnDNsiHtzLKYnE3vf9JZ6cAisqVDauhp4CEHxlb4uoOTxOCJ+9s51bIB8U1N1FJ1RXOKTIlD5B/gqw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-core-commonmark": "^2.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-normalize-identifier": "^2.0.0", + "micromark-util-sanitize-uri": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-strikethrough": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-strikethrough/-/micromark-extension-gfm-strikethrough-2.1.0.tgz", + "integrity": "sha512-ADVjpOOkjz1hhkZLlBiYA9cR2Anf8F4HqZUO6e5eDcPQd0Txw5fxLzzxnEkSkfnD0wziSGiv7sYhk/ktvbf1uw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-classify-character": "^2.0.0", + "micromark-util-resolve-all": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-table": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-table/-/micromark-extension-gfm-table-2.1.1.tgz", + "integrity": "sha512-t2OU/dXXioARrC6yWfJ4hqB7rct14e8f7m0cbI5hUmDyyIlwv5vEtooptH8INkbLzOatzKuVbQmAYcbWoyz6Dg==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-tagfilter": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-tagfilter/-/micromark-extension-gfm-tagfilter-2.0.0.tgz", + "integrity": "sha512-xHlTOmuCSotIA8TW1mDIM6X2O1SiX5P9IuDtqGonFhEK0qgRI4yeC6vMxEV2dgyr2TiD+2PQ10o+cOhdVAcwfg==", + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-gfm-task-list-item": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-gfm-task-list-item/-/micromark-extension-gfm-task-list-item-2.1.0.tgz", + "integrity": "sha512-qIBZhqxqI6fjLDYFTBIa4eivDMnP+OZqsNwmQ3xNLE4Cxwc+zfQEfbs6tzAo2Hjq+bh6q5F+Z8/cksrLFYWQQw==", + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-extension-math": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/micromark-extension-math/-/micromark-extension-math-3.1.0.tgz", + "integrity": "sha512-lvEqd+fHjATVs+2v/8kg9i5Q0AP2k85H0WUOwpIVvUML8BapsMvh1XAogmQjOCsLpoKRCVQqEkQBB3NhVBcsOg==", + "license": "MIT", + "dependencies": { + "@types/katex": "^0.16.0", + "devlop": "^1.0.0", + "katex": "^0.16.0", + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/micromark-factory-destination": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-destination/-/micromark-factory-destination-2.0.1.tgz", + "integrity": "sha512-Xe6rDdJlkmbFRExpTOmRj9N3MaWmbAgdpSrBQvCFqhezUn4AHqJHbaEnfbVYYiexVSs//tqOdY/DxhjdCiJnIA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-label": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-label/-/micromark-factory-label-2.0.1.tgz", + "integrity": "sha512-VFMekyQExqIW7xIChcXn4ok29YE3rnuyveW3wZQWWqF4Nv9Wk5rgJ99KzPvHjkmPXF93FXIbBp6YdW3t71/7Vg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-space": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-space/-/micromark-factory-space-2.0.1.tgz", + "integrity": "sha512-zRkxjtBxxLd2Sc0d+fbnEunsTj46SWXgXciZmHq0kDYGnck/ZSGj9/wULTV95uoeYiK5hRXP2mJ98Uo4cq/LQg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-title": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-title/-/micromark-factory-title-2.0.1.tgz", + "integrity": "sha512-5bZ+3CjhAd9eChYTHsjy6TGxpOFSKgKKJPJxr293jTbfry2KDoWkhBb6TcPVB4NmzaPhMs1Frm9AZH7OD4Cjzw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-factory-whitespace": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-factory-whitespace/-/micromark-factory-whitespace-2.0.1.tgz", + "integrity": "sha512-Ob0nuZ3PKt/n0hORHyvoD9uZhr+Za8sFoP+OnMcnWK5lngSzALgQYKMr9RJVOWLqQYuyn6ulqGWSXdwf6F80lQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-factory-space": "^2.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-character": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/micromark-util-character/-/micromark-util-character-2.1.1.tgz", + "integrity": "sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-chunked": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-chunked/-/micromark-util-chunked-2.0.1.tgz", + "integrity": "sha512-QUNFEOPELfmvv+4xiNg2sRYeS/P84pTW0TCgP5zc9FpXetHY0ab7SxKyAQCNCc1eK0459uoLI1y5oO5Vc1dbhA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-classify-character": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-classify-character/-/micromark-util-classify-character-2.0.1.tgz", + "integrity": "sha512-K0kHzM6afW/MbeWYWLjoHQv1sgg2Q9EccHEDzSkxiP/EaagNzCm7T/WMKZ3rjMbvIpvBiZgwR3dKMygtA4mG1Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-combine-extensions": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-combine-extensions/-/micromark-util-combine-extensions-2.0.1.tgz", + "integrity": "sha512-OnAnH8Ujmy59JcyZw8JSbK9cGpdVY44NKgSM7E9Eh7DiLS2E9RNQf0dONaGDzEG9yjEl5hcqeIsj4hfRkLH/Bg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-chunked": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-numeric-character-reference": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-decode-numeric-character-reference/-/micromark-util-decode-numeric-character-reference-2.0.2.tgz", + "integrity": "sha512-ccUbYk6CwVdkmCQMyr64dXz42EfHGkPQlBj5p7YVGzq8I7CtjXZJrubAYezf7Rp+bjPseiROqe7G6foFd+lEuw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-decode-string": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-decode-string/-/micromark-util-decode-string-2.0.1.tgz", + "integrity": "sha512-nDV/77Fj6eH1ynwscYTOsbK7rR//Uj0bZXBwJZRfaLEJ1iGBR6kIfNmlNqaqJf649EP0F3NWNdeJi03elllNUQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "decode-named-character-reference": "^1.0.0", + "micromark-util-character": "^2.0.0", + "micromark-util-decode-numeric-character-reference": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-encode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz", + "integrity": "sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-html-tag-name": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-html-tag-name/-/micromark-util-html-tag-name-2.0.1.tgz", + "integrity": "sha512-2cNEiYDhCWKI+Gs9T0Tiysk136SnR13hhO8yW6BGNyhOC4qYFnwF1nKfD3HFAIXA5c45RrIG1ub11GiXeYd1xA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-normalize-identifier": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-normalize-identifier/-/micromark-util-normalize-identifier-2.0.1.tgz", + "integrity": "sha512-sxPqmo70LyARJs0w2UclACPUUEqltCkJ6PhKdMIDuJ3gSf/Q+/GIe3WKl0Ijb/GyH9lOpUkRAO2wp0GVkLvS9Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-resolve-all": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-resolve-all/-/micromark-util-resolve-all-2.0.1.tgz", + "integrity": "sha512-VdQyxFWFT2/FGJgwQnJYbe1jjQoNTS4RjglmSjTUlpUMa95Htx9NHeYW4rGDJzbjvCsl9eLjMQwGeElsqmzcHg==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-sanitize-uri": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz", + "integrity": "sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "micromark-util-character": "^2.0.0", + "micromark-util-encode": "^2.0.0", + "micromark-util-symbol": "^2.0.0" + } + }, + "node_modules/micromark-util-subtokenize": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/micromark-util-subtokenize/-/micromark-util-subtokenize-2.1.0.tgz", + "integrity": "sha512-XQLu552iSctvnEcgXw6+Sx75GflAPNED1qx7eBJ+wydBb2KCbRZe+NwvIEEMM83uml1+2WSXpBAcp9IUCgCYWA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT", + "dependencies": { + "devlop": "^1.0.0", + "micromark-util-chunked": "^2.0.0", + "micromark-util-symbol": "^2.0.0", + "micromark-util-types": "^2.0.0" + } + }, + "node_modules/micromark-util-symbol": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz", + "integrity": "sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromark-util-types": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/micromark-util-types/-/micromark-util-types-2.0.2.tgz", + "integrity": "sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==", + "funding": [ + { + "type": "GitHub Sponsors", + "url": "https://github.com/sponsors/unifiedjs" + }, + { + "type": "OpenCollective", + "url": "https://opencollective.com/unified" + } + ], + "license": "MIT" + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/motion-dom": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", + "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.36.0" + } + }, + "node_modules/motion-utils": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/mz": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/mz/-/mz-2.7.0.tgz", + "integrity": "sha512-z81GNO7nnYMEhrGh9LeymoE4+Yr0Wn5McHIZMK5cfQCl+NDX08sCZgUc9/6MHni9IWuFLm1Z3HTCXu2z9fN62Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0", + "object-assign": "^4.0.1", + "thenify-all": "^1.0.0" + } + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/normalize-path": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", + "integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/nth-check": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", + "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "boolbase": "^1.0.0" + }, + "funding": { + "url": "https://github.com/fb55/nth-check?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-hash": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", + "integrity": "sha512-RSn9F68PjH9HqtltsSnqYC1XXoWe9Bju5+213R98cNGttag9q9yAOTzdbsqvIa7aNm5WffBZFpWYr2aWrklWAw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/parse-entities": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/parse-entities/-/parse-entities-4.0.2.tgz", + "integrity": "sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^2.0.0", + "character-entities-legacy": "^3.0.0", + "character-reference-invalid": "^2.0.0", + "decode-named-character-reference": "^1.0.0", + "is-alphanumerical": "^2.0.0", + "is-decimal": "^2.0.0", + "is-hexadecimal": "^2.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/parse-entities/node_modules/@types/unist": { + "version": "2.0.11", + "resolved": "https://registry.npmjs.org/@types/unist/-/unist-2.0.11.tgz", + "integrity": "sha512-CmBKiL6NNo/OqgmMn95Fk9Whlp2mtvIv+KNpQKN2F4SjvrEesubTRWGYSg+BnWZOnlCaSTU1sMpsBOzgbYhnsA==", + "license": "MIT" + }, + "node_modules/parse5": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-7.3.0.tgz", + "integrity": "sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==", + "license": "MIT", + "dependencies": { + "entities": "^6.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-htmlparser2-tree-adapter": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-7.1.0.tgz", + "integrity": "sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "domhandler": "^5.0.3", + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "dev": true, + "license": "MIT", + "dependencies": { + "parse5": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/parse5/node_modules/entities": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/entities/-/entities-6.0.1.tgz", + "integrity": "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pify": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/pify/-/pify-2.3.0.tgz", + "integrity": "sha512-udgsAY+fTnvv7kI7aaxbqwWNb0AHiB0qBO89PZKPkoTmGOgdbrHDKD+0B2X4uTfJ/FT1R09r9gTsjUjNJotuog==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pirates": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/pirates/-/pirates-4.0.7.tgz", + "integrity": "sha512-TfySrs/5nm8fQJDcBDuUng3VOUKsd7S+zqvbOTiGXHfxX4wK31ard+hoNuvkicM/2YFzlpDgABOevKSsB4G/FA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/postcss": { + "version": "8.5.13", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.13.tgz", + "integrity": "sha512-qif0+jGGZoLWdHey3UFHHWP0H7Gbmsk8T5VEqyYFbWqPr1XqvLGBbk/sl8V5exGmcYJklJOhOQq1pV9IcsiFag==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/postcss-import": { + "version": "15.1.0", + "resolved": "https://registry.npmjs.org/postcss-import/-/postcss-import-15.1.0.tgz", + "integrity": "sha512-hpr+J05B2FVYUAXHeK1YyI267J/dDDhMU6B6civm8hSY1jYJnBXxzKDKDswzJmtLHryrjhnDjqqp/49t8FALew==", + "dev": true, + "license": "MIT", + "dependencies": { + "postcss-value-parser": "^4.0.0", + "read-cache": "^1.0.0", + "resolve": "^1.1.7" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "postcss": "^8.0.0" + } + }, + "node_modules/postcss-js": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/postcss-js/-/postcss-js-4.1.0.tgz", + "integrity": "sha512-oIAOTqgIo7q2EOwbhb8UalYePMvYoIeRY2YKntdpFQXNosSu3vLrniGgmH9OKs/qAkfoj5oB3le/7mINW1LCfw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "camelcase-css": "^2.0.1" + }, + "engines": { + "node": "^12 || ^14 || >= 16" + }, + "peerDependencies": { + "postcss": "^8.4.21" + } + }, + "node_modules/postcss-load-config": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/postcss-load-config/-/postcss-load-config-6.0.1.tgz", + "integrity": "sha512-oPtTM4oerL+UXmx+93ytZVN82RrlY/wPUV8IeDxFrzIjXOLF1pN+EmKPLbubvKHT2HC20xXsCAH2Z+CKV6Oz/g==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "lilconfig": "^3.1.1" + }, + "engines": { + "node": ">= 18" + }, + "peerDependencies": { + "jiti": ">=1.21.0", + "postcss": ">=8.0.9", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + }, + "postcss": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/postcss-nested": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/postcss-nested/-/postcss-nested-6.2.0.tgz", + "integrity": "sha512-HQbt28KulC5AJzG+cZtj9kvKB93CFCdLvog1WFLf1D+xmMvPGlBstkpTEZfK5+AN9hfJocyBFCNiqyS48bpgzQ==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "postcss-selector-parser": "^6.1.1" + }, + "engines": { + "node": ">=12.0" + }, + "peerDependencies": { + "postcss": "^8.2.14" + } + }, + "node_modules/postcss-selector-parser": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-6.1.2.tgz", + "integrity": "sha512-Q8qQfPiZ+THO/3ZrOrO0cJJKfpYCagtMUkXbnEfmgUjwXg6z/WBeOyS9APBBPCTSiDV+s4SwQGu8yFsiMRIudg==", + "dev": true, + "license": "MIT", + "dependencies": { + "cssesc": "^3.0.0", + "util-deprecate": "^1.0.2" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/postcss-value-parser": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/postcss-value-parser/-/postcss-value-parser-4.2.0.tgz", + "integrity": "sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/property-information": { + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/property-information/-/property-information-7.1.0.tgz", + "integrity": "sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-canvas-confetti": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/react-canvas-confetti/-/react-canvas-confetti-2.0.7.tgz", + "integrity": "sha512-DIj44O35TPAwJkUSIZqWdVsgAMHtVf8h7YNmnr3jF3bn5mG+d7Rh9gEcRmdJfYgRzh6K+MAGujwUoIqQyLnMJw==", + "license": "MIT", + "dependencies": { + "@types/canvas-confetti": "^1.6.4", + "canvas-confetti": "^1.9.2" + }, + "peerDependencies": { + "react": "*" + } + }, + "node_modules/react-countup": { + "version": "6.5.3", + "resolved": "https://registry.npmjs.org/react-countup/-/react-countup-6.5.3.tgz", + "integrity": "sha512-udnqVQitxC7QWADSPDOxVWULkLvKUWrDapn5i53HE4DPRVgs+Y5rr4bo25qEl8jSh+0l2cToJgGMx+clxPM3+w==", + "license": "MIT", + "dependencies": { + "countup.js": "^2.8.0" + }, + "peerDependencies": { + "react": ">= 16.3.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-markdown": { + "version": "10.1.0", + "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", + "integrity": "sha512-qKxVopLT/TyA6BX3Ue5NwabOsAzm0Q7kAPwq6L+wWDwisYs7R8vZ0nRXqq6rkueboxpkjvLGU9fWifiX/ZZFxQ==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "devlop": "^1.0.0", + "hast-util-to-jsx-runtime": "^2.0.0", + "html-url-attributes": "^3.0.0", + "mdast-util-to-hast": "^13.0.0", + "remark-parse": "^11.0.0", + "remark-rehype": "^11.0.0", + "unified": "^11.0.0", + "unist-util-visit": "^5.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + }, + "peerDependencies": { + "@types/react": ">=18", + "react": ">=18" + } + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.3.tgz", + "integrity": "sha512-XRnlbKMTmktBkjCLE8/XcZFlnHvr2Ltdr1eJX4idL55/9BbORzyZEaIkBFDhFGCEWBBItsVrDxwx3gnisMitdw==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.3", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.3.tgz", + "integrity": "sha512-pxPcv1AczD4vso7G4Z3TKcvlxK7g7TNt3/FNGMhfqyntocvYKj+GCatfigGDjbLozC4baguJ0ReCigoDJXb0ag==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.2", + "react-router": "6.30.3" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/read-cache": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz", + "integrity": "sha512-Owdv/Ft7IjOgm/i0xvNDZ1LrRANRfew4b2prF3OWMQLxLfu3bS8FVhCsrSCMK4lR56Y9ya+AThoTpDCTxCmpRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "pify": "^2.3.0" + } + }, + "node_modules/readdirp": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz", + "integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "picomatch": "^2.2.1" + }, + "engines": { + "node": ">=8.10.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/rehype-katex": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/rehype-katex/-/rehype-katex-7.0.1.tgz", + "integrity": "sha512-OiM2wrZ/wuhKkigASodFoo8wimG3H12LWQaH8qSPVJn9apWKFSH3YOCtbKpBorTVw/eI7cuT21XBbvwEswbIOA==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/katex": "^0.16.0", + "hast-util-from-html-isomorphic": "^2.0.0", + "hast-util-to-text": "^4.0.0", + "katex": "^0.16.0", + "unist-util-visit-parents": "^6.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-gfm": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/remark-gfm/-/remark-gfm-4.0.1.tgz", + "integrity": "sha512-1quofZ2RQ9EWdeN34S79+KExV1764+wCUGop5CPL1WGdD0ocPpu91lzPGbwWMECpEpd42kJGQwzRfyov9j4yNg==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-gfm": "^3.0.0", + "micromark-extension-gfm": "^3.0.0", + "remark-parse": "^11.0.0", + "remark-stringify": "^11.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-math": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/remark-math/-/remark-math-6.0.0.tgz", + "integrity": "sha512-MMqgnP74Igy+S3WwnhQ7kqGlEerTETXMvJhrUzDikVZ2/uogJCb+WHUg97hK9/jcfc0dkD73s3LN8zU49cTEtA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-math": "^3.0.0", + "micromark-extension-math": "^3.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-parse": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-parse/-/remark-parse-11.0.0.tgz", + "integrity": "sha512-FCxlKLNGknS5ba/1lmpYijMUzX2esxW5xQqjWxw2eHFfS2MSdaHVINFmhjo+qN1WhZhNimq0dZATN9pH0IDrpA==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-from-markdown": "^2.0.0", + "micromark-util-types": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-rehype": { + "version": "11.1.2", + "resolved": "https://registry.npmjs.org/remark-rehype/-/remark-rehype-11.1.2.tgz", + "integrity": "sha512-Dh7l57ianaEoIpzbp0PC9UKAdCSVklD8E5Rpw7ETfbTl3FqcOOgq5q2LVDhgGCkaBv7p24JXikPdvhhmHvKMsw==", + "license": "MIT", + "dependencies": { + "@types/hast": "^3.0.0", + "@types/mdast": "^4.0.0", + "mdast-util-to-hast": "^13.0.0", + "unified": "^11.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/remark-stringify": { + "version": "11.0.0", + "resolved": "https://registry.npmjs.org/remark-stringify/-/remark-stringify-11.0.0.tgz", + "integrity": "sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==", + "license": "MIT", + "dependencies": { + "@types/mdast": "^4.0.0", + "mdast-util-to-markdown": "^2.0.0", + "unified": "^11.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/resolve": { + "version": "1.22.12", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", + "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rollup": { + "version": "4.60.2", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.2.tgz", + "integrity": "sha512-J9qZyW++QK/09NyN/zeO0dG/1GdGfyp9lV8ajHnRVLfo/uFsbji5mHnDgn/qYdUHyCkM2N+8VyspgZclfAh0eQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.8" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@rollup/rollup-android-arm-eabi": "4.60.2", + "@rollup/rollup-android-arm64": "4.60.2", + "@rollup/rollup-darwin-arm64": "4.60.2", + "@rollup/rollup-darwin-x64": "4.60.2", + "@rollup/rollup-freebsd-arm64": "4.60.2", + "@rollup/rollup-freebsd-x64": "4.60.2", + "@rollup/rollup-linux-arm-gnueabihf": "4.60.2", + "@rollup/rollup-linux-arm-musleabihf": "4.60.2", + "@rollup/rollup-linux-arm64-gnu": "4.60.2", + "@rollup/rollup-linux-arm64-musl": "4.60.2", + "@rollup/rollup-linux-loong64-gnu": "4.60.2", + "@rollup/rollup-linux-loong64-musl": "4.60.2", + "@rollup/rollup-linux-ppc64-gnu": "4.60.2", + "@rollup/rollup-linux-ppc64-musl": "4.60.2", + "@rollup/rollup-linux-riscv64-gnu": "4.60.2", + "@rollup/rollup-linux-riscv64-musl": "4.60.2", + "@rollup/rollup-linux-s390x-gnu": "4.60.2", + "@rollup/rollup-linux-x64-gnu": "4.60.2", + "@rollup/rollup-linux-x64-musl": "4.60.2", + "@rollup/rollup-openbsd-x64": "4.60.2", + "@rollup/rollup-openharmony-arm64": "4.60.2", + "@rollup/rollup-win32-arm64-msvc": "4.60.2", + "@rollup/rollup-win32-ia32-msvc": "4.60.2", + "@rollup/rollup-win32-x64-gnu": "4.60.2", + "@rollup/rollup-win32-x64-msvc": "4.60.2", + "fsevents": "~2.3.2" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true, + "license": "MIT" + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/space-separated-tokens": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz", + "integrity": "sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/stringify-entities": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/stringify-entities/-/stringify-entities-4.0.4.tgz", + "integrity": "sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==", + "license": "MIT", + "dependencies": { + "character-entities-html4": "^2.0.0", + "character-entities-legacy": "^3.0.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, + "node_modules/sucrase": { + "version": "3.35.1", + "resolved": "https://registry.npmjs.org/sucrase/-/sucrase-3.35.1.tgz", + "integrity": "sha512-DhuTmvZWux4H1UOnWMB3sk0sbaCVOoQZjv8u1rDoTV0HTdGem9hkAZtl4JZy8P2z4Bg0nT+YMeOFyVr4zcG5Tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.2", + "commander": "^4.0.0", + "lines-and-columns": "^1.1.6", + "mz": "^2.7.0", + "pirates": "^4.0.1", + "tinyglobby": "^0.2.11", + "ts-interface-checker": "^0.1.9" + }, + "bin": { + "sucrase": "bin/sucrase", + "sucrase-node": "bin/sucrase-node" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/sucrase/node_modules/commander": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", + "integrity": "sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/tailwindcss": { + "version": "3.4.19", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.4.19.tgz", + "integrity": "sha512-3ofp+LL8E+pK/JuPLPggVAIaEuhvIz4qNcf3nA1Xn2o/7fb7s/TYpHhwGDv1ZU3PkBluUVaF8PyCHcm48cKLWQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "arg": "^5.0.2", + "chokidar": "^3.6.0", + "didyoumean": "^1.2.2", + "dlv": "^1.1.3", + "fast-glob": "^3.3.2", + "glob-parent": "^6.0.2", + "is-glob": "^4.0.3", + "jiti": "^1.21.7", + "lilconfig": "^3.1.3", + "micromatch": "^4.0.8", + "normalize-path": "^3.0.0", + "object-hash": "^3.0.0", + "picocolors": "^1.1.1", + "postcss": "^8.4.47", + "postcss-import": "^15.1.0", + "postcss-js": "^4.0.1", + "postcss-load-config": "^4.0.2 || ^5.0 || ^6.0", + "postcss-nested": "^6.2.0", + "postcss-selector-parser": "^6.1.2", + "resolve": "^1.22.8", + "sucrase": "^3.35.0" + }, + "bin": { + "tailwind": "lib/cli.js", + "tailwindcss": "lib/cli.js" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/thenify": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/thenify/-/thenify-3.3.1.tgz", + "integrity": "sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==", + "dev": true, + "license": "MIT", + "dependencies": { + "any-promise": "^1.0.0" + } + }, + "node_modules/thenify-all": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/thenify-all/-/thenify-all-1.6.0.tgz", + "integrity": "sha512-RNxQH/qI8/t3thXJDwcstUO4zeqo64+Uy/+sNVRBx4Xn2OX+OZ9oP+iJnNFqplFra2ZUVeKCSa2oVWi3T4uVmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "thenify": ">= 3.1.0 < 4" + }, + "engines": { + "node": ">=0.8" + } + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/trim-lines": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", + "integrity": "sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/trough": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/trough/-/trough-2.2.0.tgz", + "integrity": "sha512-tmMpK00BjZiUyVyvrBK7knerNgmgvcV/KLVyuma/SC+TQN167GrMRciANTz09+k3zW8L8t60jWO1GpfkZdjTaw==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/ts-interface-checker": { + "version": "0.1.13", + "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", + "integrity": "sha512-Y/arvbn+rrz3JCKl9C4kVNfTfSm2/mEp5FSz5EsZSANGPSlQrpRI5M4PKF+mJnE52jOO90PnPSc3Ur3bTQw0gA==", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/undici": { + "version": "7.25.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.25.0.tgz", + "integrity": "sha512-xXnp4kTyor2Zq+J1FfPI6Eq3ew5h6Vl0F/8d9XU5zZQf1tX9s2Su1/3PiMmUANFULpmksxkClamIZcaUqryHsQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/unified": { + "version": "11.0.5", + "resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz", + "integrity": "sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "bail": "^2.0.0", + "devlop": "^1.0.0", + "extend": "^3.0.0", + "is-plain-obj": "^4.0.0", + "trough": "^2.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-find-after": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-find-after/-/unist-util-find-after-5.0.0.tgz", + "integrity": "sha512-amQa0Ep2m6hE2g72AugUItjbuM8X8cGQnFoHk0pGfrFeT9GZhzN5SW8nRsiGKK7Aif4CrACPENkA6P/Lw6fHGQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-is": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/unist-util-is/-/unist-util-is-6.0.1.tgz", + "integrity": "sha512-LsiILbtBETkDz8I9p1dQ0uyRUWuaQzd/cuEeS1hoRSyW5E5XGmTzlwY1OrNzzakGowI9Dr/I8HVaw4hTtnxy8g==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-position/-/unist-util-position-5.0.0.tgz", + "integrity": "sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-remove-position": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unist-util-remove-position/-/unist-util-remove-position-5.0.0.tgz", + "integrity": "sha512-Hp5Kh3wLxv0PHj9m2yZhhLt58KzPtEYKQQ4yxfYFEO7EvHwzyDYnduhHnY1mDxoqr7VUwVuHXk9RXKIiYS1N8Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-visit": "^5.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-stringify-position": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz", + "integrity": "sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/unist-util-visit/-/unist-util-visit-5.1.0.tgz", + "integrity": "sha512-m+vIdyeCOpdr/QeQCu2EzxX/ohgS8KbnPDgFni4dQsfSCtpz8UqDyY5GjRru8PDKuYn7Fq19j1CQ+nJSsGKOzg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0", + "unist-util-visit-parents": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/unist-util-visit-parents": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/unist-util-visit-parents/-/unist-util-visit-parents-6.0.2.tgz", + "integrity": "sha512-goh1s1TBrqSqukSc8wrjwWhL0hiJxgA8m4kFxGlQ+8FYQ3C/m11FcTs4YYem7V664AhHVvgoQLk890Ssdsr2IQ==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-is": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", + "dev": true, + "license": "MIT" + }, + "node_modules/vfile": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/vfile/-/vfile-6.0.3.tgz", + "integrity": "sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile-message": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-location": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/vfile-location/-/vfile-location-5.0.3.tgz", + "integrity": "sha512-5yXvWDEgqeiYiBe1lbxYF7UMAIm/IcopxMHrMQDq3nvKcjPKIhZklUKL+AE7J7uApI4kwe2snsK+eI6UTj9EHg==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "vfile": "^6.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/vfile-message": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/vfile-message/-/vfile-message-4.0.3.tgz", + "integrity": "sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==", + "license": "MIT", + "dependencies": { + "@types/unist": "^3.0.0", + "unist-util-stringify-position": "^4.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/unified" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/web-namespaces": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/web-namespaces/-/web-namespaces-2.0.1.tgz", + "integrity": "sha512-bKr1DkiNa2krS7qxNtdrtHAmzuYGFQLiQ13TsorsdT6ULTkPLKuu5+GsFpDlg6JFjUTwX2DyhMPG2be8uPrqsQ==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "deprecated": "Use @exodus/bytes instead for a more spec-conformant and faster implementation", + "dev": true, + "license": "MIT", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/zwitch": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/zwitch/-/zwitch-2.0.4.tgz", + "integrity": "sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/wooorm" + } + } + } +} diff --git a/exam-app/package.json b/exam-app/package.json new file mode 100644 index 0000000000000000000000000000000000000000..569dbbf3c52adbdc073740ce5ad563bc6fe7e817 --- /dev/null +++ b/exam-app/package.json @@ -0,0 +1,50 @@ +{ + "name": "exam-app", + "version": "0.1.0", + "private": true, + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest", + "crawl": "node scripts/crawl/index.js", + "crawl:questions": "node scripts/crawl/index.js --only=questions", + "crawl:schools": "node scripts/crawl/index.js --only=schools", + "crawl:validate": "node scripts/crawl/pipeline/validate.js --check-only", + "crawl:publish": "node scripts/crawl/pipeline/publish.js", + "crawl:generate": "node scripts/crawl/generate.js", + "crawl:publish:check": "node scripts/crawl/pipeline/publish.js --check-only", + "crawl:preview": "node scripts/crawl/pipeline/buildPreview.js" + }, + "dependencies": { + "@react-oauth/google": "^0.13.5", + "axios": "^1.7.7", + "canvas-confetti": "^1.9.4", + "dompurify": "^3.4.2", + "framer-motion": "^12.38.0", + "html-to-image": "^1.11.13", + "katex": "^0.16.45", + "mathlive": "^0.109.1", + "react": "^18.3.1", + "react-canvas-confetti": "^2.0.7", + "react-countup": "^6.5.3", + "react-dom": "^18.3.1", + "react-markdown": "^10.1.0", + "react-router-dom": "^6.27.0", + "recharts": "^2.13.3", + "rehype-katex": "^7.0.1", + "remark-gfm": "^4.0.1", + "remark-math": "^6.0.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.3", + "autoprefixer": "^10.4.20", + "cheerio": "^1.0.0", + "postcss": "^8.4.47", + "tailwindcss": "^3.4.14", + "vite": "^5.4.10", + "vitest": "^2.1.4" + } +} diff --git a/exam-app/postcss.config.js b/exam-app/postcss.config.js new file mode 100644 index 0000000000000000000000000000000000000000..2e7af2b7f1a6f391da1631d93968a9d487ba977d --- /dev/null +++ b/exam-app/postcss.config.js @@ -0,0 +1,6 @@ +export default { + plugins: { + tailwindcss: {}, + autoprefixer: {}, + }, +} diff --git a/exam-app/public/_headers b/exam-app/public/_headers new file mode 100644 index 0000000000000000000000000000000000000000..cf0f2f07df96facfa29ff8f46c9483aeb73b2a76 --- /dev/null +++ b/exam-app/public/_headers @@ -0,0 +1,6 @@ +/* + Content-Security-Policy: default-src 'self'; script-src 'self' 'unsafe-inline' https://accounts.google.com https://www.geogebra.org; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self'; connect-src 'self' https://ai-router.locdo.tech https://*.hf.space https://accounts.google.com; frame-ancestors 'none' + X-Frame-Options: DENY + X-Content-Type-Options: nosniff + Referrer-Policy: strict-origin-when-cross-origin + Permissions-Policy: clipboard-read=(), clipboard-write=() diff --git a/exam-app/public/_redirects b/exam-app/public/_redirects new file mode 100644 index 0000000000000000000000000000000000000000..7797f7c6a7356b0d451d11a49925df854c22e978 --- /dev/null +++ b/exam-app/public/_redirects @@ -0,0 +1 @@ +/* /index.html 200 diff --git a/exam-app/public/favicon.svg b/exam-app/public/favicon.svg new file mode 100644 index 0000000000000000000000000000000000000000..0940e6f9240e9ca786ff13f80794d09c31a48401 --- /dev/null +++ b/exam-app/public/favicon.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/exam-app/public/fonts/fraunces-latin-ext.woff2 b/exam-app/public/fonts/fraunces-latin-ext.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..b6e3e3a58de54b5818569977f4f87d6918ce1212 Binary files /dev/null and b/exam-app/public/fonts/fraunces-latin-ext.woff2 differ diff --git a/exam-app/public/fonts/fraunces-latin.woff2 b/exam-app/public/fonts/fraunces-latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..cb295bf0e09bde54e1846be5553ddcf0e089e680 Binary files /dev/null and b/exam-app/public/fonts/fraunces-latin.woff2 differ diff --git a/exam-app/public/fonts/fraunces-vietnamese.woff2 b/exam-app/public/fonts/fraunces-vietnamese.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..43126c27bf00aa7baa400fdbe04ad479bb0d899a Binary files /dev/null and b/exam-app/public/fonts/fraunces-vietnamese.woff2 differ diff --git a/exam-app/public/fonts/jetbrains-mono-latin-ext.woff2 b/exam-app/public/fonts/jetbrains-mono-latin-ext.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..82f96681c0762d98ec703df7836829d0ab44a785 Binary files /dev/null and b/exam-app/public/fonts/jetbrains-mono-latin-ext.woff2 differ diff --git a/exam-app/public/fonts/jetbrains-mono-latin.woff2 b/exam-app/public/fonts/jetbrains-mono-latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..4d09cda4a4c3f4c08ae253dd8a9c5133a89b31b7 Binary files /dev/null and b/exam-app/public/fonts/jetbrains-mono-latin.woff2 differ diff --git a/exam-app/public/fonts/jetbrains-mono-vietnamese.woff2 b/exam-app/public/fonts/jetbrains-mono-vietnamese.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..e38f5538f84e920b822eeb9a3dd3ea1e20bc1797 Binary files /dev/null and b/exam-app/public/fonts/jetbrains-mono-vietnamese.woff2 differ diff --git a/exam-app/public/fonts/plus-jakarta-sans-latin-ext.woff2 b/exam-app/public/fonts/plus-jakarta-sans-latin-ext.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..f82597ce4a5f5478b12b9213cc446aabdae73b0d Binary files /dev/null and b/exam-app/public/fonts/plus-jakarta-sans-latin-ext.woff2 differ diff --git a/exam-app/public/fonts/plus-jakarta-sans-latin.woff2 b/exam-app/public/fonts/plus-jakarta-sans-latin.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..a180dc40f205fc48979a75ccbe8a10221a71a4ba Binary files /dev/null and b/exam-app/public/fonts/plus-jakarta-sans-latin.woff2 differ diff --git a/exam-app/public/fonts/plus-jakarta-sans-vietnamese.woff2 b/exam-app/public/fonts/plus-jakarta-sans-vietnamese.woff2 new file mode 100644 index 0000000000000000000000000000000000000000..8c846a0201ed62923fe5bb11b4418caf78785407 Binary files /dev/null and b/exam-app/public/fonts/plus-jakarta-sans-vietnamese.woff2 differ diff --git a/exam-app/public/images/questions/q_amc8_19_21.png b/exam-app/public/images/questions/q_amc8_19_21.png new file mode 100644 index 0000000000000000000000000000000000000000..3bbec11644e482803b74fca4abdd72cc91bf606b Binary files /dev/null and b/exam-app/public/images/questions/q_amc8_19_21.png differ diff --git a/exam-app/public/images/questions/q_amc8_19_22.png b/exam-app/public/images/questions/q_amc8_19_22.png new file mode 100644 index 0000000000000000000000000000000000000000..40d54547c5477e940e4bd7285ef680661cec6f22 Binary files /dev/null and b/exam-app/public/images/questions/q_amc8_19_22.png differ diff --git a/exam-app/public/images/questions/q_amc8_19_23.png b/exam-app/public/images/questions/q_amc8_19_23.png new file mode 100644 index 0000000000000000000000000000000000000000..486a49a88df03d5a382c13a9f59cb7dde1f3dbc9 Binary files /dev/null and b/exam-app/public/images/questions/q_amc8_19_23.png differ diff --git a/exam-app/public/images/questions/q_amc8_19_24.png b/exam-app/public/images/questions/q_amc8_19_24.png new file mode 100644 index 0000000000000000000000000000000000000000..f9c0faa29b504fd973eea84d48a28ee0d886b65a Binary files /dev/null and b/exam-app/public/images/questions/q_amc8_19_24.png differ diff --git a/exam-app/public/images/questions/q_amc8_19_25.png b/exam-app/public/images/questions/q_amc8_19_25.png new file mode 100644 index 0000000000000000000000000000000000000000..f1e01af9344e280d5d763e2c5e0db6c863d77df7 Binary files /dev/null and b/exam-app/public/images/questions/q_amc8_19_25.png differ diff --git a/exam-app/public/images/questions/q_amc8_22v_18.png b/exam-app/public/images/questions/q_amc8_22v_18.png new file mode 100644 index 0000000000000000000000000000000000000000..ffaaa6e99a0d5fb91073da55cd6e08e434ac8421 Binary files /dev/null and b/exam-app/public/images/questions/q_amc8_22v_18.png differ diff --git a/exam-app/public/images/questions/q_amc8_22v_19.png b/exam-app/public/images/questions/q_amc8_22v_19.png new file mode 100644 index 0000000000000000000000000000000000000000..78f6b934dcbecc26b16b0565e6b5aa194738deb1 Binary files /dev/null and b/exam-app/public/images/questions/q_amc8_22v_19.png differ diff --git a/exam-app/public/images/questions/q_amc8_22v_20.png b/exam-app/public/images/questions/q_amc8_22v_20.png new file mode 100644 index 0000000000000000000000000000000000000000..443fc2c44ee8c0ce357b6eb5e422f0cecb9d7fbe Binary files /dev/null and b/exam-app/public/images/questions/q_amc8_22v_20.png differ diff --git a/exam-app/public/images/questions/q_amc8_22v_21.png b/exam-app/public/images/questions/q_amc8_22v_21.png new file mode 100644 index 0000000000000000000000000000000000000000..01e32f8c56f53f1f98da5171adeba4018f4f7a34 Binary files /dev/null and b/exam-app/public/images/questions/q_amc8_22v_21.png differ diff --git a/exam-app/public/images/questions/q_amc8_22v_22.png b/exam-app/public/images/questions/q_amc8_22v_22.png new file mode 100644 index 0000000000000000000000000000000000000000..260b0aed46ecd0e3a008a9f9723d5aba98ba09b9 Binary files /dev/null and b/exam-app/public/images/questions/q_amc8_22v_22.png differ diff --git a/exam-app/public/images/questions/q_amc8_22v_23.png b/exam-app/public/images/questions/q_amc8_22v_23.png new file mode 100644 index 0000000000000000000000000000000000000000..04795ad577d85d65d4d4458b5a7fcfd538448887 Binary files /dev/null and b/exam-app/public/images/questions/q_amc8_22v_23.png differ diff --git a/exam-app/public/images/questions/q_amc8_22v_24.png b/exam-app/public/images/questions/q_amc8_22v_24.png new file mode 100644 index 0000000000000000000000000000000000000000..bcc3938b5ed19d44b5e4b9b7be17a0972b20e233 Binary files /dev/null and b/exam-app/public/images/questions/q_amc8_22v_24.png differ diff --git a/exam-app/public/images/questions/q_amc8_22v_25.png b/exam-app/public/images/questions/q_amc8_22v_25.png new file mode 100644 index 0000000000000000000000000000000000000000..fd1cdfa3b88d05df8c350947ecef264416933cf0 Binary files /dev/null and b/exam-app/public/images/questions/q_amc8_22v_25.png differ diff --git a/exam-app/public/images/questions/q_cemc_g8_23_20.png b/exam-app/public/images/questions/q_cemc_g8_23_20.png new file mode 100644 index 0000000000000000000000000000000000000000..0a9be7e023806098079b5cf56c8755f1ca4798b0 Binary files /dev/null and b/exam-app/public/images/questions/q_cemc_g8_23_20.png differ diff --git a/exam-app/public/images/questions/q_cemc_g8_23_21.png b/exam-app/public/images/questions/q_cemc_g8_23_21.png new file mode 100644 index 0000000000000000000000000000000000000000..42408d82fa54a821cf22313b2ed813c8c20de85d Binary files /dev/null and b/exam-app/public/images/questions/q_cemc_g8_23_21.png differ diff --git a/exam-app/public/images/questions/q_cemc_g8_23_22.png b/exam-app/public/images/questions/q_cemc_g8_23_22.png new file mode 100644 index 0000000000000000000000000000000000000000..a3e22d96b62d77254fb1869f59d86f0f108f0950 Binary files /dev/null and b/exam-app/public/images/questions/q_cemc_g8_23_22.png differ diff --git a/exam-app/public/images/questions/q_cemc_g8_23_23.png b/exam-app/public/images/questions/q_cemc_g8_23_23.png new file mode 100644 index 0000000000000000000000000000000000000000..29780b59cfcbf130296a8e64cb74129fa8280aa1 Binary files /dev/null and b/exam-app/public/images/questions/q_cemc_g8_23_23.png differ diff --git a/exam-app/public/images/questions/q_cemc_g8_23_24.png b/exam-app/public/images/questions/q_cemc_g8_23_24.png new file mode 100644 index 0000000000000000000000000000000000000000..03b7ae7612996c7165ac03e0d731687b4542fccb Binary files /dev/null and b/exam-app/public/images/questions/q_cemc_g8_23_24.png differ diff --git a/exam-app/public/images/questions/q_cemc_g8_23_25.png b/exam-app/public/images/questions/q_cemc_g8_23_25.png new file mode 100644 index 0000000000000000000000000000000000000000..c006cb5f152e58536e2b9aedc435ceec98db0f85 Binary files /dev/null and b/exam-app/public/images/questions/q_cemc_g8_23_25.png differ diff --git a/exam-app/public/images/questions/q_ukmt_imc20_15.png b/exam-app/public/images/questions/q_ukmt_imc20_15.png new file mode 100644 index 0000000000000000000000000000000000000000..aaf7802fdbd0c3e236a52e1a5b35ab03b1ffba1d Binary files /dev/null and b/exam-app/public/images/questions/q_ukmt_imc20_15.png differ diff --git a/exam-app/public/images/questions/q_ukmt_imc20_16.png b/exam-app/public/images/questions/q_ukmt_imc20_16.png new file mode 100644 index 0000000000000000000000000000000000000000..0ab64621167ccc86edab05197c3984b7b7402d29 Binary files /dev/null and b/exam-app/public/images/questions/q_ukmt_imc20_16.png differ diff --git a/exam-app/public/images/questions/q_ukmt_imc20_17.png b/exam-app/public/images/questions/q_ukmt_imc20_17.png new file mode 100644 index 0000000000000000000000000000000000000000..a7199eb71f81ef275f07469e969ebc9470ab09a6 Binary files /dev/null and b/exam-app/public/images/questions/q_ukmt_imc20_17.png differ diff --git a/exam-app/public/images/questions/q_ukmt_imc20_18.png b/exam-app/public/images/questions/q_ukmt_imc20_18.png new file mode 100644 index 0000000000000000000000000000000000000000..7f73175021899f21b61667afec2474e95edbb687 Binary files /dev/null and b/exam-app/public/images/questions/q_ukmt_imc20_18.png differ diff --git a/exam-app/public/images/questions/q_ukmt_imc20_19.png b/exam-app/public/images/questions/q_ukmt_imc20_19.png new file mode 100644 index 0000000000000000000000000000000000000000..b132a224525d56529fb2dc5b1671abe0d80bbc1b Binary files /dev/null and b/exam-app/public/images/questions/q_ukmt_imc20_19.png differ diff --git a/exam-app/public/images/questions/q_ukmt_imc20_20.png b/exam-app/public/images/questions/q_ukmt_imc20_20.png new file mode 100644 index 0000000000000000000000000000000000000000..128ccb7a4a1fed3c0211dce64351d9c5af5f9f04 Binary files /dev/null and b/exam-app/public/images/questions/q_ukmt_imc20_20.png differ diff --git a/exam-app/public/images/questions/q_ukmt_imc20_21.png b/exam-app/public/images/questions/q_ukmt_imc20_21.png new file mode 100644 index 0000000000000000000000000000000000000000..acb4885928660d3d132ab1ba4023146782cbf0ba Binary files /dev/null and b/exam-app/public/images/questions/q_ukmt_imc20_21.png differ diff --git a/exam-app/public/images/questions/q_ukmt_imc20_22.png b/exam-app/public/images/questions/q_ukmt_imc20_22.png new file mode 100644 index 0000000000000000000000000000000000000000..bfb78bde0f7e63f40cde213b9870780bc82a9018 Binary files /dev/null and b/exam-app/public/images/questions/q_ukmt_imc20_22.png differ diff --git a/exam-app/public/images/questions/q_ukmt_imc20_23.png b/exam-app/public/images/questions/q_ukmt_imc20_23.png new file mode 100644 index 0000000000000000000000000000000000000000..af09b01fbc200295ee17b54bba884ad35aeeb86a Binary files /dev/null and b/exam-app/public/images/questions/q_ukmt_imc20_23.png differ diff --git a/exam-app/public/images/questions/q_ukmt_imc20_24.png b/exam-app/public/images/questions/q_ukmt_imc20_24.png new file mode 100644 index 0000000000000000000000000000000000000000..4f75a097f55bb80b9d1e13a1d113087699d9efd6 Binary files /dev/null and b/exam-app/public/images/questions/q_ukmt_imc20_24.png differ diff --git a/exam-app/public/images/questions/q_ukmt_imc20_25.png b/exam-app/public/images/questions/q_ukmt_imc20_25.png new file mode 100644 index 0000000000000000000000000000000000000000..98f80b7a9751c1a4f334a7dacc966c74967dd33c Binary files /dev/null and b/exam-app/public/images/questions/q_ukmt_imc20_25.png differ diff --git a/exam-app/public/images/questions/q_ukmt_jmc19_21.png b/exam-app/public/images/questions/q_ukmt_jmc19_21.png new file mode 100644 index 0000000000000000000000000000000000000000..14e5022ba961485ba4e1897750df8c59c3a43d03 Binary files /dev/null and b/exam-app/public/images/questions/q_ukmt_jmc19_21.png differ diff --git a/exam-app/public/images/questions/q_ukmt_jmc19_22.png b/exam-app/public/images/questions/q_ukmt_jmc19_22.png new file mode 100644 index 0000000000000000000000000000000000000000..8bd3ab7883fd10176cb0101fca13f38c732b8f30 Binary files /dev/null and b/exam-app/public/images/questions/q_ukmt_jmc19_22.png differ diff --git a/exam-app/public/images/questions/q_ukmt_jmc19_23.png b/exam-app/public/images/questions/q_ukmt_jmc19_23.png new file mode 100644 index 0000000000000000000000000000000000000000..52b20eb5e5ed53e6c22c6ada3d8ce97d052c0220 Binary files /dev/null and b/exam-app/public/images/questions/q_ukmt_jmc19_23.png differ diff --git a/exam-app/public/images/questions/q_ukmt_jmc19_24.png b/exam-app/public/images/questions/q_ukmt_jmc19_24.png new file mode 100644 index 0000000000000000000000000000000000000000..dd41b728ac2251f4e1d90207e24aa3f47eac396d Binary files /dev/null and b/exam-app/public/images/questions/q_ukmt_jmc19_24.png differ diff --git a/exam-app/public/images/questions/q_ukmt_jmc19_25.png b/exam-app/public/images/questions/q_ukmt_jmc19_25.png new file mode 100644 index 0000000000000000000000000000000000000000..717ff74eebd38663f9f4159e5bd7ec8c3406f65d Binary files /dev/null and b/exam-app/public/images/questions/q_ukmt_jmc19_25.png differ diff --git a/exam-app/public/manifest.json b/exam-app/public/manifest.json new file mode 100644 index 0000000000000000000000000000000000000000..3f9b760fa80724b67f6b7c9f0c15e1204398afdb --- /dev/null +++ b/exam-app/public/manifest.json @@ -0,0 +1,17 @@ +{ + "name": "ExamApp — Ôn tập Toán", + "short_name": "ExamApp", + "description": "Ôn tập Toán với 40+ đề thi Việt Nam & quốc tế — AI phân tích kết quả và gợi ý ôn luyện.", + "start_url": "/", + "display": "standalone", + "background_color": "#0A0E1A", + "theme_color": "#0A0E1A", + "icons": [ + { + "src": "/favicon.svg", + "sizes": "any", + "type": "image/svg+xml", + "purpose": "any maskable" + } + ] +} diff --git a/exam-app/public/offline.html b/exam-app/public/offline.html new file mode 100644 index 0000000000000000000000000000000000000000..015ed09b5b598bd4a4ad6f92884ee464d499d31c --- /dev/null +++ b/exam-app/public/offline.html @@ -0,0 +1,45 @@ + + + + + + Ngoại tuyến · Zenith + + + +
📡
+

Bạn đang ngoại tuyến

+

Kiểm tra kết nối mạng và thử lại. Các đề thi đã tải trước vẫn khả dụng khi có mạng trở lại.

+ + + diff --git a/exam-app/public/robots.txt b/exam-app/public/robots.txt new file mode 100644 index 0000000000000000000000000000000000000000..f7bab3bdecf4c073a082dcad8f2c058379a40fcd --- /dev/null +++ b/exam-app/public/robots.txt @@ -0,0 +1,22 @@ +User-agent: * +Allow: / +Allow: /exams +Allow: /diagnostic +Allow: /oracle + +Disallow: /admin +Disallow: /account +Disallow: /test/ +Disallow: /results/ +Disallow: /history +Disallow: /review +Disallow: /mistakes +Disallow: /practice/ +Disallow: /daily +Disallow: /progress +Disallow: /generate-exam +Disallow: /study-plan/ +Disallow: /placement +Disallow: /challenge + +Sitemap: https://exam-app-ey0.pages.dev/sitemap.xml diff --git a/exam-app/public/sitemap.xml b/exam-app/public/sitemap.xml new file mode 100644 index 0000000000000000000000000000000000000000..69988199e5299494403c5ee534ebb2805b52960a --- /dev/null +++ b/exam-app/public/sitemap.xml @@ -0,0 +1,18 @@ + + + + https://exam-app-ey0.pages.dev/ + weekly + 1.0 + + + https://exam-app-ey0.pages.dev/exams + weekly + 0.9 + + + https://exam-app-ey0.pages.dev/diagnostic + monthly + 0.8 + + diff --git a/exam-app/public/sw.js b/exam-app/public/sw.js new file mode 100644 index 0000000000000000000000000000000000000000..c937d72f0dae0cc90e08a2792a9c5fbfb965dbfb --- /dev/null +++ b/exam-app/public/sw.js @@ -0,0 +1,109 @@ +const CACHE_SHELL = 'exam-shell-v4' +const CACHE_ASSETS = 'exam-assets-v4' + +// App shell — cached on install for reliable offline navigation +const SHELL_URLS = ['/', '/manifest.json', '/favicon.svg', '/offline.html'] + +// Asset types that are safe to cache aggressively (hashed filenames never change) +function isHashedAsset(url) { + return /\/assets\/[^/]+-[A-Za-z0-9]{8}\.(js|css|woff2?)$/.test(url.pathname) +} + +function isStaticJson(url) { + return url.pathname.endsWith('.json') && url.origin === self.location.origin +} + +// API calls — never cache +function isApiCall(url) { + const p = url.pathname + return p.startsWith('/analyze') || p.startsWith('/hint') || p.startsWith('/explain') || + p.startsWith('/auth') || p.startsWith('/users') || p.startsWith('/study-plan') || + p.startsWith('/math') || p.startsWith('/tutor') || p.startsWith('/wiki') || + p.startsWith('/admin') || p.startsWith('/questions') +} + +self.addEventListener('install', e => { + e.waitUntil( + caches.open(CACHE_SHELL).then(c => c.addAll(SHELL_URLS)).catch(() => {}) + ) + self.skipWaiting() +}) + +self.addEventListener('activate', e => { + e.waitUntil( + caches.keys().then(keys => + Promise.all( + keys + .filter(k => k !== CACHE_SHELL && k !== CACHE_ASSETS) + .map(k => caches.delete(k)) + ) + ) + ) + self.clients.claim() +}) + +self.addEventListener('fetch', e => { + const { request } = e + if (request.method !== 'GET') return + + const url = new URL(request.url) + if (isApiCall(url)) return // let API calls pass through without caching + + // Hashed JS/CSS assets — cache-first (they never change) + if (isHashedAsset(url)) { + e.respondWith( + caches.open(CACHE_ASSETS).then(cache => + cache.match(request).then(cached => { + if (cached) return cached + return fetch(request).then(res => { + if (res.ok) cache.put(request, res.clone()) + return res + }) + }) + ) + ) + return + } + + // Static JSON data files (questions.json, exams.json, schools.json) — network-first, cache fallback + if (isStaticJson(url)) { + e.respondWith( + caches.open(CACHE_ASSETS).then(cache => + fetch(request) + .then(res => { + if (res.ok) cache.put(request, res.clone()) + return res + }) + .catch(() => cache.match(request)) + ) + ) + return + } + + // Navigation (HTML pages) — network-first, fallback to offline page + if (request.mode === 'navigate') { + e.respondWith( + fetch(request).catch(() => + caches.match('/offline.html').then(r => r || caches.match('/').then(s => s || new Response('Offline', { status: 503 }))) + ) + ) + return + } + + // Everything else — network-first with cache fallback + // Only cache same-origin responses to avoid CSP violations on cross-origin URLs + // (e.g. Google avatar images at lh3.googleusercontent.com) + if (url.origin !== self.location.origin) return + + e.respondWith( + fetch(request) + .then(res => { + if (res.ok) { + const clone = res.clone() + caches.open(CACHE_ASSETS).then(c => c.put(request, clone)) + } + return res + }) + .catch(() => caches.match(request)) + ) +}) diff --git a/exam-app/scripts/crawl/generate.js b/exam-app/scripts/crawl/generate.js new file mode 100644 index 0000000000000000000000000000000000000000..7ad3ff8f2c7bd43b40284c4fae92f09dcaf3ec05 --- /dev/null +++ b/exam-app/scripts/crawl/generate.js @@ -0,0 +1,232 @@ +/** + * AI-based dataset generator. + * Generates 30 MCQ questions per year (2015-2024) matching the HCMC Grade 10 + * Math entrance exam format, including SVG figures for geometry questions. + * + * Usage: node scripts/crawl/generate.js + * node scripts/crawl/generate.js --years=2023,2024 (subset) + * node scripts/crawl/generate.js --skip-figures + */ + +import axios from 'axios' +import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs' +import { join, dirname } from 'path' +import { fileURLToPath } from 'url' +import { normalize } from './pipeline/normalize.js' +import { tag } from './pipeline/tag.js' +import { dedupe } from './pipeline/dedupe.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const OUTPUT_DIR = join(__dirname, 'output') +const RAW_DIR = join(OUTPUT_DIR, 'raw') + +const skipFigures = process.argv.includes('--skip-figures') +const yearsArg = process.argv.find(a => a.startsWith('--years='))?.split('=')[1] +const ALL_YEARS = [2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024] +const YEARS = yearsArg ? yearsArg.split(',').map(Number) : ALL_YEARS + +// ── env ────────────────────────────────────────────────────────────────────── + +function loadEnv() { + const p = join(__dirname, '../../../backend/.env') + if (!existsSync(p)) return {} + const env = {} + for (const line of readFileSync(p, 'utf8').split('\n')) { + const m = line.match(/^([^#=]+)=(.*)$/) + if (m) env[m[1].trim()] = m[2].trim() + } + return env +} + +const ENV = loadEnv() +const BASE = (ENV.ANTHROPIC_BASE_URL || 'https://ai-router.locdo.tech') + '/v2' +const TOKEN = ENV.ANTHROPIC_AUTH_TOKEN +const SONNET = ENV.ANTHROPIC_DEFAULT_SONNET_MODEL || 'claude-sonnet-4.6' +const HAIKU = ENV.ANTHROPIC_DEFAULT_HAIKU_MODEL || 'claude-haiku-4.5' + +if (!TOKEN) { console.error('Missing ANTHROPIC_AUTH_TOKEN in backend/.env'); process.exit(1) } + +async function callAI(model, prompt, maxTokens = 4000) { + const res = await axios.post( + `${BASE}/chat/completions`, + { model, max_tokens: maxTokens, messages: [{ role: 'user', content: prompt }] }, + { headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' }, timeout: 60000 } + ) + return res.data?.choices?.[0]?.message?.content ?? '' +} + +// ── question generation ─────────────────────────────────────────────────────── + +const TOPIC_DISTRIBUTION = [ + // [topic, count] totaling 30 per exam + ['algebra', 12], + ['geometry', 10], + ['statistics', 4], + ['combinatorics', 4], +] + +// Difficulty by question position (first 10 easy, next 15 medium, last 5 hard) +function difficulty(idx) { + if (idx < 10) return 'easy' + if (idx < 25) return 'medium' + return 'hard' +} + +function buildPrompt(year, topic, count, startIdx) { + const diffLabels = Array.from({ length: count }, (_, i) => difficulty(startIdx + i)) + + const topicGuide = { + algebra: `phương trình bậc nhất/bậc hai, hệ phương trình, bất phương trình, hàm số y=ax+b và y=ax², căn thức, đa thức`, + geometry: `tam giác (Pythagoras, đồng dạng, tứ giác nội tiếp), đường tròn (tiếp tuyến, dây cung), hệ thức lượng trong tam giác vuông, diện tích và chu vi`, + statistics: `trung bình cộng, tần số, biểu đồ, xác suất cơ bản`, + combinatorics: `quy tắc đếm, hoán vị, chỉnh hợp, tổ hợp`, + } + + return `Bạn là giáo viên Toán lớp 9 tại TP.HCM. Hãy tạo ${count} câu hỏi trắc nghiệm Toán cho kỳ thi tuyển sinh vào lớp 10 TP.HCM năm ${year}. + +Chủ đề: ${topic} — ${topicGuide[topic]} +Độ khó: ${diffLabels.join(', ')} (câu ${startIdx + 1} đến ${startIdx + count}) + +YÊU CẦU: +- Câu hỏi đúng chuẩn đề thi tuyển sinh lớp 10 TP.HCM (không dùng kiến thức lớp 10) +- Mỗi câu có đúng 4 lựa chọn (A, B, C, D), chỉ 1 đáp án đúng +- Câu hỏi đa dạng, không lặp lại dạng bài +- Giải thích ngắn gọn, rõ ràng bước giải +- Với câu hình học: mô tả đủ dữ liệu (độ dài, góc, bán kính...) để vẽ hình +- Năm ${year}: điều chỉnh độ khó phù hợp với xu hướng đề thi năm đó + +TRẢ VỀ JSON array (không có markdown): +[ + { + "question": "Nội dung câu hỏi...", + "choices": ["Đáp án A", "Đáp án B", "Đáp án C", "Đáp án D"], + "correct": 0, + "explanation": "Giải: ..." + } +]` +} + +async function generateQuestionsForTopic(year, topic, count, startIdx) { + const prompt = buildPrompt(year, topic, count, startIdx) + for (let attempt = 1; attempt <= 3; attempt++) { + try { + const raw = await callAI(SONNET, prompt, 4000) + // Extract the outermost JSON array robustly (handles trailing text after array) + const start = raw.indexOf('[') + if (start === -1) throw new Error('No JSON array in response') + let depth = 0, end = -1 + for (let i = start; i < raw.length; i++) { + if (raw[i] === '[') depth++ + else if (raw[i] === ']') { depth--; if (depth === 0) { end = i; break } } + } + if (end === -1) throw new Error('Unclosed JSON array') + const parsed = JSON.parse(raw.slice(start, end + 1)) + if (!Array.isArray(parsed) || parsed.length === 0) throw new Error('Empty array') + return parsed.slice(0, count).map((q, i) => ({ + ...q, + topic, + difficulty: difficulty(startIdx + i), + year, + source: 'ai-generated', + })) + } catch (e) { + console.warn(` [WARN] ${year} ${topic} attempt ${attempt}: ${e.message}`) + if (attempt < 3) await new Promise(r => setTimeout(r, 3000)) + } + } + return [] +} + +// ── SVG figure generation ───────────────────────────────────────────────────── + +function isValidSvg(s) { + return typeof s === 'string' && s.trimStart().startsWith('') +} + +async function generateSvg(question) { + const prompt = `Generate a minimal SVG diagram (viewBox="0 0 200 160", no width/height attrs) for this Vietnamese Grade 10 math geometry question. +Style: background rect fill="#0D1221", strokes stroke="#94A3B8", text fill="#F8FAFC" font-size="11" font-family="sans-serif". Mark right angles with a small square (5px). +Return ONLY the element — no explanation, no markdown fences. + +Question: ${question}` + try { + const raw = await callAI(HAIKU, prompt, 600) + const m = raw.match(//i) + return (m && isValidSvg(m[0])) ? m[0] : null + } catch { + return null + } +} + +// ── main ────────────────────────────────────────────────────────────────────── + +async function generateYear(year) { + console.log(`\nGenerating ${year}...`) + const questions = [] + let idx = 0 + + for (const [topic, count] of TOPIC_DISTRIBUTION) { + process.stdout.write(` ${topic} (${count})... `) + const qs = await generateQuestionsForTopic(year, topic, count, idx) + console.log(`${qs.length} generated`) + questions.push(...qs) + idx += count + await new Promise(r => setTimeout(r, 2000)) + } + + // Add SVG figures for geometry questions + if (!skipFigures) { + const geoQs = questions.filter(q => q.topic === 'geometry') + process.stdout.write(` figures (${geoQs.length} geometry)... `) + let figCount = 0 + for (const q of geoQs) { + const svg = await generateSvg(q.question) + if (svg) { + q.figure = { type: 'svg', data: svg } + figCount++ + } + await new Promise(r => setTimeout(r, 1000)) + } + console.log(`${figCount} SVGs generated`) + } + + return questions +} + +async function main() { + mkdirSync(RAW_DIR, { recursive: true }) + console.log(`Generating ${YEARS.length} exam years: ${YEARS.join(', ')}`) + console.log(`Model: ${SONNET} (questions), ${HAIKU} (figures)`) + + const allRaw = [] + + for (const year of YEARS) { + const questions = await generateYear(year) + allRaw.push(...questions) + writeFileSync(join(RAW_DIR, `ai-${year}.json`), JSON.stringify(questions, null, 2)) + console.log(` year ${year}: ${questions.length} questions saved`) + // Pause between years to be gentle on rate limits + if (year !== YEARS[YEARS.length - 1]) await new Promise(r => setTimeout(r, 3000)) + } + + writeFileSync(join(RAW_DIR, 'questions-raw.json'), JSON.stringify(allRaw, null, 2)) + console.log(`\nRaw total: ${allRaw.length} questions`) + + // Run through pipeline + const normalized = normalize.questions(allRaw) + const tagged = tag(normalized) + const deduped = dedupe(tagged) + const exams = normalize.exams(deduped) + + writeFileSync(join(OUTPUT_DIR, 'questions.json'), JSON.stringify(deduped, null, 2)) + writeFileSync(join(OUTPUT_DIR, 'exams.json'), JSON.stringify(exams, null, 2)) + + console.log(`\nDone.`) + console.log(` ${deduped.length} questions → output/questions.json`) + console.log(` ${exams.length} exams → output/exams.json`) + console.log(`\nNext steps:`) + console.log(` npm run crawl:preview # review SVG figures`) + console.log(` npm run crawl:publish # publish to src/data/`) +} + +main().catch(e => { console.error('Fatal:', e.message); process.exit(1) }) diff --git a/exam-app/scripts/crawl/httpClient.js b/exam-app/scripts/crawl/httpClient.js new file mode 100644 index 0000000000000000000000000000000000000000..9ea02d9fa6128dd241e7ddd52e14e8905ed30434 --- /dev/null +++ b/exam-app/scripts/crawl/httpClient.js @@ -0,0 +1,12 @@ +import axios from 'axios' + +const UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36' + +export const http = axios.create({ + timeout: 15000, + headers: { + 'User-Agent': UA, + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'vi-VN,vi;q=0.9,en;q=0.8', + }, +}) diff --git a/exam-app/scripts/crawl/index.js b/exam-app/scripts/crawl/index.js new file mode 100644 index 0000000000000000000000000000000000000000..9eaf2c6b313499cfece09eec7b9c85071b98372d --- /dev/null +++ b/exam-app/scripts/crawl/index.js @@ -0,0 +1,140 @@ +import { crawlQuestions } from './sources/questions/vndoc.js' +import { crawlThuVienHocLieu } from './sources/questions/thuvienhoclieu.js' +import { crawlLoiGiaiHay } from './sources/questions/loigiaihay.js' +import { crawlToanMath } from './sources/questions/toanmath.js' +import { crawlTaiLieu } from './sources/questions/tailieu.js' +import { crawlHSG } from './sources/questions/hsg.js' +import { crawlTuyenSinh247 } from './sources/schools/tuyensinh247.js' +import { crawlDantri } from './sources/schools/dantri.js' +import { crawlHcmedu } from './sources/schools/hcmedu.js' +import { crawlBGDT } from './sources/schools/bgdt.js' +import { normalize } from './pipeline/normalize.js' +import { tag } from './pipeline/tag.js' +import { addFigures } from './pipeline/figure.js' +import { dedupe } from './pipeline/dedupe.js' +import { aiValidate } from './pipeline/aiValidate.js' +import { validate } from './pipeline/validate.js' +import { merge } from './pipeline/merge.js' +import { writeFileSync, mkdirSync } from 'fs' +import { join, dirname } from 'path' +import { fileURLToPath } from 'url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const OUTPUT_DIR = join(__dirname, 'output') +const RAW_DIR = join(OUTPUT_DIR, 'raw') + +const only = process.argv.find(a => a.startsWith('--only='))?.split('=')[1] +const skipValidate = process.argv.includes('--skip-validate') +const ignoreRobots = process.argv.includes('--ignore-robots') + +async function sleep(ms) { + return new Promise(r => setTimeout(r, ms)) +} + +async function checkRobots(domain) { + try { + const { default: axios } = await import('axios') + const res = await axios.get(`https://${domain}/robots.txt`, { timeout: 5000 }) + if (res.data.includes('Disallow: /')) { + console.warn(`[WARN] ${domain}/robots.txt has broad Disallow — skipping`) + return false + } + return true + } catch { + return true // assume allowed if robots.txt unreachable + } +} + +async function runQuestions() { + console.log('Crawling questions...') + const sources = [ + { fn: crawlQuestions, domain: 'vndoc.com' }, + { fn: crawlThuVienHocLieu, domain: 'thuvienhoclieu.com' }, + { fn: crawlLoiGiaiHay, domain: 'loigiaihay.com' }, + { fn: crawlToanMath, domain: 'toanmath.com' }, + { fn: crawlTaiLieu, domain: 'tailieu.vn' }, + { fn: crawlHSG, domain: 'thuvienhoclieu.com' }, + ] + let allRaw = [] + for (const { fn, domain } of sources) { + if (!ignoreRobots) { + const ok = await checkRobots(domain) + if (!ok) continue + } + try { + const items = await fn() + allRaw.push(...items) + console.log(` ${domain}: ${items.length} questions`) + } catch (e) { + console.error(` [ERROR] ${domain}: ${e.message}`) + } + await sleep(1500) + } + writeFileSync(join(RAW_DIR, 'questions-raw.json'), JSON.stringify(allRaw, null, 2)) + + const normalized = normalize.questions(allRaw) + const tagged = tag(normalized) + const withFigures = await addFigures(tagged) + const deduped = dedupe(withFigures) + if (!skipValidate) await aiValidate(deduped) + const exams = normalize.exams(deduped) + const report = validate(deduped, null) + report.exams = exams.length + + writeFileSync(join(OUTPUT_DIR, 'questions.json'), JSON.stringify(deduped, null, 2)) + writeFileSync(join(OUTPUT_DIR, 'exams.json'), JSON.stringify(exams, null, 2)) + return { deduped, exams, report } +} + +async function runSchools() { + console.log('Crawling schools...') + const sources = [ + { fn: crawlTuyenSinh247, domain: 'tuyensinh247.com' }, + { fn: crawlDantri, domain: 'dantri.com.vn' }, + { fn: crawlHcmedu, domain: 'hcm.edu.vn' }, + { fn: crawlBGDT, domain: 'moet.gov.vn' }, + ] + let allSchoolData = [] + for (const { fn, domain } of sources) { + const ok = await checkRobots(domain) + if (!ok) continue + try { + const items = await fn() + allSchoolData.push(...items) + console.log(` ${domain}: ${items.length} schools`) + } catch (e) { + console.error(` [ERROR] ${domain}: ${e.message}`) + } + await sleep(1500) + } + writeFileSync(join(RAW_DIR, 'schools-raw.json'), JSON.stringify(allSchoolData, null, 2)) + + const schools = merge(allSchoolData) + const report = validate(null, schools) + + writeFileSync(join(OUTPUT_DIR, 'schools.json'), JSON.stringify(schools, null, 2)) + return { schools, report } +} + +async function main() { + mkdirSync(RAW_DIR, { recursive: true }) + const report = { timestamp: new Date().toISOString(), conflicts: [] } + + try { + if (!only || only === 'questions') { + const { report: qReport } = await runQuestions() + Object.assign(report, qReport) + } + if (!only || only === 'schools') { + const { report: sReport } = await runSchools() + Object.assign(report, sReport) + } + writeFileSync(join(OUTPUT_DIR, 'crawl-report.json'), JSON.stringify(report, null, 2)) + console.log('Done. Report:', join(OUTPUT_DIR, 'crawl-report.json')) + } catch (e) { + console.error('Fatal:', e.message) + process.exit(1) + } +} + +main() diff --git a/exam-app/scripts/crawl/pipeline/aiValidate.js b/exam-app/scripts/crawl/pipeline/aiValidate.js new file mode 100644 index 0000000000000000000000000000000000000000..691be7f9af1cdc16be6d500f5ab011a4ba8782d9 --- /dev/null +++ b/exam-app/scripts/crawl/pipeline/aiValidate.js @@ -0,0 +1,75 @@ +import axios from 'axios' +import { writeFileSync } from 'fs' +import { join, dirname } from 'path' +import { fileURLToPath } from 'url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const OUTPUT_DIR = join(__dirname, '../output') +const BACKEND = 'http://localhost:8000' +const DELAY_MS = 3000 + +async function checkHealth() { + try { + await axios.get(`${BACKEND}/health`, { timeout: 3000 }) + return true + } catch { + return false + } +} + +export async function aiValidate(questions) { + const alive = await checkHealth() + if (!alive) { + console.warn(' [WARN] aiValidate: backend not running — skipping AI validation') + console.warn(' Start backend with: uvicorn app.main:app --reload') + return { questions, report: [], skipped: true } + } + + console.log(` aiValidate: checking ${questions.length} questions via /explain...`) + const report = [] + + for (let i = 0; i < questions.length; i++) { + const q = questions[i] + try { + const { data } = await axios.post(`${BACKEND}/explain`, { + question: q, + chosen_index: q.correct, + }, { timeout: 15000 }) + + const aiCorrect = data?.correct_index ?? q.correct + const verdict = aiCorrect === q.correct ? 'CORRECT' : 'WRONG' + report.push({ + id: q.id, + year: q.year, + storedCorrect: q.correct, + aiCorrect, + aiExplanation: data?.explanation ?? null, + verdict, + }) + + if (verdict === 'WRONG') { + console.warn(` [WARN] ${q.id}: stored=${q.correct} ai=${aiCorrect}`) + } + } catch (e) { + report.push({ id: q.id, verdict: 'ERROR', error: e.message }) + } + + if (i < questions.length - 1) { + await new Promise(r => setTimeout(r, DELAY_MS)) + } + } + + const wrong = report.filter(r => r.verdict === 'WRONG').length + const errors = report.filter(r => r.verdict === 'ERROR').length + const pct = Math.round((wrong / questions.length) * 100) + console.log(` aiValidate: ${wrong} WRONG, ${errors} ERROR out of ${questions.length} (${pct}% wrong)`) + + writeFileSync(join(OUTPUT_DIR, 'validation-report.json'), JSON.stringify(report, null, 2)) + + if (pct > 10) { + console.error(` [ERROR] ${pct}% wrong answers exceeds 10% threshold — review crawled data`) + process.exitCode = 1 + } + + return { questions, report } +} diff --git a/exam-app/scripts/crawl/pipeline/applyFixes.js b/exam-app/scripts/crawl/pipeline/applyFixes.js new file mode 100644 index 0000000000000000000000000000000000000000..921cfd780f1138bafeb5e9349a227686349add62 --- /dev/null +++ b/exam-app/scripts/crawl/pipeline/applyFixes.js @@ -0,0 +1,39 @@ +import { readFileSync, writeFileSync, existsSync } from 'fs' +import { join, dirname } from 'path' +import { fileURLToPath } from 'url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const OUTPUT_DIR = join(__dirname, '../output') + +const reportPath = join(OUTPUT_DIR, 'validation-report.json') +const questionsPath = join(OUTPUT_DIR, 'questions.json') + +if (!existsSync(reportPath)) { + console.error('Run aiValidate first (via crawl pipeline).') + process.exit(1) +} + +const report = JSON.parse(readFileSync(reportPath, 'utf8')) +const questions = JSON.parse(readFileSync(questionsPath, 'utf8')) +const qMap = Object.fromEntries(questions.map(q => [q.id, q])) + +const wrongItems = report.filter(r => r.verdict === 'WRONG') +const fixes = [] + +for (const item of wrongItems) { + const q = qMap[item.id] + if (!q) continue + fixes.push({ + id: item.id, + oldCorrect: q.correct, + newCorrect: item.aiCorrect, + oldExplanation: q.explanation, + newExplanation: item.aiExplanation, + }) + q.correct = item.aiCorrect + if (item.aiExplanation) q.explanation = item.aiExplanation +} + +writeFileSync(questionsPath, JSON.stringify(questions, null, 2)) +writeFileSync(join(OUTPUT_DIR, 'fixes-applied.json'), JSON.stringify(fixes, null, 2)) +console.log(`Applied ${fixes.length} fixes. Details: output/fixes-applied.json`) diff --git a/exam-app/scripts/crawl/pipeline/buildPreview.js b/exam-app/scripts/crawl/pipeline/buildPreview.js new file mode 100644 index 0000000000000000000000000000000000000000..dad05dad4d75957479f8b1bda3a628747de4fe1e --- /dev/null +++ b/exam-app/scripts/crawl/pipeline/buildPreview.js @@ -0,0 +1,85 @@ +import { readFileSync, writeFileSync, existsSync } from 'fs' +import { join, dirname } from 'path' +import { fileURLToPath } from 'url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const OUTPUT_DIR = join(__dirname, '../output') +const QUESTIONS_PATH = join(OUTPUT_DIR, 'questions.json') +const PREVIEW_PATH = join(OUTPUT_DIR, 'figure-preview.html') + +if (!existsSync(QUESTIONS_PATH)) { + console.error('Run the crawl pipeline first: node index.js --only=questions') + process.exit(1) +} + +const questions = JSON.parse(readFileSync(QUESTIONS_PATH, 'utf8')) +const withFigures = questions.filter(q => q.figure?.data) + +console.log(`Building preview for ${withFigures.length} figures...`) + +const cards = withFigures.map(q => ` +
+
+

${q.id} · ${q.year} · ${q.topic}

+

${q.question}

+
    ${q.choices.map((c, i) => `
  • ${'ABCD'[i]}. ${c}
  • `).join('')}
+

Correct: ${'ABCD'[q.correct]}

+
+
${q.figure.data}
+
+ +
+
+`).join('\n') + +const html = ` + + + +Figure Preview + + + +

Figure Preview — ${withFigures.length} diagrams

+

Check each figure. Flag any that are geometrically incorrect, then click Export.

+ +${cards} + + +` + +writeFileSync(PREVIEW_PATH, html) +console.log('Preview written to:', PREVIEW_PATH) +console.log('Open it in a browser, flag incorrect figures, then run:') +console.log(' node scripts/crawl/pipeline/correctFigures.js --flagged=output/flagged-ids.json') diff --git a/exam-app/scripts/crawl/pipeline/correctFigures.js b/exam-app/scripts/crawl/pipeline/correctFigures.js new file mode 100644 index 0000000000000000000000000000000000000000..4703655dea2e8aa51b4099db080e8fe5a2a81d8b --- /dev/null +++ b/exam-app/scripts/crawl/pipeline/correctFigures.js @@ -0,0 +1,31 @@ +import { readFileSync, writeFileSync, existsSync } from 'fs' +import { join, dirname } from 'path' +import { fileURLToPath } from 'url' +import { addFigures } from './figure.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const OUTPUT_DIR = join(__dirname, '../output') + +const flaggedArg = process.argv.find(a => a.startsWith('--flagged='))?.split('=')[1] +if (!flaggedArg || !existsSync(flaggedArg)) { + console.error('Usage: node correctFigures.js --flagged=') + process.exit(1) +} + +const flaggedIds = new Set(JSON.parse(readFileSync(flaggedArg, 'utf8'))) +const questionsPath = join(OUTPUT_DIR, 'questions.json') +const questions = JSON.parse(readFileSync(questionsPath, 'utf8')) + +console.log(`Re-generating figures for ${flaggedIds.size} flagged questions...`) + +// Clear figure data for flagged questions so addFigures regenerates them +for (const q of questions) { + if (flaggedIds.has(q.id)) { + q.figure = null + q.needs_figure = true + } +} + +const updated = await addFigures(questions) +writeFileSync(questionsPath, JSON.stringify(updated, null, 2)) +console.log('Corrections applied. Re-run buildPreview.js to verify.') diff --git a/exam-app/scripts/crawl/pipeline/dedupe.js b/exam-app/scripts/crawl/pipeline/dedupe.js new file mode 100644 index 0000000000000000000000000000000000000000..78193b088355e849da14d5caf5a6d330a7186f5f --- /dev/null +++ b/exam-app/scripts/crawl/pipeline/dedupe.js @@ -0,0 +1,66 @@ +import { createHash } from 'crypto' + +function hashQuestion(q) { + return createHash('md5').update(q.question.trim().toLowerCase()).digest('hex') +} + +function normalizeText(str) { + return str.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/đ/g, 'd').trim() +} + +function levenshtein(a, b) { + const m = a.length, n = b.length + const dp = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)]) + for (let j = 0; j <= n; j++) dp[0][j] = j + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = a[i-1] === b[j-1] ? dp[i-1][j-1] + : 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + } + } + return dp[m][n] +} + +export function dedupe(questions) { + // Pass 1: exact MD5 dedup + const seen = new Set() + let exactRemoved = 0 + const afterExact = [] + for (const q of questions) { + const h = hashQuestion(q) + if (!seen.has(h)) { + seen.add(h) + afterExact.push(q) + } else { + exactRemoved++ + } + } + + // Pass 2: fuzzy Levenshtein dedup (edit distance < 8% of longer string length) + const normalized = afterExact.map(q => normalizeText(q.question)) + const keep = new Array(afterExact.length).fill(true) + let fuzzyRemoved = 0 + for (let i = 0; i < afterExact.length; i++) { + if (!keep[i]) continue + for (let j = i + 1; j < afterExact.length; j++) { + if (!keep[j]) continue + const maxLen = Math.max(normalized[i].length, normalized[j].length) + if (maxLen === 0) continue + const dist = levenshtein(normalized[i], normalized[j]) + if (dist / maxLen < 0.08) { + // Keep the one with a non-null explanation + if (afterExact[i].explanation && !afterExact[j].explanation) { + keep[j] = false + } else { + keep[i] = false + } + fuzzyRemoved++ + break + } + } + } + + const result = afterExact.filter((_, i) => keep[i]) + console.log(` dedupe: ${exactRemoved} exact + ${fuzzyRemoved} fuzzy duplicates removed → ${result.length} kept`) + return result +} diff --git a/exam-app/scripts/crawl/pipeline/figure.js b/exam-app/scripts/crawl/pipeline/figure.js new file mode 100644 index 0000000000000000000000000000000000000000..04b49e8699fd5281f95dc02ef6b8e3561f12442e --- /dev/null +++ b/exam-app/scripts/crawl/pipeline/figure.js @@ -0,0 +1,89 @@ +import axios from 'axios' +import { readFileSync, existsSync } from 'fs' +import { join, dirname } from 'path' +import { fileURLToPath } from 'url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const ENV_PATH = join(__dirname, '../../../../backend/.env') + +function loadEnv() { + if (!existsSync(ENV_PATH)) return {} + const env = {} + for (const line of readFileSync(ENV_PATH, 'utf8').split('\n')) { + const m = line.match(/^([^#=]+)=(.*)$/) + if (m) env[m[1].trim()] = m[2].trim() + } + return env +} + +function isValidSvg(data) { + return typeof data === 'string' && data.trimStart().startsWith('') +} + +async function generateSvg(question, env, attempt = 1) { + const baseUrl = env.ANTHROPIC_BASE_URL || 'https://ai-router.locdo.tech' + const token = env.ANTHROPIC_AUTH_TOKEN + const model = env.ANTHROPIC_DEFAULT_HAIKU_MODEL || 'claude-haiku-4.5' + + const prompt = `Generate a minimal SVG diagram (viewBox="0 0 200 160", no width/height attrs) for this Vietnamese Grade 10 math geometry problem. Dark theme: add a background , draw shapes with stroke="#94A3B8" stroke-width="1.5" fill="none", add text labels with fill="#F8FAFC" font-size="11" font-family="sans-serif". Mark right angles with a small 6px square. Keep it simple and accurate. Output ONLY the raw element — no markdown, no explanation. + +Question: ${question}` + + try { + const res = await axios.post( + `${baseUrl}/v2/chat/completions`, + { model, max_tokens: 1000, messages: [{ role: 'user', content: prompt }] }, + { headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' }, timeout: 25000 } + ) + const text = res.data?.choices?.[0]?.message?.content ?? '' + // Extract first complete ... block + const start = text.indexOf('') + if (start === -1 || end === -1) return null + const svg = text.slice(start, end + 6) + return isValidSvg(svg) ? svg : null + } catch (e) { + if (e.response?.status === 429 && attempt <= 4) { + const wait = attempt * 8000 // 8s, 16s, 24s, 32s + await new Promise(r => setTimeout(r, wait)) + return generateSvg(question, env, attempt + 1) + } + throw e + } +} + +export async function addFigures(questions) { + const env = loadEnv() + if (!env.ANTHROPIC_AUTH_TOKEN) { + console.warn(' [WARN] figure.js: ANTHROPIC_AUTH_TOKEN not found — skipping figure generation') + return questions + } + + const needsFigure = questions.filter(q => q.needs_figure && !q.figure?.data) + console.log(` figures: ${needsFigure.length} questions need SVG generation`) + + // Sequential with generous delay to stay under rate limit + for (let i = 0; i < needsFigure.length; i++) { + const q = needsFigure[i] + process.stdout.write(` figure ${i + 1}/${needsFigure.length} (${q.id})... `) + try { + const svg = await generateSvg(q.question, env) + if (svg) { + q.figure = { type: 'svg', data: svg } + process.stdout.write('OK\n') + } else { + q.figure = { type: 'svg', data: null, error: 'invalid SVG returned' } + process.stdout.write('invalid SVG\n') + } + } catch (e) { + q.figure = { type: 'svg', data: null, error: e.message } + process.stdout.write(`error: ${e.message}\n`) + } + // 4s between each request — safe for 20 req/min limit + if (i < needsFigure.length - 1) await new Promise(r => setTimeout(r, 4000)) + } + + const succeeded = questions.filter(q => q.figure?.data).length + console.log(` figures: ${succeeded} total SVGs now present`) + return questions +} diff --git a/exam-app/scripts/crawl/pipeline/merge.js b/exam-app/scripts/crawl/pipeline/merge.js new file mode 100644 index 0000000000000000000000000000000000000000..0c3aa741870b720a1b55b2a71eba70c62c584c61 --- /dev/null +++ b/exam-app/scripts/crawl/pipeline/merge.js @@ -0,0 +1,62 @@ +// Levenshtein distance for fuzzy name matching (diacritics-insensitive) +function normalize(str) { + return str.toLowerCase().normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/đ/g, 'd') +} + +function levenshtein(a, b) { + const m = a.length, n = b.length + const dp = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)]) + for (let j = 0; j <= n; j++) dp[0][j] = j + for (let i = 1; i <= m; i++) { + for (let j = 1; j <= n; j++) { + dp[i][j] = a[i-1] === b[j-1] ? dp[i-1][j-1] + : 1 + Math.min(dp[i-1][j], dp[i][j-1], dp[i-1][j-1]) + } + } + return dp[m][n] +} + +function computeTrend(cutoffs) { + const years = Object.keys(cutoffs).map(Number).sort() + if (years.length < 4) return 'stable' + const recent = years.slice(-3).map(y => cutoffs[y].math) + const older = years.slice(0, 2).map(y => cutoffs[y].math) + const avgRecent = recent.reduce((a, b) => a + b, 0) / recent.length + const avgOlder = older.reduce((a, b) => a + b, 0) / older.length + const delta = avgRecent - avgOlder + if (delta > 0.2) return 'rising' + if (delta < -0.2) return 'falling' + return 'stable' +} + +export function merge(allSchoolData) { + const profiles = {} + const cutoffsByName = {} + + for (const item of allSchoolData) { + if (item.type === 'profile') { + profiles[item.name] = item + } else if (item.type === 'cutoff') { + const match = Object.keys(profiles).find(name => + levenshtein(normalize(name), normalize(item.name)) <= 2 + ) ?? item.name + if (!cutoffsByName[match]) cutoffsByName[match] = {} + if (item.source2 && item.conflict) { + // cross-source conflict flagged — keep primary + } + Object.assign(cutoffsByName[match], item.cutoffs ?? {}) + } + } + + return Object.entries(profiles).map(([name, profile]) => { + const cutoffs = cutoffsByName[name] ?? {} + return { + id: profile.id, + name, + district: profile.district, + type: profile.type, + cutoffs, + trend: computeTrend(cutoffs), + } + }) +} diff --git a/exam-app/scripts/crawl/pipeline/normalize.js b/exam-app/scripts/crawl/pipeline/normalize.js new file mode 100644 index 0000000000000000000000000000000000000000..d83b10f5c240db8c2ad2eebffe1b8163fb811f25 --- /dev/null +++ b/exam-app/scripts/crawl/pipeline/normalize.js @@ -0,0 +1,61 @@ +const CHOICE_RE = /([A-D])[.)]\s*(.+?)(?=[A-D][.)]|$)/gi +const ANSWER_RE = /Câu\s*(\d+)[:\s]*([A-D])/gi +const QUESTION_RE = /Câu\s*(\d+)[:.]\s*([\s\S]+?)(?=Câu\s*\d+|Đáp án|$)/gi + +const LETTER_TO_INDEX = { A: 0, B: 1, C: 2, D: 3 } + +export function parseQuestionBlock(block, year, source) { + const questions = [] + QUESTION_RE.lastIndex = 0 + let m + while ((m = QUESTION_RE.exec(block)) !== null) { + const num = parseInt(m[1]) + const body = m[2].trim() + const choiceMatches = [...body.matchAll(new RegExp(CHOICE_RE.source, 'gi'))] + const choices = choiceMatches.map(c => c[2].trim()) + if (choices.length !== 4) continue + questions.push({ num, question: body.split(/[A-D][.)]/)[0].trim(), choices, year, source }) + } + // parse answers separately and attach + const answers = {} + ANSWER_RE.lastIndex = 0 + let am + while ((am = ANSWER_RE.exec(block)) !== null) { + answers[parseInt(am[1])] = LETTER_TO_INDEX[am[2].toUpperCase()] ?? 0 + } + return questions.map(q => ({ ...q, correct: answers[q.num] ?? 0 })) +} + +function normalizeQuestions(raw) { + return raw.map((q, i) => ({ + id: q.id || `q_${q.source}_${q.year}_${String(i + 1).padStart(3, '0')}`, + source: q.source, + year: q.year, + topic: q.topic || 'algebra', + difficulty: q.difficulty || 'medium', + question: q.question, + choices: q.choices, + correct: typeof q.correct === 'number' ? q.correct : (LETTER_TO_INDEX[q.correct] ?? 0), + explanation: q.explanation ?? null, + figure: q.figure ?? null, + })) +} + +function normalizeExams(questions) { + const byYear = {} + for (const q of questions) { + if (!byYear[q.year]) byYear[q.year] = [] + byYear[q.year].push(q.id) + } + return Object.entries(byYear).map(([year, ids]) => ({ + id: `hcmc_${year}_math_thithu`, + year: parseInt(year), + title: `Đề thi vào lớp 10 Toán - TPHCM ${year}`, + duration: 90, + mode: 'thithu', + questionIds: ids, + totalQuestions: ids.length, + })) +} + +export const normalize = { questions: normalizeQuestions, exams: normalizeExams } diff --git a/exam-app/scripts/crawl/pipeline/publish.js b/exam-app/scripts/crawl/pipeline/publish.js new file mode 100644 index 0000000000000000000000000000000000000000..59a15112a17f7694414e9cf885c4834a078f0efc --- /dev/null +++ b/exam-app/scripts/crawl/pipeline/publish.js @@ -0,0 +1,80 @@ +import { readFileSync, writeFileSync, existsSync } from 'fs' +import { join, dirname } from 'path' +import { fileURLToPath } from 'url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const OUTPUT_DIR = join(__dirname, '../output') +const DATA_DIR = join(__dirname, '../../../src/data') + +const checkOnly = process.argv.includes('--check-only') + +function readJson(path) { + return existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : null +} + +function mergeById(existing, incoming) { + const map = Object.fromEntries(existing.map(x => [x.id, x])) + for (const item of incoming) { + if (!map[item.id]) { + map[item.id] = item + } else if (item.figure?.data && !map[item.id].figure?.data) { + // Update figure data if incoming has one and existing doesn't + map[item.id] = { ...map[item.id], figure: item.figure } + } + } + return Object.values(map) +} + +const crawledQuestions = readJson(join(OUTPUT_DIR, 'questions.json')) +const crawledExams = readJson(join(OUTPUT_DIR, 'exams.json')) + +if (!crawledQuestions || !crawledExams) { + console.error('Missing output files. Run: node exam-app/scripts/crawl/index.js --only=questions') + process.exit(1) +} + +const existingQuestions = readJson(join(DATA_DIR, 'questions.json')) ?? [] +const existingExams = readJson(join(DATA_DIR, 'exams.json')) ?? [] + +const mergedQuestions = mergeById(existingQuestions, crawledQuestions) +const mergedExams = mergeById(existingExams, crawledExams) + +// Integrity checks +const questionIds = new Set(mergedQuestions.map(q => q.id)) +const integrityErrors = [] + +for (const exam of mergedExams) { + for (const qid of (exam.questionIds ?? [])) { + if (!questionIds.has(qid)) + integrityErrors.push(`Exam ${exam.id}: questionId "${qid}" not found in questions`) + } +} +for (const q of mergedQuestions) { + if (!Array.isArray(q.choices) || q.choices.length !== 4) + integrityErrors.push(`Question ${q.id}: must have exactly 4 choices`) + if (typeof q.correct !== 'number' || q.correct < 0 || q.correct > 3) + integrityErrors.push(`Question ${q.id}: correct=${q.correct} out of range 0–3`) + if (q.needs_figure && !q.figure?.data) + console.warn(` [WARN] ${q.id}: needs_figure but no SVG — will render text only`) +} + +if (integrityErrors.length > 0) { + integrityErrors.forEach(e => console.error('[INTEGRITY]', e)) + process.exit(1) +} + +const thiThuExams = mergedExams.filter(e => e.mode === 'thithu') +const thiThuQuestions = mergedQuestions.filter(q => crawledQuestions.some(c => c.id === q.id)) + +console.log(`Integrity: OK`) +console.log(` Questions: ${existingQuestions.length} existing + ${thiThuQuestions.length} new = ${mergedQuestions.length} total`) +console.log(` Exams: ${existingExams.length} existing + ${thiThuExams.length} thithu = ${mergedExams.length} total`) + +if (checkOnly) { + console.log('--check-only: no files written.') + process.exit(0) +} + +writeFileSync(join(DATA_DIR, 'questions.json'), JSON.stringify(mergedQuestions, null, 2)) +writeFileSync(join(DATA_DIR, 'exams.json'), JSON.stringify(mergedExams, null, 2)) +console.log('Published to src/data/') diff --git a/exam-app/scripts/crawl/pipeline/tag.js b/exam-app/scripts/crawl/pipeline/tag.js new file mode 100644 index 0000000000000000000000000000000000000000..33f9c45d028bcc354ae38e0dc94d033ae34118ec --- /dev/null +++ b/exam-app/scripts/crawl/pipeline/tag.js @@ -0,0 +1,56 @@ +const TOPIC_KEYWORDS = { + algebra: ['phương trình', 'bất phương trình', 'hàm số', 'đa thức', 'hệ phương trình', 'căn thức'], + geometry: ['tam giác', 'hình tròn', 'đường thẳng', 'góc', 'diện tích', 'chu vi', 'tiếp tuyến'], + statistics: ['xác suất', 'thống kê', 'tần số', 'trung bình cộng', 'biểu đồ'], + combinatorics: ['tổ hợp', 'chỉnh hợp', 'hoán vị', 'nhị thức Newton', 'quy tắc đếm'], +} + +const FIGURE_KEYWORDS = [ + 'AB =', 'AC =', 'BC =', 'bán kính', 'đường kính', + 'điểm A(', 'điểm B(', 'tọa độ', + 'đồ thị hàm số', 'parabol', 'trục hoành', 'trục tung', + 'tam giác ABC', 'tứ giác ABCD', 'hình thang ABCD', + 'nội tiếp', 'ngoại tiếp', 'tiếp tuyến tại', + 'đường tròn (O', 'đường tròn (O;', 'dây AB', 'dây cung', 'dây CD', + 'tiếp tuyến MA', 'tiếp tuyến MB', 'kẻ hai tiếp tuyến', + 'khoảng cách từ tâm', 'từ điểm M nằm ngoài', +] + +function detectFigure(question) { + return FIGURE_KEYWORDS.some(kw => question.includes(kw)) +} + +function detectTopic(question) { + const text = question.toLowerCase() + for (const [topic, keywords] of Object.entries(TOPIC_KEYWORDS)) { + if (keywords.some(kw => text.includes(kw))) return topic + } + return 'algebra' +} + +function detectDifficulty(index, total) { + const pct = index / total + if (pct < 10 / 30) return 'easy' + if (pct < 25 / 30) return 'medium' + return 'hard' +} + +export function tag(questions) { + const byYear = {} + for (const q of questions) { + if (!byYear[q.year]) byYear[q.year] = [] + byYear[q.year].push(q) + } + const result = [] + for (const group of Object.values(byYear)) { + group.forEach((q, i) => { + result.push({ + ...q, + topic: q.topic !== 'algebra' ? q.topic : detectTopic(q.question), + difficulty: q.difficulty || detectDifficulty(i, group.length), + needs_figure: detectFigure(q.question), + }) + }) + } + return result +} diff --git a/exam-app/scripts/crawl/pipeline/validate.js b/exam-app/scripts/crawl/pipeline/validate.js new file mode 100644 index 0000000000000000000000000000000000000000..c74cd95c4dd18cb36e6c78cf4816b461cc2fa408 --- /dev/null +++ b/exam-app/scripts/crawl/pipeline/validate.js @@ -0,0 +1,79 @@ +import { readFileSync } from 'fs' +import { join, dirname } from 'path' +import { fileURLToPath } from 'url' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const OUTPUT_DIR = join(__dirname, '../output') + +const checkOnly = process.argv.includes('--check-only') + +function validateQuestions(questions) { + const errors = [] + if (!questions || questions.length < 200) + errors.push(`questions.json: only ${questions?.length ?? 0} questions, need ≥ 200`) + + const yearMap = {} + for (const q of (questions ?? [])) { + yearMap[q.year] = (yearMap[q.year] ?? 0) + 1 + } + const years = Object.keys(yearMap) + if (years.length < 8) + errors.push(`questions.json: only ${years.length} years, need ≥ 8`) + + for (const [yr, count] of Object.entries(yearMap)) { + if (count < 8) + console.warn(` [WARN] Year ${yr}: only ${count} questions (< 8, likely mixed-format exam)`) + } + + for (const q of (questions ?? [])) { + if (typeof q.correct !== 'number' || q.correct < 0 || q.correct > 3) + errors.push(`Question ${q.id}: correct=${q.correct} out of range 0–3`) + } + + const topics = ['algebra', 'geometry', 'statistics', 'combinatorics'] + const total = questions?.length ?? 0 + for (const topic of topics) { + const count = questions?.filter(q => q.topic === topic).length ?? 0 + if (count / total > 0.6) + errors.push(`Topic "${topic}" is ${Math.round(count/total*100)}% of total — exceeds 60%`) + } + return errors +} + +function validateSchools(schools) { + const errors = [] + if (!schools || schools.length < 30) + errors.push(`schools.json: only ${schools?.length ?? 0} schools, need ≥ 30`) + + for (const s of (schools ?? [])) { + if (!s.trend) errors.push(`School ${s.id}: missing trend`) + const years = Object.keys(s.cutoffs ?? {}) + if (years.length < 3) errors.push(`School ${s.id}: only ${years.length} cutoff years`) + for (const yr of years) { + if (!s.cutoffs[yr].math || !s.cutoffs[yr].total) + errors.push(`School ${s.id} year ${yr}: missing math or total cutoff`) + } + } + return errors +} + +export function validate(questions, schools) { + const errors = [ + ...validateQuestions(questions), + ...validateSchools(schools), + ] + return { valid: errors.length === 0, errors } +} + +// CLI entry +if (checkOnly) { + let questions, schools + try { questions = JSON.parse(readFileSync(join(OUTPUT_DIR, 'questions.json'), 'utf8')) } catch {} + try { schools = JSON.parse(readFileSync(join(OUTPUT_DIR, 'schools.json'), 'utf8')) } catch {} + const { valid, errors } = validate(questions, schools) + if (!valid) { + errors.forEach(e => console.error('[INVALID]', e)) + process.exit(1) + } + console.log('Validation passed.') +} diff --git a/exam-app/scripts/crawl/sources/questions/hsg.js b/exam-app/scripts/crawl/sources/questions/hsg.js new file mode 100644 index 0000000000000000000000000000000000000000..a669331869bcf3a54375d9d79e5cbb707dc18b13 --- /dev/null +++ b/exam-app/scripts/crawl/sources/questions/hsg.js @@ -0,0 +1,49 @@ +/** + * HSG (Học sinh giỏi) math competition scraper — thuvienhoclieu.com + * Returns competition problems tagged source:"hsg", difficulty:"hard". + */ +import * as cheerio from 'cheerio' +import { http } from '../../httpClient.js' +import { parseQuestionBlock } from '../../pipeline/normalize.js' + +const BASE = 'https://thuvienhoclieu.com' +const LISTING = '/toan/hsg' +const YEAR_RANGE = [2018, 2019, 2020, 2021, 2022, 2023, 2024] + +export async function crawlHSG() { + let listing + try { + listing = await http.get(BASE + LISTING) + } catch (e) { + console.warn(` [WARN] hsg listing: ${e.message}`) + return [] + } + + const $ = cheerio.load(listing.data) + const byYear = {} + $('a[href]').each((_, el) => { + const href = $(el).attr('href') ?? '' + if (!href.toLowerCase().includes('hsg') && !href.includes('hoc-sinh-gioi')) return + const m = href.match(/20(\d{2})/) + if (!m) return + const year = 2000 + parseInt(m[1]) + if (!YEAR_RANGE.includes(year) || byYear[year]) return + byYear[year] = href.startsWith('http') ? href : BASE + href + }) + + const results = [] + for (const [year, url] of Object.entries(byYear)) { + try { + const page = await http.get(url) + const $p = cheerio.load(page.data) + const text = $p('.post-content, .article-content, .entry-content').text() + const questions = parseQuestionBlock(text, parseInt(year), 'hsg') + results.push(...questions.map(q => ({ ...q, difficulty: 'hard' }))) + console.log(` hsg ${year}: ${questions.length} questions`) + await new Promise(r => setTimeout(r, 1500)) + } catch (e) { + console.warn(` [WARN] hsg ${year}: ${e.message}`) + } + } + return results +} diff --git a/exam-app/scripts/crawl/sources/questions/loigiaihay.js b/exam-app/scripts/crawl/sources/questions/loigiaihay.js new file mode 100644 index 0000000000000000000000000000000000000000..020d4bf413bae73fcd28ac211db91215d984adeb --- /dev/null +++ b/exam-app/scripts/crawl/sources/questions/loigiaihay.js @@ -0,0 +1,43 @@ +import * as cheerio from 'cheerio' +import { http } from '../../httpClient.js' +import { parseQuestionBlock } from '../../pipeline/normalize.js' + +const BASE = 'https://loigiaihay.com' +const LISTING = '/de-thi-vao-lop-10' +const YEAR_RANGE = [2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024] + +export async function crawlLoiGiaiHay() { + const res = await http.get(BASE + LISTING) + const $ = cheerio.load(res.data) + + const byYear = {} + $('a[href]').each((_, el) => { + const href = $(el).attr('href') ?? '' + if (!href.includes('de-thi') && !href.includes('toan')) return + const m = href.match(/20(\d{2})/) + if (!m) return + const year = 2000 + parseInt(m[1]) + if (!YEAR_RANGE.includes(year) || byYear[year]) return + byYear[year] = href.startsWith('http') ? href : BASE + href + }) + + const results = [] + for (const [year, url] of Object.entries(byYear)) { + try { + const page = await http.get(url) + const $p = cheerio.load(page.data) + const text = $p('.post-content, article, .content').text() + const questions = parseQuestionBlock(text, parseInt(year), 'loigiaihay') + if (questions.length === 0) { + console.warn(` [WARN] loigiaihay ${year}: 0 questions parsed`) + continue + } + results.push(...questions) + console.log(` loigiaihay ${year}: ${questions.length} questions`) + await new Promise(r => setTimeout(r, 1500)) + } catch (e) { + console.warn(` [WARN] loigiaihay ${year}: ${e.message}`) + } + } + return results +} diff --git a/exam-app/scripts/crawl/sources/questions/tailieu.js b/exam-app/scripts/crawl/sources/questions/tailieu.js new file mode 100644 index 0000000000000000000000000000000000000000..1fe595ee1625f405aae7ea802ef3ee0babfe84cf --- /dev/null +++ b/exam-app/scripts/crawl/sources/questions/tailieu.js @@ -0,0 +1,43 @@ +import * as cheerio from 'cheerio' +import { http } from '../../httpClient.js' +import { parseQuestionBlock } from '../../pipeline/normalize.js' + +const BASE = 'https://tailieu.vn' +const TAG = '/tag/toan-vao-lop-10-tphcm' +const YEAR_RANGE = [2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024] + +export async function crawlTaiLieu() { + const res = await http.get(BASE + TAG) + const $ = cheerio.load(res.data) + + const byYear = {} + $('a[href]').each((_, el) => { + const href = $(el).attr('href') ?? '' + if (!href.includes('de-thi') && !href.includes('toan')) return + const m = href.match(/20(\d{2})/) + if (!m) return + const year = 2000 + parseInt(m[1]) + if (!YEAR_RANGE.includes(year) || byYear[year]) return + byYear[year] = href.startsWith('http') ? href : BASE + href + }) + + const results = [] + for (const [year, url] of Object.entries(byYear)) { + try { + const page = await http.get(url) + const $p = cheerio.load(page.data) + const text = $p('.document-content, .content-detail, article').text() + const questions = parseQuestionBlock(text, parseInt(year), 'tailieu') + if (questions.length === 0) { + console.warn(` [WARN] tailieu ${year}: 0 questions parsed`) + continue + } + results.push(...questions) + console.log(` tailieu ${year}: ${questions.length} questions`) + await new Promise(r => setTimeout(r, 1500)) + } catch (e) { + console.warn(` [WARN] tailieu ${year}: ${e.message}`) + } + } + return results +} diff --git a/exam-app/scripts/crawl/sources/questions/thuvienhoclieu.js b/exam-app/scripts/crawl/sources/questions/thuvienhoclieu.js new file mode 100644 index 0000000000000000000000000000000000000000..675bc35c8badca348ff89935a210e64fe50976c3 --- /dev/null +++ b/exam-app/scripts/crawl/sources/questions/thuvienhoclieu.js @@ -0,0 +1,43 @@ +import * as cheerio from 'cheerio' +import { http } from '../../httpClient.js' +import { parseQuestionBlock } from '../../pipeline/normalize.js' + +const BASE = 'https://thuvienhoclieu.com' +const TAG = '/de-thi-thu-toan-lop-10-tphcm' +const YEAR_RANGE = [2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024] + +export async function crawlThuVienHocLieu() { + const res = await http.get(BASE + TAG) + const $ = cheerio.load(res.data) + + const byYear = {} + $('a[href]').each((_, el) => { + const href = $(el).attr('href') ?? '' + if (!href.includes('de-thi') && !href.includes('toan')) return + const m = href.match(/20(\d{2})/) + if (!m) return + const year = 2000 + parseInt(m[1]) + if (!YEAR_RANGE.includes(year) || byYear[year]) return + byYear[year] = href.startsWith('http') ? href : BASE + href + }) + + const results = [] + for (const [year, url] of Object.entries(byYear)) { + try { + const page = await http.get(url) + const $p = cheerio.load(page.data) + const text = $p('.entry-content, .post-content').text() + const questions = parseQuestionBlock(text, parseInt(year), 'thuvienhoclieu') + if (questions.length === 0) { + console.warn(` [WARN] thuvienhoclieu ${year}: 0 questions parsed`) + continue + } + results.push(...questions) + console.log(` thuvienhoclieu ${year}: ${questions.length} questions`) + await new Promise(r => setTimeout(r, 1500)) + } catch (e) { + console.warn(` [WARN] thuvienhoclieu ${year}: ${e.message}`) + } + } + return results +} diff --git a/exam-app/scripts/crawl/sources/questions/toanmath.js b/exam-app/scripts/crawl/sources/questions/toanmath.js new file mode 100644 index 0000000000000000000000000000000000000000..663e1970d3b83792406367c38cb7cbb98cc13730 --- /dev/null +++ b/exam-app/scripts/crawl/sources/questions/toanmath.js @@ -0,0 +1,42 @@ +import * as cheerio from 'cheerio' +import { http } from '../../httpClient.js' +import { parseQuestionBlock } from '../../pipeline/normalize.js' + +const BASE = 'https://toanmath.com' +const TAG = '/tag/de-thi-toan-vao-lop-10-tphcm' +const YEAR_RANGE = [2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024] + +export async function crawlToanMath() { + const res = await http.get(BASE + TAG) + const $ = cheerio.load(res.data) + + const byYear = {} + $('a[href]').each((_, el) => { + const href = $(el).attr('href') ?? '' + const m = href.match(/20(\d{2})/) + if (!m) return + const year = 2000 + parseInt(m[1]) + if (!YEAR_RANGE.includes(year) || byYear[year]) return + byYear[year] = href.startsWith('http') ? href : BASE + href + }) + + const results = [] + for (const [year, url] of Object.entries(byYear)) { + try { + const page = await http.get(url) + const $p = cheerio.load(page.data) + const text = $p('.entry-content, .post-content, article').text() + const questions = parseQuestionBlock(text, parseInt(year), 'toanmath') + if (questions.length === 0) { + console.warn(` [WARN] toanmath ${year}: 0 questions parsed`) + continue + } + results.push(...questions) + console.log(` toanmath ${year}: ${questions.length} questions`) + await new Promise(r => setTimeout(r, 1500)) + } catch (e) { + console.warn(` [WARN] toanmath ${year}: ${e.message}`) + } + } + return results +} diff --git a/exam-app/scripts/crawl/sources/questions/vndoc.js b/exam-app/scripts/crawl/sources/questions/vndoc.js new file mode 100644 index 0000000000000000000000000000000000000000..240389be868ccc02009d24446b172ed2efdafb53 --- /dev/null +++ b/exam-app/scripts/crawl/sources/questions/vndoc.js @@ -0,0 +1,43 @@ +import * as cheerio from 'cheerio' +import { http } from '../../httpClient.js' +import { parseQuestionBlock } from '../../pipeline/normalize.js' + +const BASE = 'https://vndoc.com' +const LISTING = '/de-thi-vao-lop-10-mon-toan-tp-hcm' +const YEAR_RANGE = [2015, 2016, 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024] + +export async function crawlQuestions() { + const res = await http.get(BASE + LISTING) + const $ = cheerio.load(res.data) + + const byYear = {} + $('a[href]').each((_, el) => { + const href = $(el).attr('href') ?? '' + if (!href.includes('toan') && !href.includes('de-thi')) return + const m = href.match(/20(\d{2})/) + if (!m) return + const year = 2000 + parseInt(m[1]) + if (!YEAR_RANGE.includes(year) || byYear[year]) return + byYear[year] = href.startsWith('http') ? href : BASE + href + }) + + const results = [] + for (const [year, url] of Object.entries(byYear)) { + try { + const page = await http.get(url) + const $p = cheerio.load(page.data) + const text = $p('.post-content, .article-content, .content').text() + const questions = parseQuestionBlock(text, parseInt(year), 'vndoc') + if (questions.length === 0) { + console.warn(` [WARN] vndoc ${year}: 0 questions parsed`) + continue + } + results.push(...questions) + console.log(` vndoc ${year}: ${questions.length} questions`) + await new Promise(r => setTimeout(r, 1500)) + } catch (e) { + console.warn(` [WARN] vndoc ${year}: ${e.message}`) + } + } + return results +} diff --git a/exam-app/scripts/crawl/sources/schools/bgdt.js b/exam-app/scripts/crawl/sources/schools/bgdt.js new file mode 100644 index 0000000000000000000000000000000000000000..ffbe035edc6ca3765c258ce27855810ad7d2745d --- /dev/null +++ b/exam-app/scripts/crawl/sources/schools/bgdt.js @@ -0,0 +1,50 @@ +/** + * BGDT (Bộ Giáo dục và Đào tạo) official exam scraper — moet.gov.vn + * Returns structured exam questions tagged source:"bgdt_official". + * Deduplicates against hcmedu output by source key. + */ +import * as cheerio from 'cheerio' +import { http } from '../../httpClient.js' +import { parseQuestionBlock } from '../../pipeline/normalize.js' + +const BASE = 'https://moet.gov.vn' +const LISTING = '/giaoducquocdan/de-thi-vao-lop-10' +const YEAR_RANGE = [2018, 2019, 2020, 2021, 2022, 2023, 2024] + +export async function crawlBGDT() { + let listing + try { + listing = await http.get(BASE + LISTING) + } catch (e) { + console.warn(` [WARN] bgdt listing: ${e.message}`) + return [] + } + + const $ = cheerio.load(listing.data) + const byYear = {} + $('a[href]').each((_, el) => { + const href = $(el).attr('href') ?? '' + if (!href.includes('toan') && !href.includes('de-thi')) return + const m = href.match(/20(\d{2})/) + if (!m) return + const year = 2000 + parseInt(m[1]) + if (!YEAR_RANGE.includes(year) || byYear[year]) return + byYear[year] = href.startsWith('http') ? href : BASE + href + }) + + const results = [] + for (const [year, url] of Object.entries(byYear)) { + try { + const page = await http.get(url) + const $p = cheerio.load(page.data) + const text = $p('.post-content, .article-content, .entry-content, .content').text() + const questions = parseQuestionBlock(text, parseInt(year), 'bgdt_official') + results.push(...questions) + console.log(` bgdt_official ${year}: ${questions.length} questions`) + await new Promise(r => setTimeout(r, 1500)) + } catch (e) { + console.warn(` [WARN] bgdt_official ${year}: ${e.message}`) + } + } + return results +} diff --git a/exam-app/scripts/crawl/sources/schools/dantri.js b/exam-app/scripts/crawl/sources/schools/dantri.js new file mode 100644 index 0000000000000000000000000000000000000000..3bf2dfb4b41b9e5a7fc269d2a9027299a698b241 --- /dev/null +++ b/exam-app/scripts/crawl/sources/schools/dantri.js @@ -0,0 +1,38 @@ +import axios from 'axios' +import * as cheerio from 'cheerio' + +const BASE = 'https://dantri.com.vn' +const YEARS = [2020, 2021, 2022, 2023, 2024] + +export async function crawlDantri() { + const results = [] + for (const year of YEARS) { + const url = `${BASE}/giao-duc-huong-nghiep/diem-chuan-vao-lop-10-tphcm-${year}.htm` + try { + const res = await axios.get(url, { timeout: 10000 }) + const $ = cheerio.load(res.data) + const primary = {} + $('table tr').each((_, row) => { + const cells = $(row).find('td') + if (cells.length < 3) return + const name = $(cells[0]).text().trim() + const math = parseFloat($(cells[1]).text()) + const total = parseFloat($(cells[2]).text()) + if (!name || isNaN(math)) return + if (primary[name]) { + // cross-validation: flag conflict if > 0.5 difference + if (Math.abs(primary[name].math - math) > 0.5) { + results.push({ type: 'conflict', name, year, primary: primary[name].math, secondary: math }) + } + } else { + primary[name] = { math, total } + results.push({ type: 'cutoff', name, source: 'dantri', cutoffs: { [year]: { math, total } } }) + } + }) + await new Promise(r => setTimeout(r, 1500)) + } catch (e) { + console.warn(` dantri ${year}: ${e.message}`) + } + } + return results +} diff --git a/exam-app/scripts/crawl/sources/schools/hcmedu.js b/exam-app/scripts/crawl/sources/schools/hcmedu.js new file mode 100644 index 0000000000000000000000000000000000000000..3ec6cc33cbb71e80b197153b5f564958e965b8f3 --- /dev/null +++ b/exam-app/scripts/crawl/sources/schools/hcmedu.js @@ -0,0 +1,29 @@ +import axios from 'axios' +import * as cheerio from 'cheerio' + +const BASE = 'https://hcm.edu.vn' +const DIRECTORY = '/truong-thpt' + +const TYPE_MAP = { 'chuyên': 'chuyên', 'chất lượng cao': 'chất_lượng_cao', 'thường': 'thường' } + +export async function crawlHcmedu() { + const results = [] + try { + const res = await axios.get(BASE + DIRECTORY, { timeout: 10000 }) + const $ = cheerio.load(res.data) + $('.school-item, tr').each((_, el) => { + const name = $(el).find('.school-name, td:nth-child(1)').text().trim() + const district = $(el).find('.district, td:nth-child(2)').text().trim() + const typeRaw = $(el).find('.type, td:nth-child(3)').text().trim().toLowerCase() + const type = TYPE_MAP[typeRaw] ?? 'thường' + if (!name) return + const id = name.toLowerCase() + .normalize('NFD').replace(/[̀-ͯ]/g, '').replace(/đ/g, 'd') + .replace(/\s+/g, '_').replace(/[^a-z0-9_]/g, '') + results.push({ type: 'profile', id: `thpt_${id}`, name, district, type }) + }) + } catch (e) { + console.warn(` hcmedu: ${e.message}`) + } + return results +} diff --git a/exam-app/scripts/crawl/sources/schools/tuyensinh247.js b/exam-app/scripts/crawl/sources/schools/tuyensinh247.js new file mode 100644 index 0000000000000000000000000000000000000000..b3d87f37069a5ef076e233331594ebc5c529cb28 --- /dev/null +++ b/exam-app/scripts/crawl/sources/schools/tuyensinh247.js @@ -0,0 +1,29 @@ +import axios from 'axios' +import * as cheerio from 'cheerio' + +const BASE = 'https://tuyensinh247.com' +const YEARS = [2020, 2021, 2022, 2023, 2024] + +export async function crawlTuyenSinh247() { + const results = [] + for (const year of YEARS) { + const url = `${BASE}/diem-chuan-vao-lop-10-tphcm-${year}.html` + try { + const res = await axios.get(url, { timeout: 10000 }) + const $ = cheerio.load(res.data) + $('table tr').each((_, row) => { + const cells = $(row).find('td') + if (cells.length < 3) return + const name = $(cells[0]).text().trim() + const math = parseFloat($(cells[1]).text()) + const total = parseFloat($(cells[2]).text()) + if (!name || isNaN(math)) return + results.push({ type: 'cutoff', name, source: 'tuyensinh247', cutoffs: { [year]: { math, total } } }) + }) + await new Promise(r => setTimeout(r, 1500)) + } catch (e) { + console.warn(` tuyensinh247 ${year}: ${e.message}`) + } + } + return results +} diff --git a/exam-app/scripts/ingest/crawl-bridge.js b/exam-app/scripts/ingest/crawl-bridge.js new file mode 100644 index 0000000000000000000000000000000000000000..bafdfb55d75f24941a426e4e123b52f059a83c58 --- /dev/null +++ b/exam-app/scripts/ingest/crawl-bridge.js @@ -0,0 +1,61 @@ +import { readFileSync } from 'fs' +import { join, dirname } from 'path' +import { fileURLToPath } from 'url' +import { listPending, markIngested } from './state.js' +import { chunkQuestions } from './formatter.js' +import { ingestChunk } from './httpClient.js' + +const __dirname = dirname(fileURLToPath(import.meta.url)) +const QUESTIONS_PATH = join(__dirname, '../crawl/output/questions.json') +const BACKEND_URL = process.env.BACKEND_URL || 'http://localhost:8000' + +const dryRun = process.argv.includes('--dry-run') +const onlyArg = process.argv.find(a => a.startsWith('--only='))?.split('=')[1] +const onlyKeys = onlyArg ? new Set(onlyArg.split(',')) : null + +function groupBySourceKey(questions) { + const groups = new Map() + for (const q of questions) { + const key = `${q.source}_${q.year}` + if (!groups.has(key)) groups.set(key, []) + groups.get(key).push(q) + } + return groups +} + +async function main() { + const questions = JSON.parse(readFileSync(QUESTIONS_PATH, 'utf8')) + const groups = groupBySourceKey(questions) + const allKeys = [...groups.keys()] + let pendingKeys = listPending(allKeys) + if (onlyKeys) pendingKeys = pendingKeys.filter(k => onlyKeys.has(k)) + + console.log(`Total source keys: ${allKeys.length} | pending: ${pendingKeys.length}`) + + if (dryRun) { + console.log('DRY RUN — keys that would be ingested:') + pendingKeys.forEach((k, i) => console.log(` [${i + 1}/${pendingKeys.length}] ${k} (${groups.get(k).length} questions)`)) + return + } + + let done = 0 + for (const key of pendingKeys) { + done++ + const chunks = chunkQuestions(groups.get(key)) + let totalProblems = 0 + let totalWikiUnits = 0 + try { + for (const chunk of chunks) { + const res = await ingestChunk(chunk, BACKEND_URL) + totalProblems += res.problems + totalWikiUnits += res.wiki_units + } + markIngested(key) + console.log(`[${done}/${pendingKeys.length}] ${key} → ${totalProblems} problems, ${totalWikiUnits} wiki_units`) + } catch (e) { + console.error(`[${done}/${pendingKeys.length}] ${key} FAILED: ${e.message}`) + } + } +} + +main().catch(e => { console.error(e.message); process.exit(1) }) diff --git a/exam-app/scripts/ingest/formatter.js b/exam-app/scripts/ingest/formatter.js new file mode 100644 index 0000000000000000000000000000000000000000..4c3993f285caa6fda1d6862baeb847d769cca85c --- /dev/null +++ b/exam-app/scripts/ingest/formatter.js @@ -0,0 +1,21 @@ +const LETTERS = ['A', 'B', 'C', 'D'] + +export function formatQuestion(q, n) { + const lines = [`Câu ${n}. ${q.question}`] + if (Array.isArray(q.choices)) { + q.choices.forEach((c, i) => lines.push(`${LETTERS[i]}. ${c}`)) + } + const answerLetter = typeof q.correct === 'number' ? LETTERS[q.correct] : (q.correct ?? 'A') + lines.push(`Đáp án: ${answerLetter}`) + if (q.explanation) lines.push(q.explanation) + return lines.join('\n') +} + +export function chunkQuestions(questions, size = 15) { + const chunks = [] + for (let i = 0; i < questions.length; i += size) { + const slice = questions.slice(i, i + size) + chunks.push(slice.map((q, j) => formatQuestion(q, i + j + 1)).join('\n\n')) + } + return chunks +} diff --git a/exam-app/scripts/ingest/httpClient.js b/exam-app/scripts/ingest/httpClient.js new file mode 100644 index 0000000000000000000000000000000000000000..c94b3fee5553a4ff781b0f32a6344f98abcf9205 --- /dev/null +++ b/exam-app/scripts/ingest/httpClient.js @@ -0,0 +1,25 @@ +const sleep = ms => new Promise(r => setTimeout(r, ms)) + +export async function ingestChunk(text, backendUrl) { + const url = `${backendUrl}/math-ingest` + let delay = 1000 + for (let attempt = 1; attempt <= 3; attempt++) { + const res = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }), + }) + if (res.ok) { + const body = await res.json() + console.log(` → ${text.length} chars → ${body.problems} problems, ${body.wiki_units} wiki_units`) + return body + } + if (res.status >= 400 && res.status < 500) { + throw new Error(`4xx ${res.status}: ${await res.text()}`) + } + console.warn(` [attempt ${attempt}/3] ${res.status} — retrying in ${delay}ms`) + await sleep(delay) + delay *= 2 + } + throw new Error(`Failed after 3 retries (${text.length} chars)`) +} diff --git a/exam-app/scripts/ingest/state.js b/exam-app/scripts/ingest/state.js new file mode 100644 index 0000000000000000000000000000000000000000..e9c292998a6c9927a9351977a0c4f7618c489488 --- /dev/null +++ b/exam-app/scripts/ingest/state.js @@ -0,0 +1,29 @@ +import { readFileSync, writeFileSync, existsSync } from 'fs' +import { join, dirname } from 'path' +import { fileURLToPath } from 'url' + +const STATE_PATH = join(dirname(fileURLToPath(import.meta.url)), 'state.json') + +function _load() { + if (!existsSync(STATE_PATH)) return {} + try { return JSON.parse(readFileSync(STATE_PATH, 'utf8')) } catch { return {} } +} + +function _save(state) { + writeFileSync(STATE_PATH, JSON.stringify(state, null, 2)) +} + +export function isIngested(sourceKey) { + return Boolean(_load()[sourceKey]) +} + +export function markIngested(sourceKey) { + const state = _load() + state[sourceKey] = true + _save(state) +} + +export function listPending(allKeys) { + const state = _load() + return allKeys.filter(k => !state[k]) +} diff --git a/exam-app/src/App.jsx b/exam-app/src/App.jsx new file mode 100644 index 0000000000000000000000000000000000000000..3e679bab8078451d707a0d7ad555ac201bcce001 --- /dev/null +++ b/exam-app/src/App.jsx @@ -0,0 +1,213 @@ +import { useState, lazy, Suspense, useCallback } from 'react' +import { MotionConfig, AnimatePresence } from 'framer-motion' +import { Routes, Route, Navigate, useNavigate, useLocation } from 'react-router-dom' +import { ExamProvider } from './context/ExamContext.jsx' +import { HistoryProvider } from './context/HistoryContext.jsx' +import { useAuth } from './context/AuthContext.jsx' +import { useExamDispatch } from './context/ExamContext.jsx' +import { loadExamById, loadQuestionsByIds } from './api/index.js' +import Navbar from './components/Navbar.jsx' +import AuthModal from './components/AuthModal.jsx' +import ProfileOnboarding from './components/ProfileOnboarding.jsx' +import ExtendedOnboarding from './components/ExtendedOnboarding.jsx' +import LowCreditBanner from './components/LowCreditBanner.jsx' +import OfflineBanner from './components/OfflineBanner.jsx' +import ScrollToTop from './components/ScrollToTop.jsx' +import InstallPrompt from './components/InstallPrompt.jsx' +import { OracleProvider } from './context/OracleContext.jsx' + +const Landing = lazy(() => import('./pages/Landing.jsx')) +const ExamSelect = lazy(() => import('./pages/ExamSelect.jsx')) +const TestInterface = lazy(() => import('./pages/TestInterface.jsx')) +const Results = lazy(() => import('./pages/Results.jsx')) +const History = lazy(() => import('./pages/History.jsx')) +const StudyPlan = lazy(() => import('./pages/StudyPlan.jsx')) +const MathOracle = lazy(() => import('./pages/MathOracle.jsx')) +const Account = lazy(() => import('./pages/Account.jsx')) +const ReviewSession = lazy(() => import('./pages/ReviewSession.jsx')) +const Mistakes = lazy(() => import('./pages/Mistakes.jsx')) +const AdaptivePractice = lazy(() => import('./pages/AdaptivePractice.jsx')) +const DailyChallenge = lazy(() => import('./pages/DailyChallenge.jsx')) +const Admin = lazy(() => import('./pages/Admin.jsx')) +const AdminSecurityEvents = lazy(() => import('./pages/AdminSecurityEvents.jsx')) +const ShareView = lazy(() => import('./pages/ShareView.jsx')) +const ChallengeLanding = lazy(() => import('./pages/ChallengeLanding.jsx')) +const DiagnosticTest = lazy(() => import('./pages/DiagnosticTest.jsx')) +const GenerateExam = lazy(() => import('./pages/GenerateExam.jsx')) +const Progress = lazy(() => import('./pages/Progress.jsx')) +const AdaptiveStudyPlan = lazy(() => import('./pages/AdaptiveStudyPlan.jsx')) +const Placement = lazy(() => import('./pages/Placement.jsx')) + +const PageFallback = () =>
+ +function SuspensionModal({ reason, onLogout }) { + return ( +
+
+ +
+ Tài khoản bị tạm khoá + {reason &&

{reason}

} +

Liên hệ hỗ trợ nếu bạn cho rằng đây là nhầm lẫn.

+
+ +
+
+ ) +} + +function AppInner() { + const [authOpen, setAuthOpen] = useState(false) + const { user, loading, logout } = useAuth() + const dispatch = useExamDispatch() + const navigate = useNavigate() + const location = useLocation() + const isAdminRoute = location.pathname === '/admin' || location.pathname === '/admin/security-events' + + const [resumeBanner] = useState(() => { + try { + for (let i = 0; i < sessionStorage.length; i++) { + const key = sessionStorage.key(i) + if (key && key.startsWith('exam-draft-')) { + const raw = sessionStorage.getItem(key) + if (!raw) continue + const draft = JSON.parse(raw) + if (draft && draft.examId && draft.answers && Object.keys(draft.answers).length > 0) { + return { + examId: draft.examId, + answers: draft.answers, + mode: draft.mode || 'timed', + answeredCount: Object.keys(draft.answers).length, + userId: draft.userId ?? null, + } + } + } + } + } catch { + // ignore storage errors + } + return null + }) + const [resumeDismissed, setResumeDismissed] = useState(false) + + const handleResume = useCallback(async () => { + if (!resumeBanner) return + const exam = loadExamById(resumeBanner.examId) + if (!exam) { + setResumeDismissed(true) + sessionStorage.removeItem(`exam-draft-${resumeBanner.examId}`) + return + } + let questions + try { + questions = await loadQuestionsByIds(exam.questionIds) + } catch { + setResumeDismissed(true) + sessionStorage.removeItem(`exam-draft-${resumeBanner.examId}`) + return + } + dispatch({ type: 'START_EXAM', exam, questions, mode: resumeBanner.mode || 'timed' }) + for (const [questionId, choiceIndex] of Object.entries(resumeBanner.answers)) { + dispatch({ type: 'ANSWER_QUESTION', questionId, choiceIndex }) + } + setResumeDismissed(true) + navigate(`/test/${resumeBanner.examId}`) + }, [resumeBanner, dispatch, navigate]) + + const showOnboarding = !loading && user && !user.grade + const showExtendedOnboarding = !loading && user && user.grade && !user.extended_onboarding_done + const showLowCredit = !loading && user && (user.credits_balance ?? 0) < 10 + const showSuspension = !loading && Boolean(user?.is_suspended) + const showLocked = !loading && Boolean(user?.is_locked) + const showDeactivated = !loading && Boolean(user?.is_deactivated) + + return ( + <> + + + + {!isAdminRoute && setAuthOpen(true)} />} + setAuthOpen(false)} /> + {showDeactivated && ( + + )} + {!showDeactivated && showLocked && ( + + )} + {!showDeactivated && !showLocked && showSuspension && ( + + )} + {!isAdminRoute && !showDeactivated && !showLocked && !showSuspension && showOnboarding && ( + {}} /> + )} + {!isAdminRoute && !showDeactivated && !showLocked && !showSuspension && !showOnboarding && showExtendedOnboarding && ( + {}} /> + )} +
+ {showLowCredit && !isAdminRoute && !showOnboarding && !showDeactivated && !showLocked && !showSuspension && ( + + )} + }> + + + setAuthOpen(true)} />} /> + setAuthOpen(true)} />} /> + } /> + setAuthOpen(true)} />} /> + setAuthOpen(true)} />} /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + +
+ {resumeBanner && !resumeDismissed && (resumeBanner.userId ?? null) === (user?.id ?? null) && ( +
+
+ Bạn có bài thi đang dở + {resumeBanner.answeredCount} câu đã trả lời · Tiếp tục từ điểm dừng? +
+
+ + +
+
+ )} + + ) +} + +export default function App() { + return ( + + + + + + + + + + ) +} diff --git a/exam-app/src/api/aiClient.js b/exam-app/src/api/aiClient.js new file mode 100644 index 0000000000000000000000000000000000000000..18998ce369c292436e3bbf9e0ec824b01dd9a1e4 --- /dev/null +++ b/exam-app/src/api/aiClient.js @@ -0,0 +1,462 @@ +import axios from 'axios' +import { loadPreferences } from '../utils/aiPreferences.js' + +const BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000' + +const client = axios.create({ baseURL: BASE, timeout: 30000 }) +const slowClient = axios.create({ baseURL: BASE, timeout: 130000 }) + +let _logoutRef = null +export function setLogoutRef(fn) { _logoutRef = fn } + +let _refreshUserRef = null +export function setRefreshUserRef(fn) { _refreshUserRef = fn } + +// Optimistic credit helpers — wired in by AuthContext on mount +let _deductRef = null +let _refundRef = null +export function setCreditRefs(deduct, refund) { _deductRef = deduct; _refundRef = refund } + +const _ACCOUNT_STATUS_CODES = new Set(['account_locked', 'account_suspended', 'account_deactivated']) + +function _attachInterceptors(instance) { + instance.interceptors.request.use(config => { + const token = localStorage.getItem('auth_token') + if (token) config.headers.Authorization = `Bearer ${token}` + return config + }) + instance.interceptors.response.use( + res => res, + err => { + const status = err.response?.status + const code = err.response?.data?.detail?.code ?? err.response?.data?.code + if (status === 401) { + localStorage.removeItem('auth_token') + _logoutRef?.() + } else if (status === 403 && _ACCOUNT_STATUS_CODES.has(code)) { + // Refresh user so App.jsx picks up the new account status flag and shows the modal + _refreshUserRef?.() + } + return Promise.reject(err) + } + ) +} + +// Separate instance for admin requests — no 401 auto-logout (admin key failures must not log out the user) +const adminClient = axios.create({ baseURL: BASE, timeout: 30000 }) + +_attachInterceptors(client) +_attachInterceptors(slowClient) + +async function withRetry(fn, attempts = 2) { + for (let i = 0; i <= attempts; i++) { + try { return await fn() } + catch (err) { + const status = err?.response?.status + // Don't retry 4xx (auth/credits/tier errors) or last attempt + if (i === attempts || (status && status < 500)) throw err + await new Promise(r => setTimeout(r, 800 * (i + 1))) + } + } +} + +function wrap(promise) { + return promise + .then(res => ({ data: res.data, error: null, status: res.status })) + .catch(err => { + if (!err.response) return { data: null, error: 'Không thể kết nối đến máy chủ. Vui lòng thử lại sau.', status: 0 } + const detail = err.response.data?.detail + // Preserve structured error objects (e.g. 402 insufficient_credits) + return { data: null, error: detail ?? err.message ?? 'Lỗi kết nối', status: err.response.status } + }) +} + +function wrapRetry(fn) { + return wrap(withRetry(fn).catch(err => Promise.reject(err))) +} + +// Deducts `cost` Tia immediately, refunds if the server returns 402. +async function wrapOptimistic(cost, fn) { + if (!navigator.onLine) return { data: null, error: 'Bạn đang ngoại tuyến — kết nối mạng để dùng tính năng AI', status: 0 } + _deductRef?.(cost) + const result = await wrap(withRetry(fn).catch(err => Promise.reject(err))) + if (result.status === 402) _refundRef?.(cost) + return result +} + +function withAIPrefs(payload) { + return { ai_preferences: loadPreferences(), ...payload } +} + +export function analyzeResult(payload) { + return wrapOptimistic(3, () => client.post('/analyze', withAIPrefs(payload))) +} + +// Streams AI analysis as NDJSON field-by-field. +// onUpdate({ field: accumulatedValue, ... }) is called (via RAF) as content arrives. +// Returns { data: analysisObj, error, status } when the stream ends. +export async function analyzeResultStream(payload, onUpdate, signal) { + if (!navigator.onLine) return { data: null, error: 'Bạn đang ngoại tuyến — kết nối mạng để dùng tính năng AI', status: 0 } + const token = localStorage.getItem('auth_token') + if (!token) return { data: null, error: 'not authenticated', status: 401 } + _deductRef?.(3) + + const fieldData = {} // accumulated field text (raw) + const pending = {} // batched updates waiting for next RAF + let rafId = null + + const flush = () => { + rafId = null + if (Object.keys(pending).length === 0) return + const snap = { ...pending } + Object.keys(pending).forEach(k => delete pending[k]) + onUpdate?.(snap) + } + + try { + const res = await fetch(`${BASE}/analyze/stream`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify(withAIPrefs(payload)), + signal, + }) + if (!res.ok) { + const detail = await res.json().catch(() => ({})) + console.error('[analyzeResultStream] HTTP error:', res.status, detail) + if (res.status === 402) _refundRef?.(3) + return { data: null, error: detail?.detail ?? `HTTP ${res.status}`, status: res.status } + } + + const reader = res.body.getReader() + const decoder = new TextDecoder('utf-8') // handles Vietnamese 3-byte sequences across chunks + let lineBuffer = '' + + while (true) { + if (signal?.aborted) { reader.cancel(); return { data: null, error: 'aborted', status: 0 } } + const { value, done } = await reader.read() + if (done) break + lineBuffer += decoder.decode(value, { stream: true }) + const lines = lineBuffer.split('\n') + lineBuffer = lines.pop() ?? '' + + for (const line of lines) { + if (!line.trim()) continue + try { + const event = JSON.parse(line) + if (event.error) { + // Refund credits on server-side failures (backend already refunds DB balance) + _refundRef?.(3) + return { data: null, error: event.error, status: 502 } + } + const { field, chunk, done: isDone } = event + if (!field) continue + + if (isDone) { + // Field complete — try to JSON-decode string escape sequences + const raw = fieldData[field] ?? '' + let final = raw + try { final = JSON.parse('"' + raw + '"') } catch { /* keep raw */ } + fieldData[field] = final + } else if (chunk) { + fieldData[field] = (fieldData[field] ?? '') + chunk + pending[field] = fieldData[field] + if (!rafId) rafId = requestAnimationFrame(flush) + } + } catch { /* ignore malformed line */ } + } + } + + // Flush any remaining buffered updates + if (rafId) cancelAnimationFrame(rafId) + flush() + + // Parse array fields from JSON strings — fall back to [] on parse failure + for (const f of ['weak_topics', 'recommendations', 'schools']) { + if (typeof fieldData[f] === 'string') { + try { + fieldData[f] = JSON.parse(fieldData[f]) + } catch (e) { + console.warn(`[analyzeResultStream] Failed to parse field "${f}", using []:`, e) + fieldData[f] = [] + } + } + } + + return { data: fieldData, error: null, status: 200 } + } catch (err) { + if (rafId) cancelAnimationFrame(rafId) + if (err.name === 'AbortError') return { data: null, error: 'aborted', status: 0 } + console.error('[analyzeResultStream] stream error:', err) + return { data: null, error: err?.message ?? 'Stream error', status: 0 } + } +} + +export function getHint(payload) { + return wrapOptimistic(1, () => client.post('/hint', withAIPrefs(payload))) +} + +export function getExplanation(payload) { + return wrapOptimistic(1, () => client.post('/explain', withAIPrefs(payload))) +} + +export function generateStudyPlan(payload) { + return wrapRetry(() => slowClient.post('/study-plan', payload)) +} + +export async function solveMath(question, imageFile) { + let imagePayload = {} + if (imageFile) { + try { + const buf = await imageFile.arrayBuffer() + const bytes = new Uint8Array(buf) + let binary = '' + for (let i = 0; i < bytes.length; i++) binary += String.fromCharCode(bytes[i]) + imagePayload = { + image_base64: btoa(binary), + image_mime: imageFile.type || 'image/jpeg', + } + } catch { /* ignore serialisation errors — proceed without image */ } + } + return wrap(slowClient.post('/math-solve', { question, ...imagePayload })) +} + +export function getMathStats() { + return wrap(client.get('/math-stats')) +} + +export function reviewMath(problem, solution) { + return wrap(slowClient.post('/math-review', { problem, solution })) +} + +export function ocrImage(file) { + const form = new FormData() + form.append('file', file) + return wrap(slowClient.post('/math-ocr', form)) +} + +export function getWikiStatus() { + return wrap(client.get('/wiki/status')) +} + + +export function googleSignIn(idToken, ref) { + return wrap(client.post('/auth/google', { id_token: idToken, ...(ref ? { ref } : {}) })) +} + +export function upsertDevice(payload) { + return wrap(client.post('/users/me/device', payload)) +} + +// ── Learning Graph / Review endpoints ──────────────────────────────────────── + +export function migrateReviewItems(items) { + return wrap(client.post('/users/me/review-items', { items })) +} + +export function getDueReviewItems() { + return wrap(client.get('/users/me/review-items/due')) +} + +export function answerReviewItem(itemId, quality, responseTimeSeconds = null) { + return wrap(client.post(`/users/me/review-items/${itemId}/answer`, { + quality, + response_time_seconds: responseTimeSeconds, + })) +} + +export function getConceptMastery() { + return wrap(client.get('/users/me/concept-mastery')) +} + +export function getSessionToday() { + return wrap(client.get('/users/me/session/today')) +} + +export function completeSession() { + return wrap(client.post('/users/me/session/complete')) +} + +export function useStreakFreeze() { + return wrap(client.post('/users/me/streak-freeze')) +} + +export function getAdaptiveStudyPlan() { + return wrap(client.get('/users/me/adaptive-study-plan')) +} + +export function seedDiagnostic(weights) { + return wrap(client.post('/users/me/diagnostic-seed', { weights })) +} + +export function submitPlacement(answers) { + return wrap(client.post('/users/me/placement', { answers })) +} + +export function getMe() { + return wrap(client.get('/users/me')) +} + +export function updateExtendedProfile(payload) { + return wrap(client.post('/users/me/profile/extended', payload)) +} + +export function updateProfile(payload) { + return wrap(client.post('/users/me/profile', payload)) +} + +export function updateUsername(username) { + return wrap(client.patch('/users/me/username', { username })) +} + +export function acceptTos() { + return wrap(client.post('/users/me/tos-accept')) +} + +export function activateTrial() { + return wrap(client.post('/users/me/trial')) +} + +export function reportQuestion(questionId, reason) { + return wrap(client.post(`/questions/${questionId}/report`, { reason })) +} + +export function getCreditLog() { + return wrap(client.get('/users/me/credits/log')) +} + +export function classifyError(question, wrongChoice, correctChoice) { + return wrap(client.post('/classify-error', { question, wrong_choice: wrongChoice, correct_choice: correctChoice })) +} + +export function getPercentile(examId, score) { + return wrap(client.get(`/results/${encodeURIComponent(examId)}/percentile`, { params: { score } })) +} + +export function generateAdaptivePractice(payload) { + return wrapOptimistic(payload.count ?? 5, () => client.post('/adaptive-practice', payload)) +} + +export function getReferral() { + return wrap(client.get('/users/me/referral')) +} + +export function createClass(name) { + return wrap(client.post('/classes', { name })) +} + +export function joinClass(code) { + return wrap(client.post('/classes/join', { code })) +} + +export function listClasses() { + return wrap(client.get('/classes')) +} + +export function getClassResults(classId) { + return wrap(client.get(`/classes/${classId}/results`)) +} + +export function ocrExam(file) { + const form = new FormData() + form.append('file', file) + return wrap(client.post('/ocr/exam', form)) +} + +export function postHistory(entries) { + return wrap(client.post('/users/me/history', entries)) +} + +export function getHistory() { + return wrap(client.get('/users/me/history')) +} + +export const deleteAccount = (confirmEmail) => + wrap(client.delete('/users/me', { data: { confirm_email: confirmEmail } })) + +export const deactivateAccount = () => + wrap(client.post('/users/me/deactivate')) + +export const reactivateAccount = () => + wrap(client.post('/users/me/reactivate')) + +export const adminListUsers = (key, { search = '', page = 1, limit = 20 } = {}) => + wrap(adminClient.get('/admin/users', { params: { search, page, limit }, headers: { 'x-admin-key': key } })) + +export const adminDeleteUser = (key, userId) => + wrap(adminClient.delete(`/admin/users/${userId}`, { headers: { 'x-admin-key': key } })) + +export const adminUnlockUser = (key, userId) => + wrap(adminClient.post(`/admin/users/${userId}/unlock`, {}, { headers: { 'x-admin-key': key } })) + +export const adminResetUser = (key, userId) => + wrap(adminClient.post(`/admin/users/${userId}/reset`, {}, { headers: { 'x-admin-key': key } })) + +export const adminSuspendUser = (key, userId, reason) => + wrap(adminClient.post(`/admin/users/${userId}/suspend`, { reason }, { headers: { 'x-admin-key': key } })) + +export const adminUnsuspendUser = (key, userId) => + wrap(adminClient.post(`/admin/users/${userId}/unsuspend`, {}, { headers: { 'x-admin-key': key } })) + +export const adminGrantCredits = (key, userId, amount) => + wrap(adminClient.post(`/admin/users/${userId}/credits`, { amount }, { headers: { 'x-admin-key': key } })) + +export const adminGetSecurityEvents = (key) => + wrap(adminClient.get('/admin/security-events', { headers: { 'x-admin-key': key } })) + +export const adminGetUserDevices = (key, userId) => + wrap(adminClient.get(`/admin/users/${userId}/devices`, { headers: { 'x-admin-key': key } })) + +export const getPaymentConfig = () => + wrap(client.get('/payment/config')) + +export const getDailyChallenge = () => + wrap(client.get('/daily-challenge')) + +export const submitDailyScore = ({ question_id, correct }) => + wrap(client.post('/daily-challenge/score', { question_id, correct })) + +export const generateExam = (topicFocus, difficulty = 'medium', count = 10) => + wrap(slowClient.post('/generate-exam', { topic_focus: topicFocus, difficulty, count })) + +export const predictScore = () => + wrap(client.get('/predict-score')) + +export const examStrategy = () => + wrap(client.post('/strategy', {})) + +export const compareProvince = () => + wrap(client.get('/compare/province')) + +export const getChartInsights = (payload) => + wrap(client.post('/insights/charts', payload)) + +export const getWeeklyInsight = (payload) => + wrap(client.post('/insights/weekly', payload)) + +export const getPeerStats = () => + wrap(client.get('/insights/peer-stats')) + +// ── Sprint 19: Teacher class integration ───────────────────────────────────── + +export const getClassInfo = () => + wrap(client.get('/teacher-classes/me')) + +export const joinTeacherClass = (class_code) => + wrap(client.post('/teacher-classes/join', { class_code })) + +// ── Sprint 21: MOAT 5 — Study Partner Matching ──────────────────────────────── + +export const getPartnerCandidates = () => + wrap(client.get('/study-partners/candidates')) + +export const connectPartner = (partner_id) => + wrap(client.post('/study-partners/connect', { partner_id })) + +export const getMyPartners = () => + wrap(client.get('/study-partners/me')) + +export const respondToPartner = (request_id, action) => + wrap(client.post('/study-partners/respond', { request_id, action })) + +export const getSimulationBriefing = (payload) => + wrap(client.post('/insights/simulation-brief', payload)) diff --git a/exam-app/src/api/index.js b/exam-app/src/api/index.js new file mode 100644 index 0000000000000000000000000000000000000000..6ba0244eece1e5610b7cc19d1c0a575886bbd16f --- /dev/null +++ b/exam-app/src/api/index.js @@ -0,0 +1,312 @@ +import examsData from '../data/exams.json' +import schoolsData from '../data/schools.json' + +const _API_BASE = import.meta.env.VITE_API_BASE_URL || 'http://localhost:8000' + +async function _apiFetch(path, token) { + const res = await fetch(`${_API_BASE}${path}`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }) + if (!res.ok) throw new Error(`HTTP ${res.status}`) + return res.json() +} + +// In-memory caches +let _questionsCache = null // { [id]: question } +let _questionsPromise = null + +async function _loadQuestionsFromJson() { + const { default: local } = await import('../data/questions.json') + _questionsCache = Object.fromEntries(local.map(q => [q.id, q])) + return local +} + +export async function loadQuestions() { + if (_questionsCache) return Object.values(_questionsCache) + if (_questionsPromise) return _questionsPromise + _questionsPromise = (async () => { + try { + const token = localStorage.getItem('auth_token') + if (token) { + const data = await _apiFetch('/questions', token) + if (data?.length) { + _questionsCache = Object.fromEntries(data.map(q => [q.id, q])) + return data + } + } + } catch {} + // Offline fallback — JSON bundle + return _loadQuestionsFromJson() + })() + const result = await _questionsPromise + _questionsPromise = null + return result +} + +// Auth-gated variant — rejects unauthenticated callers +export async function loadQuestionsForExam() { + const token = localStorage.getItem('auth_token') + if (!token) throw new Error('auth_required') + return loadQuestions() +} + +// Exam list cache — populated from API, falls back to bundled JSON +let _examsCache = null + +async function _loadExamsData() { + if (_examsCache) return + try { + const data = await _apiFetch('/exams') + _examsCache = data?.length ? data : examsData + } catch { + _examsCache = examsData + } +} + +export async function loadExams() { + await _loadExamsData() + return _examsCache.filter(e => e.mode !== 'thithu' && e.mode !== 'retired') +} + +export async function loadThiThuExams() { + await _loadExamsData() + return _examsCache.filter(e => e.mode === 'thithu').sort((a, b) => b.year - a.year) +} + +export async function loadAppliedExams() { + await _loadExamsData() + return _examsCache.filter(e => e.mode === 'applied') +} + +export async function loadOlympiadExams() { + await _loadExamsData() + return _examsCache.filter(e => e.mode === 'olympiad') +} + +export function loadSchools() { + return schoolsData +} + +// Synchronous lookup (always uses bundled JSON — safe for sync render paths) +export function loadExamById(examId) { + return examsData.find(e => e.id === examId) ?? null +} + +// Async variant — fetches from API on cold cache, useful for deep-links before loadExams() runs +export async function loadExamByIdAsync(examId) { + const fromJson = loadExamById(examId) + if (fromJson) return fromJson + try { + return await _apiFetch(`/exams/${examId}`) + } catch { + return null + } +} + +export async function loadQuestionsByIds(ids, requireAuth = false) { + const data = requireAuth ? await loadQuestionsForExam() : await loadQuestions() + const map = Object.fromEntries(data.map(q => [q.id, q])) + return ids.map(id => map[id]).filter(Boolean) +} + +const PASS_THRESHOLD = 5.0 +const GATED_MODES = new Set(['thithu', 'practice']) + +/** + * Returns which exam IDs are accessible based on sequential progression. + * Within each category, exams sorted by year ascending; first is always open. + * Each subsequent exam unlocks when: (a) previous year passed ≥ 5.0, OR + * (b) user already has any result for it (grandfather clause for existing users). + * + * @param {Array} results - user's exam history (array of {examId, score}) + * @param {Array} allExams - exams currently in view (mode-filtered) + * @returns {{ accessible: Set, prerequisites: Object }} + */ +export function getAccessibleExamIds(results, allExams) { + const passedIds = new Set(results.filter(r => (r.score ?? 0) >= PASS_THRESHOLD).map(r => r.examId)) + const submittedIds = new Set(results.map(r => r.examId)) + const accessible = new Set() + const prerequisites = {} + + for (const category of ['grade10', 'thpt']) { + const ordered = allExams + .filter(e => e.category === category && (GATED_MODES.has(e.mode) || !e.mode)) + .sort((a, b) => (a.year ?? 0) - (b.year ?? 0)) + if (ordered.length === 0) continue + accessible.add(ordered[0].id) + for (let i = 1; i < ordered.length; i++) { + const prev = ordered[i - 1] + const curr = ordered[i] + prerequisites[curr.id] = prev.id + if (passedIds.has(prev.id) || submittedIds.has(curr.id)) { + accessible.add(curr.id) + } else { + break + } + } + } + return { accessible, prerequisites } +} + +const DIFF_RANK = { hard: 3, medium: 2, easy: 1 } + +// Normalize a Vietnamese province string for fuzzy matching +export function normProvince(p = '') { + return p.toLowerCase() + .replace(/thành phố|tp\.|tỉnh\s*/gi, '') + .replace(/\bhcm\b|ho chi minh|hồ chí minh/gi, 'hcm') + .trim() +} +// Keep private alias for internal use +const _normProvince = normProvince + +// Get the most recent math cutoff score from a school record +function _latestCutoff(school) { + const years = Object.keys(school.cutoffs || {}).sort().reverse() + for (const yr of years) { + const c = school.cutoffs[yr]?.math + if (c != null) return c + } + return null +} + +// Build matched school recommendations for the analyze payload +function _matchSchools(studentScore, province) { + const userProv = _normProvince(province) + const scoreMatched = schoolsData.filter(s => { + const cutoff = _latestCutoff(s) + return cutoff !== null && Math.abs(studentScore - cutoff) <= 2.0 + }) + const byScore = (a, b) => + Math.abs(studentScore - _latestCutoff(a)) - Math.abs(studentScore - _latestCutoff(b)) + + // Filter strictly by province when possible, fall back to national if <3 results + const provincial = userProv + ? scoreMatched.filter(s => _normProvince(s.province).includes(userProv)) + : scoreMatched + const pool = provincial.length >= 3 + ? provincial + : [...provincial, ...scoreMatched.filter(s => !_normProvince(s.province).includes(userProv || ''))] + return pool + .sort(byScore) + .slice(0, 6) + .map(s => ({ + school: { name: s.name }, + matchStrength: studentScore >= _latestCutoff(s) ? 'Rất phù hợp' : 'Khá phù hợp', + cutoff: _latestCutoff(s), + })) +} + +// Province-only school lookup for admin panel (no score filter) +export function getSchoolsByProvince(province) { + if (!province) return [] + const norm = _normProvince(province) + return schoolsData + .filter(s => _normProvince(s.province).includes(norm) || norm.includes(_normProvince(s.province))) + .slice(0, 3) +} + +// Builds the payload for /analyze including wrong questions. +export async function buildAnalyzePayload(result, history, _unused, examCategory, userProfile) { + const exam = loadExamById(result.examId) + const questions = exam ? await loadQuestionsByIds(exam.questionIds) : [] + + const wrong = questions + .filter(q => { + const chosen = result.answers?.[q.id] + return chosen !== undefined && chosen !== null && chosen !== q.correct + }) + .map(q => ({ + topic: q.topic, + difficulty: q.difficulty, + question: q.question, + correct_answer: q.choices[q.correct], + })) + .sort((a, b) => (DIFF_RANK[b.difficulty] || 0) - (DIFF_RANK[a.difficulty] || 0)) + .slice(0, 8) + + const province = userProfile?.province || userProfile?.location || '' + const studentScore = result?.score ?? 0 + const schoolRecs = _matchSchools(studentScore, province) + + return { + result, + history, + wrong_questions: wrong, + school_recommendations: schoolRecs, + exam_category: examCategory || exam?.category || "", + user_profile: userProfile || {}, + } +} + +// Returns { result, history, wrong_questions, topic_miss_counts } enriched +// with representative wrong questions (max 2–3 per topic, hardest first). +// Capped at 8 total so the prompt stays within token limits. +export async function buildStudyPlanPayload(result, history) { + const exam = loadExamById(result.examId) + const questions = exam ? await loadQuestionsByIds(exam.questionIds) : [] + + const allWrong = questions + .filter(q => { + const chosen = result.answers?.[q.id] + return chosen === undefined || chosen === null || chosen !== q.correct + }) + .map(q => ({ + topic: q.topic, + difficulty: q.difficulty, + question: q.question, + correct_answer: q.choices[q.correct], + explanation: q.explanation || '', + })) + + // Per-topic miss counts (full picture for the AI) + const topic_miss_counts = {} + for (const q of allWrong) { + topic_miss_counts[q.topic] = (topic_miss_counts[q.topic] || 0) + 1 + } + + // Select up to 3 representative questions per topic, hardest first, 8 total + const byTopic = {} + for (const q of allWrong) { + if (!byTopic[q.topic]) byTopic[q.topic] = [] + byTopic[q.topic].push(q) + } + for (const topic of Object.keys(byTopic)) { + byTopic[topic].sort((a, b) => (DIFF_RANK[b.difficulty] || 0) - (DIFF_RANK[a.difficulty] || 0)) + } + + const wrong_questions = [] + const PER_TOPIC = 3 + for (const qs of Object.values(byTopic)) { + wrong_questions.push(...qs.slice(0, PER_TOPIC)) + } + wrong_questions.sort((a, b) => (DIFF_RANK[b.difficulty] || 0) - (DIFF_RANK[a.difficulty] || 0)) + wrong_questions.splice(8) + + return { result, history, wrong_questions, topic_miss_counts } +} + +// Returns the best unattempted exam for the student given weak topics. +export async function recommendNextExam(weakTopics, attemptedExamIds) { + const allQuestions = await loadQuestions() + const allExams = examsData.filter(e => e.mode !== 'retired') + + const attempted = new Set(attemptedExamIds) + const candidates = allExams.filter(e => !attempted.has(e.id)) + if (!candidates.length) return null + + const weakSet = new Set(weakTopics) + + // Score each candidate by weak-topic overlap with its questions + const scores = await Promise.all(candidates.map(async exam => { + const qs = exam.questionIds + ? exam.questionIds.map(id => allQuestions.find(q => q.id === id)).filter(Boolean) + : [] + const overlap = qs.filter(q => weakSet.has(q.topic)).length + const ratio = qs.length > 0 ? overlap / qs.length : 0 + return { exam, score: ratio } + })) + + scores.sort((a, b) => b.score - a.score) + return scores[0]?.exam ?? null +} diff --git a/exam-app/src/components/AIErrorBoundary.jsx b/exam-app/src/components/AIErrorBoundary.jsx new file mode 100644 index 0000000000000000000000000000000000000000..036b846e887f41b270d22df14748fa8fcec86627 --- /dev/null +++ b/exam-app/src/components/AIErrorBoundary.jsx @@ -0,0 +1,27 @@ +import { Component } from 'react' + +export default class AIErrorBoundary extends Component { + constructor(props) { + super(props) + this.state = { hasError: false } + } + + static getDerivedStateFromError() { + return { hasError: true } + } + + componentDidCatch(error, info) { + console.error('[AIErrorBoundary] caught error:', error, info) + } + + render() { + if (this.state.hasError) { + return ( +
+ Phân tích AI không khả dụng +
+ ) + } + return this.props.children + } +} diff --git a/exam-app/src/components/AIInsights.jsx b/exam-app/src/components/AIInsights.jsx new file mode 100644 index 0000000000000000000000000000000000000000..d2e04bc847fe26ad43a20aab80dba1464acc67fc --- /dev/null +++ b/exam-app/src/components/AIInsights.jsx @@ -0,0 +1,273 @@ +import { useNavigate } from 'react-router-dom' +import { useEffect, useRef } from 'react' +import { motion, AnimatePresence } from 'framer-motion' +import { TOPIC_LABELS } from '../utils/topicLabels.js' +import { ResultsInsightsSkeleton } from './Skeleton.jsx' +import MarkdownProse from './MarkdownProse.jsx' + +// Renders streaming plain text with a CSS fade-in on each newly arrived chunk. +// key={prevLen} on the new-text span forces a fresh DOM node each chunk, retriggering the animation. +function StreamingText({ text, className = '' }) { + const prevLenRef = useRef(0) + const prevLen = prevLenRef.current + useEffect(() => { prevLenRef.current = text.length }) + const oldText = text.slice(0, prevLen) + const newText = text.slice(prevLen) + return ( + + {oldText} + {newText && {newText}} + + ) +} + +function FieldSkeleton({ rows = 2 }) { + return ( +
+ {Array.from({ length: rows }).map((_, i) => ( +
+ ))} +
+ ) +} + +function FieldOrSkeleton({ label, value, rows = 2 }) { + return ( +
+ {label && {label}} + + {value ? ( + + {typeof value === 'string' + ?

{value}

+ : value} +
+ ) : ( + + + + )} +
+
+ ) +} + +function TipList({ label, items }) { + if (!items || items.length === 0) return null + return ( +
+ {label} +
+ {items.map((tip, i) => ( +
+
+ {i + 1} +
+

{tip}

+
+ ))} +
+
+ ) +} + +function SchoolSection({ schoolInsight, schools, score }) { + if (!schoolInsight && (!schools || schools.length === 0)) return null + return ( +
+
+ Trường phù hợp + {score !== undefined && ( + + Điểm Toán: {score}/10 + + )} +
+ {schoolInsight && ( +

+ {schoolInsight} +

+ )} +
+ ) +} + +function AIErrorMessage({ error }) { + const navigate = useNavigate() + if (!error) return null + + // Structured 402 insufficient_credits + if (typeof error === 'object' && error.code === 'insufficient_credits') { + return ( +
+ + Hết Tia — còn {error.balance} Tia, cần {error.required}. + + +
+ ) + } + + // 403 tier_required + if (typeof error === 'object' && error.code === 'tier_required') { + return ( +
+ {error.message || 'Cần nâng cấp gói để sử dụng tính năng này.'} + +
+ ) + } + + // Generic string error + const msg = typeof error === 'string' ? error : 'Phân tích AI không khả dụng — đang dùng phân tích ngoại tuyến' + return ( +
{msg}
+ ) +} + +export default function AIInsights({ analysis, loading, error, score }) { + if (loading && !analysis?._streaming) return + + // Streaming in-progress — field-level skeleton → crossfade to content + if (analysis?._streaming && !analysis?._streaming_done && !error) { + return ( +
+ {/* insights — word-level fade + static stream cursor */} + + + | +

+ : null + } rows={3} /> + {/* question_analysis — word-level fade */} + + +

+ : null + } rows={2} /> + {/* weak_topics */} + {analysis.weak_topics.map(t => ( + + {TOPIC_LABELS[t] ?? t} + ))}
+ : null + } rows={1} /> + {/* recommendations — word-level fade on last item (actively streaming) */} + {analysis.recommendations.map((r, i, arr) => ( +

+ {'• '} + {i === arr.length - 1 + ? + : r} +

))}
+ : null + } rows={2} /> +
+ ) + } + + if (!analysis) { + return + } + + const isAI = analysis._source === 'ai' + + // ── AI-powered view ────────────────────────────────────────────────────── + if (isAI) { + return ( +
+ {analysis.insights && ( +

{analysis.insights}

+ )} + {analysis.question_analysis && ( +
+ Phân tích câu trả lời +

{analysis.question_analysis}

+
+ )} + {analysis.weak_topics && analysis.weak_topics.length > 0 && ( +
+ Chủ đề cần cải thiện +
+ {analysis.weak_topics.map(t => ( + + {TOPIC_LABELS[t] ?? t} + + ))} +
+
+ )} + +
+ ) + } + + // ── Local (offline) view ───────────────────────────────────────────────── + const { predictedScoreRange, percentile, weakTopics, recommendations, improvementStrategy } = analysis + + return ( +
+ {error && ( + + Ngoại tuyến + + )} + + {predictedScoreRange && ( +
+
+ Dự đoán điểm số kỳ thi thật + + {predictedScoreRange[0]} – {predictedScoreRange[1]} + + {percentile !== undefined && ( + + Top {100 - percentile}% trong lịch sử của bạn + + )} +
+
+ Tốt +
+
+ )} + + {weakTopics && weakTopics.length > 0 && ( +
+ Chủ đề cần cải thiện +
+ {weakTopics.map(t => ( + + {TOPIC_LABELS[t] ?? t} + + ))} +
+
+ )} + + + + {/* Offline mode: no AI school suggestions available */} +
+ ) +} diff --git a/exam-app/src/components/AchievementCeremony.jsx b/exam-app/src/components/AchievementCeremony.jsx new file mode 100644 index 0000000000000000000000000000000000000000..64222678b5b02258650c21a5d2aab06aaf53468d --- /dev/null +++ b/exam-app/src/components/AchievementCeremony.jsx @@ -0,0 +1,34 @@ +import { useRef } from 'react' +import { motion } from 'framer-motion' + +/** + * Tier 3 ceremony — spring-physics scale pop on milestone achievement. + * + * Fires once when `trigger` flips from false → true. + * If already true on mount (loaded from storage), no animation plays. + * + * Usage: + * + * + * + */ +export default function AchievementCeremony({ trigger, children, className }) { + const wasTriggeredOnMount = useRef(trigger) + + // No animation if already triggered at mount time (stale state from storage) + const initial = wasTriggeredOnMount.current ? false : { scale: 0.7, opacity: 0 } + const animate = trigger + ? { scale: 1, opacity: 1 } + : { scale: 0.7, opacity: 0 } + + return ( + + {children} + + ) +} diff --git a/exam-app/src/components/AmbientGlows.jsx b/exam-app/src/components/AmbientGlows.jsx new file mode 100644 index 0000000000000000000000000000000000000000..a47e6c366876e145420bca0f67adda7488300c34 --- /dev/null +++ b/exam-app/src/components/AmbientGlows.jsx @@ -0,0 +1,40 @@ +const ORBS = [ + { color: '#3b82f6', size: 320, left: '10%', top: '20%', duration: 18, delay: 0 }, + { color: '#8b5cf6', size: 240, left: '75%', top: '60%', duration: 23, delay: -7 }, + { color: '#06b6d4', size: 180, left: '50%', top: '80%', duration: 15, delay: -4 }, + { color: '#f59e0b', size: 140, left: '85%', top: '15%', duration: 20, delay: -10 }, +] + +export default function AmbientGlows({ className = '' }) { + return ( +