diff --git a/README.md b/README.md index 7b2165b..2aae678 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ spaCy CEFR Ladder AI Model - Pytest + Pytest License

@@ -136,6 +136,13 @@ - **学习设置即时生效(含队列行为探针)**:每日新词上限、新词排序、学习范围三组设置在工作台顶栏分段控件一键切换,无需刷新页面;尾部队列同步重算、手动追加的词豁免裁剪。v5.0.0 新增 node:vm 动态探针验证真实队列行为。 +### 🎯 12. 备考域重布局与等级可扩展 (ADR-0005 首刀, v5.2.0) + +- **独立「备考」域**:主导航新增 `PRÜFUNG` 顶层入口(桌面 + 移动 dock),A1 写作/听力/阅读/口语/词表五模块从工具视图收拢入域;「写作润色」回归纯 essay、「复习卡片」回归纯复习,考纲素材不再混入工具容器。 +- **exam catalog 目录化**:`/api/exams/catalog` 由 `exam_catalog.py` 代码注册目录单源提供等级→模块导航;等级页签与模块卡片数据驱动,加 A2/B1 = 插一行注册数据,无需改 HTML/JS(问卷库不入 SQLite,YAGNI)。 +- **成绩表泛化**:`exam_trials(level,module,…)` 泛化成续表 + 幂等迁移 A1 存量(行数对账 `>=` 防透传模式重迁),旧 `a1_hoeren_records`/`a1_lesen_records` 保留兼容期,备份/还原接线完整。 +- **行为级 DOM 探针**:`tools/ia_dom_mount_probe.mjs` 以 node:vm 真跑迁移后的挂载链,断言五面板落位 view-exam、window 挂载可达、渲染目标不回退主站容器;实现回退必红。 + --- ## 🚀 快速启动指南 (Quick Start) @@ -366,6 +373,8 @@ DeLector/ - [x] **v5.1.0**:**局域网随时静默同步 Stage B(WebRTC 自动化)**——① 信令端点补 `X-WB-Key` 鉴权并修 POST 预检放行;② **持久配对凭证 + 一键撤销**(撤销即换新 key,替代每会话短码);③ WebRTC 信令中继 `/api/wb/rtc/signal`(按配对密钥建邮箱、sender 过滤防重放);④ 前端 `wbsync.rtc` 建连与 DataChannel **静默同步**(信封与 HTTP PUT 同构);⑤ 断线自动重连 + HTTP 轮询兜底降级(连续失败停手保可达)。Stage A HTTP 轮询保留为兜底。全量 pytest **487 全绿**;9 wbsync 探针 + 40 定向测试无回归。 +- [x] **v5.2.0**:**备考域重布局 + 等级可扩展(ADR-0005 Phase 1)**——① **主导航加「备考 (Prüfung)」顶层域**:A1 写作/听力/阅读/口语/词表五模块从「写作润色」「复习卡片」工具容器迁入独立备考域,工具视图回归纯工具语义(写作=纯 essay、卡片=纯复习);② **exam catalog 目录化**:`/api/exams/catalog` 代码注册目录单源,等级页签与模块卡片数据驱动,加 A2/B1 = 插一行数据(问卷库不入库,YAGNI);③ **成绩表泛化**:`exam_trials(level,module,…)` 表 + 幂等迁移 A1 存量(旧 `a1_hoeren_records`/`a1_lesen_records` 保留兼容),备份/还原接线收编;④ **导航单源静态入口 + 备考域骨架**、`tools/ia_dom_mount_probe.mjs` 行为级 DOM 探针(node:vm 真跑,回退必红)。测试 **559 全绿**。 + - [x] **v5.1.1**:**审计修复收口 + 性能与稳定性 (M1–M5 + M4)**——① **审计修复(M1–M5)**:旧 6 位短码 LAN 面板停用标注并整体禁用(端点已强制配对密钥,死 UI 明示);AI 判分/成功提示类残余 `alert` 收敛为 notify(写路径保留 + 双面黑白名单护栏);wb pull 指数退避、RTC 瞬态不累计、阅读计时器防叠;② **性能(M4)**:查词/判题热路径常量模块级提升、复合词拆解与核心词查表缓存、句切分缩写保护正则提升;③ **稳定性**:PWA 版本更新改温和提示不硬刷窗口、TTS blob URL 统一撤销 + 播放请求令牌防错句覆盖、Reader 陈旧响应守卫、AI 请求输入上限与 TTS voice 白名单;④ **安全补漏**:批注删除纳入本机写闸、X-WB-Key 统一 `secrets.compare_digest` 消除时序侧信道、还原不导入 API 配置防 Key 外泄、Anki 导出 HTML 转义防存储型 XSS;⑤ 测试库隔离与断言护栏补齐。**本版同时回补 v5.0.2 → v5.1.1 的版本面同步**(sw.js 缓存键 / index.html 顶栏 / build.gradle / README / AGENTS)。 - [x] **`server.py`** **拆分重构**(v4.6.4):3053 行单文件拆为 `nlp.py`(NLP/CEFR/文本分析)、`database.py`(DB/CRUD/备份)、`security.py`(SSRF/URL 安全),`server.py` 保留路由骨架。依赖图无环,319 测试全绿。 diff --git a/database.py b/database.py index 10b70a3..aa63907 100644 --- a/database.py +++ b/database.py @@ -113,6 +113,19 @@ def init_progress_db(db_path: Optional[str] = None): wrong_questions_json TEXT NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); + CREATE TABLE IF NOT EXISTS exam_trials ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + level TEXT NOT NULL, + module TEXT NOT NULL, + set_id INTEGER NOT NULL, + score_raw INTEGER NOT NULL, + score_official REAL NOT NULL, + total_questions INTEGER NOT NULL, + duration_seconds INTEGER NOT NULL, + answers_json TEXT NOT NULL, + wrong_questions_json TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); CREATE INDEX IF NOT EXISTS idx_quiz_card ON quiz_log(card_id); CREATE INDEX IF NOT EXISTS idx_study_logged ON study_log(logged_at); """) @@ -120,6 +133,8 @@ def init_progress_db(db_path: Optional[str] = None): # init 阶段不能用 db_progress_conn(会递归回自身自动建表),此处确定关闭 _close_db_conn(conn) _INITIALIZED_PROGRESS_DBS.add(target_path) + # 泛化成绩表上线即迁移存量 A1 行;函数自身行数对账幂等,重复调用无害 + migrate_a1_records_to_exam_trials(db_path=target_path) def log_study_event(event_type: str, ref_id: Optional[int] = None, note: str = "", minutes: int = 0, db_path: Optional[str] = None): @@ -748,6 +763,10 @@ def prune_audio_cache(max_files: int = 300, cache_dir: Optional[str] = None): ("id", "set_id", "score_raw", "score_official", "total_questions", "duration_seconds", "answers_json", "wrong_questions_json", "created_at"), {"set_id": 1, "score_raw": 0, "score_official": 0.0, "total_questions": 15, "duration_seconds": 0, "answers_json": "{}", "wrong_questions_json": "[]"}, ), + "exam_trials": ( + ("id", "level", "module", "set_id", "score_raw", "score_official", "total_questions", "duration_seconds", "answers_json", "wrong_questions_json", "created_at"), + {"level": "A1", "module": "hoeren", "set_id": 1, "score_raw": 0, "score_official": 0.0, "total_questions": 15, "duration_seconds": 0, "answers_json": "{}", "wrong_questions_json": "[]"}, + ), } @@ -906,60 +925,136 @@ def add_prep_saved(lemma: str, praep: str, kasus: str, db_path: Optional[str] = ) -def record_a1_hoeren_trial(set_id: int, score_raw: int, score_official: float, - total_questions: int, duration_seconds: int, - answers_json: str, wrong_questions_json: str, - db_path: Optional[str] = None) -> int: - """持久化一次 A1 听力模考记录""" +def record_exam_trial(level: str, module: str, set_id: int, score_raw: int, + score_official: float, total_questions: int, + duration_seconds: int, answers_json: str, + wrong_questions_json: str, + db_path: Optional[str] = None) -> int: + """写入一次泛化模考成绩(exam_trials:level × module 维度)。 + + 与旧 A1 专用表不同,同一张表承载所有等级/模块(A1 听力、A1 阅读, + 未来 A2…),备份/restore 也在同一张表上通用。 + """ with db_progress_conn(db_path) as conn: cur = conn.execute(""" - INSERT INTO a1_hoeren_records ( - set_id, score_raw, score_official, total_questions, - duration_seconds, answers_json, wrong_questions_json - ) VALUES (?, ?, ?, ?, ?, ?, ?) - """, (set_id, score_raw, score_official, total_questions, + INSERT INTO exam_trials ( + level, module, set_id, score_raw, score_official, + total_questions, duration_seconds, answers_json, wrong_questions_json + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, (level, module, set_id, score_raw, score_official, total_questions, duration_seconds, answers_json, wrong_questions_json)) record_id = cur.lastrowid # log_study_event opens its own connection — must be OUTSIDE the with block # to avoid SQLITE_BUSY from nested locks on progress.db. - log_study_event("a1_hoeren", ref_id=record_id, note=f"Set {set_id}: {score_official}/25.0", minutes=max(1, duration_seconds // 60), db_path=db_path) + # A1 双模块沿用旧 event_type(test_a1_grade_populates_study_log 契约); + # 其余组合的 daily_summary 映射留待 Phase 2 端点切换时扩展。 + if level == "A1": + event_type = "a1_hoeren" if module == "hoeren" else "a1_lesen" + else: + event_type = f"{level.lower()}_{module}" + log_study_event(event_type, ref_id=record_id, + note=f"Set {set_id}: {score_official}/25.0", + minutes=max(1, duration_seconds // 60), db_path=db_path) return record_id -def get_a1_hoeren_history(limit: int = 50, db_path: Optional[str] = None) -> List[Dict[str, Any]]: - """查询 A1 听力模考历史记录""" +def get_exam_history(level: str, module: str, limit: int = 50, + db_path: Optional[str] = None) -> List[Dict[str, Any]]: + """查询泛化模考历史;返回结构与旧 get_a1_*_history 逐字段等价。 + + 显式投影 9 个旧列而非 SELECT *:表里多出的 level/module 是存储维度, + 不是 API 字段——透传契约下调用方(routes_a1_*)不应看到它们。 + """ with db_progress_conn(db_path) as conn: rows = conn.execute(""" - SELECT * FROM a1_hoeren_records ORDER BY id DESC LIMIT ? - """, (limit,)).fetchall() + SELECT id, set_id, score_raw, score_official, total_questions, + duration_seconds, answers_json, wrong_questions_json, created_at + FROM exam_trials + WHERE level = ? AND module = ? + ORDER BY id DESC LIMIT ? + """, (level, module, limit)).fetchall() return [dict(r) for r in rows] +def migrate_a1_records_to_exam_trials(db_path: Optional[str] = None) -> Dict[str, Dict[str, Any]]: + """把 a1_hoeren_records / a1_lesen_records 存量行迁入 exam_trials(level='A1')。 + + 幂等策略是**行数对账**:对每个 module,若 exam_trials 里该 (level='A1', + module) 的行数 **≥** 旧表行数则整个跳过——重复执行零副作用。 + + 为什么用 ≥ 而不是 ==:旧表在迁移后冻结(透传模式只写 exam_trials), + legacy_count 恒定,而 exam_trials 随每次新成绩提交单调增。若用 ==, + 「迁移后又做了一次新成绩」再重启时 general=legacy+1 ≠ legacy 会误判为 + 「未迁移」,把旧表整行再插一遍 → 重复。≥ 在「旧表冻结 + 新表单调增」下 + 天然满足:一次迁入后 general ≥ legacy 恒成立,永不再迁。 + + 原子性:db_progress_conn 单事务,迁移中途异常整体回滚,不会留下半迁状 + 态;重跑从 0 迁,无剩余行。故 ≥ 对「迁移中断」也安全。 + + 旧表本身保留不删,读历史兼容期后由 Phase 2 退役。 + + Returns: {"hoeren": {"migrated": n, "skipped": bool}, "lesen": {...}} + """ + report: Dict[str, Dict[str, Any]] = {} + sources = { + "hoeren": "a1_hoeren_records", + "lesen": "a1_lesen_records", + } + with db_progress_conn(db_path) as conn: + for module, table in sources.items(): + legacy_count = conn.execute(f"SELECT COUNT(*) FROM {table}").fetchone()[0] + general_count = conn.execute( + "SELECT COUNT(*) FROM exam_trials WHERE level = ? AND module = ?", + ("A1", module)).fetchone()[0] + if general_count >= legacy_count: + report[module] = {"migrated": 0, "skipped": True} + continue + conn.execute(f""" + INSERT INTO exam_trials ( + level, module, set_id, score_raw, score_official, + total_questions, duration_seconds, answers_json, + wrong_questions_json, created_at + ) + SELECT 'A1', ?, set_id, score_raw, score_official, total_questions, + duration_seconds, answers_json, wrong_questions_json, created_at + FROM {table} + """, (module,)) + report[module] = {"migrated": legacy_count, "skipped": False} + return report + + +def record_a1_hoeren_trial(set_id: int, score_raw: int, score_official: float, + total_questions: int, duration_seconds: int, + answers_json: str, wrong_questions_json: str, + db_path: Optional[str] = None) -> int: + """持久化一次 A1 听力模考记录 + + 透传泛化实现(exam_trials level='A1' module='hoeren'):签名与返回 + 结构不变,调用方零改动;旧行由 migrate_a1_records_to_exam_trials 迁入。 + """ + return record_exam_trial("A1", "hoeren", set_id, score_raw, score_official, + total_questions, duration_seconds, answers_json, + wrong_questions_json, db_path=db_path) + + +def get_a1_hoeren_history(limit: int = 50, db_path: Optional[str] = None) -> List[Dict[str, Any]]: + """查询 A1 听力模考历史记录(透传泛化实现,返回结构不变)""" + return get_exam_history("A1", "hoeren", limit=limit, db_path=db_path) + + def record_a1_lesen_trial(set_id: int, score_raw: int, score_official: float, total_questions: int, duration_seconds: int, answers_json: str, wrong_questions_json: str, db_path: Optional[str] = None) -> int: - """持久化一次 A1 阅读模考记录""" - with db_progress_conn(db_path) as conn: - cur = conn.execute(""" - INSERT INTO a1_lesen_records ( - set_id, score_raw, score_official, total_questions, - duration_seconds, answers_json, wrong_questions_json - ) VALUES (?, ?, ?, ?, ?, ?, ?) - """, (set_id, score_raw, score_official, total_questions, - duration_seconds, answers_json, wrong_questions_json)) - record_id = cur.lastrowid - log_study_event("a1_lesen", ref_id=record_id, note=f"Set {set_id}: {score_official}/25.0", minutes=max(1, duration_seconds // 60), db_path=db_path) - return record_id + """持久化一次 A1 阅读模考记录(透传泛化实现,签名与返回结构不变)""" + return record_exam_trial("A1", "lesen", set_id, score_raw, score_official, + total_questions, duration_seconds, answers_json, + wrong_questions_json, db_path=db_path) def get_a1_lesen_history(limit: int = 50, db_path: Optional[str] = None) -> List[Dict[str, Any]]: - """查询 A1 阅读模考历史记录""" - with db_progress_conn(db_path) as conn: - rows = conn.execute(""" - SELECT * FROM a1_lesen_records ORDER BY id DESC LIMIT ? - """, (limit,)).fetchall() - return [dict(r) for r in rows] + """查询 A1 阅读模考历史记录(透传泛化实现,返回结构不变)""" + return get_exam_history("A1", "lesen", limit=limit, db_path=db_path) __all__ = [ @@ -1011,4 +1106,7 @@ def get_a1_lesen_history(limit: int = 50, db_path: Optional[str] = None) -> List "get_a1_hoeren_history", "record_a1_lesen_trial", "get_a1_lesen_history", + "record_exam_trial", + "get_exam_history", + "migrate_a1_records_to_exam_trials", ] diff --git a/docs/plans/2026-09-04-ia-nav-exam-domain-phase1-ledger.md b/docs/plans/2026-09-04-ia-nav-exam-domain-phase1-ledger.md new file mode 100644 index 0000000..f452016 --- /dev/null +++ b/docs/plans/2026-09-04-ia-nav-exam-domain-phase1-ledger.md @@ -0,0 +1,116 @@ +# ADR-0005 Phase 1 Ledger + +> 计划:`docs/plans/2026-09-04-ia-nav-exam-domain-phase1.md`(修订版)。分支:`feat/ia-nav-exam-domain`。基线:master @ e2cc2a2 (v5.1.1)。 + +## Task 0: 分支 + 修复收编 + 基线 [Role: Guard] + +- [x] Step 1: 建分支 `feat/ia-nav-exam-domain`(commit `2ae2bc2`:test 修复收编 + 计划入库) +- [x] Step 2: 全量 pytest 基线 + 前端定向基线 +- [x] Step 3: 迁移敏感断言清单登记 +- [x] Step 4: 原子 commit ledger + +### 基线回归证据 + +| 套件 | 结果 | +|---|---| +| 前端定向(test_german_workbench + test_goethe_a1 + test_goethe_a1_writing + test_prep_matrix + test_frontend_module_graph) | 117 passed | +| wb_*.mjs 动态探针(9 个) | 全绿 | +| 全量 pytest | 518 passed (98.95s) | + +### 迁移敏感断言清单(Task 2 同步项) + +| 文件:行 | 断言 | 迁移后命运 | +|---|---|---| +| test_goethe_a1.py:128 | `id="seg-a1"` 存在 | **失效**——seg-a1 按钮删除,断言改为 exam 入口 | +| test_goethe_a1.py:129-133 | `a1-toolbar`/`a1-topic-pills`/`a1-tab-vocab|teil2|teil3` 存在 | id 不变,搬进 view-exam 后仍绿 | +| test_goethe_a1_writing.py:205-206 | `writer-mode-a1-formular/email` | **失效**——按钮改名 `exam-tab-formular/email`,测试同 commit 更新 | +| test_goethe_a1_writing.py:207-208 | `a1-formular-view`/`a1-email-view` | id 不变,搬进 view-exam 后仍绿 | +| test_german_workbench.py:71-73 | `nav-btn-german`/`mob-btn-german` 顺序 | 不动(静态加按钮方案),天然绿 | +| test_german_workbench.py:64 | `view-german` 块 split | view-exam 置于 view-german **之前**,不破 split | +| test_writer_mobile.py:415 | show() 调 closeWriterMobilePanel | 不动,天然绿 | +| test_frontend_module_graph.py:358-368 | main.js 必须 star-import + exposer A1Hoeren/A1Lesen | DOM 迁移不改挂载,天然绿 | + +### index.html 搬移边界(执行精确锚点) + +| 区块 | 行区间 | 去处 | +|---|---|---| +| writer mode 按钮条(essay 外两项) | 944-970(删 a1 两按钮,保留 essay) | view-exam `exam-writing` 头部 | +| `#a1-formular-view` | 973-1037 | view-exam `exam-writing` | +| `#a1-email-view` | 1040-1146 | view-exam `exam-writing` | +| cards `seg-a1` 按钮 | 305-316 | **删除**(备考域入口替代) | +| cards `a1-toolbar` 整块 | 379-433 | view-exam `exam-vocab` 头部 | +| cards `a1-hoeren-container`/`a1-lesen-container` | 436-437 | view-exam `exam-hoeren`/`exam-lesen` | +| view-exam 新建 | — | view-german(:2637) 之前 | + +(边界行号基于 v5.1.1 工作区,Task 2 执行时以 grep 重定位为准。) + +## Task 1: 备考域骨架 + 静态导航入口 + +- [x] Step 1: RED 测试(test_exam_domain.py 11 条,RED 证据 FFFFFFFFFFF) +- [x] Step 2: index.html 静态双端按钮 + view-exam 壳(纯插入 +110 行) +- [x] Step 3: GREEN + 定向回归 128 passed(基线 117 + 新增 11) +- [x] Step 4: 原子 commit `692e8ea`(评审 PASS:纯插入零删除/断言防恒真切片/配平验证) + +## Task 2: A1 五模块迁入备考域 + +- [x] Step 1: 探针 ia_dom_mount_probe.mjs(RED 证据:exit 1 共 48 条问题 + pytest 9 failed) +- [x] Step 2: index.html 原子搬移(ledger 边界表;id 唯一性 7 关键 id 各恰 1 次) +- [x] Step 3: js 引用修改 + GREEN(a1CardsHost/setExamWritingTab/setExamModule/懒加载守卫;探针 4 场景绿) +- [x] Step 4: 测试同步(seg-a1→exam-card-vocab、writer-mode-a1-*→exam-tab-*)+ 全量探针 10/10 绿 + 537 passed +- [x] Step 5: 原子 commit `db80212`(评审 PASS:悬空标识符 165 handler 交叉核对/变异两向验证/no-op 防悬空) + +**偏差注记**: +- 新增 main.js `setExamModule` mediator(页签⇄面板路由住根模块,因 a1_cards/a1_writer 互不 import)——计划未预见,评审认可。 +- a1_cards.js 自管 view toggle(`a1ViewMode` + `setA1CardViewMode`)而非复用主站 setCardViewMode(避免双 toggle querySelector 冲突)——按计划「择简」授权。 +- writer.js/cards.js 间 `export *` 转发链保留,cards.js 删自身 A1Cards import(转发达成后属死 import)。 +- 评审 nit(不阻塞):test_exam_domain.py:43 TOOL_VIEW_FORBIDDEN 死常量;探针 JSON 部分键为自报常量非测量值(真门禁在 problems[])。 + +## Task 3: exam catalog 目录化 + +- [x] Step 1: test_exam_catalog.py RED(6 用例 collection error 必红;含 A2 扩展点变异 + count 防御) +- [x] Step 2: exam_catalog.py + routes_exam.py + include GREEN(count 数据推导 18/5/6/54/702) +- [x] Step 3: 前端 initExamCatalog 数据驱动(失败静态回退 + 幂等守卫 + show('exam') 惰性;新页签「待接入」标注防死按钮) +- [x] Step 4: 旧端点回归 6 passed + 定向 37 passed + commit `9ec0355` + +**偏差注记**: +- panel 实测只有两个 section:exam-writing + exam-cards-family(听力/阅读/口语/词表共用宿主由 setA1Mode 切换)——catalog panel 字段按实测写,测试钉「panel 必须真实存在于 DOM」。 +- 初轮评审 REVISE 两处已修:_safe_count 静默吞异常→logger.warning + caplog 钉死;_LEVEL_ORDER 死代码删。 +- 评审 nit(已接线):非静态新等级页签 no-op + aria-disabled + title「待接入」(v5.1.0 死按钮纪律)。 + +## Task 4: exam_trials 泛化表 + +- [x] Step 1: test_exam_trials.py RED(9 failed:AttributeError/OperationalError,符合预期) +- [x] Step 2: 表 + 函数 + 迁移 + _PROGRESS_TABLES + RestoreReq GREEN +- [x] Step 3: routes_a1_hoeren/lesen 回归 + 迁移幂等验证 +- [x] Step 4: commit `af93df0` + +**评审期间发现并修复真缺陷(MUST 级)**: +- migrate 幂等谓词原 `==`,透传后旧表冻结+新表随成绩单调增,「迁移后又做一次成绩再重启」场景 general=legacy+1 误判未迁移→整表重插(真实时序复现 count 3→5)。改为 `>=`,新增 test_migrate_after_new_grade_does_not_duplicate 钉死。db_progress_conn 单事务保证半迁回滚。 + +**偏差注记**: +- 首个评审子代理因 429 截断;编排者亲自复现缺陷后补派聚焦评审确认修复与无遗漏(PASS)。 +- server.py 加 restore 后迁移调用(v5.1 备份无 exam_trials 键时历史仍可见)——计划未列,属必要补漏。 +- test_server.py clean_db env 双钉修复(收集顺序健壮性),与 Task 0 test_audit_hardening 修复同源模式。 + +## Task 5: 收口 + +- [x] Step 1: 全量回归差异表——基线 518 → 559 passed(+41);前端定向 128 → 176;10/10 tools/*.mjs 探针全绿 +- [x] Step 2: ledger 回填 + README 同步(commit `7a4c658`) +- [x] Step 3: push(SSH 443 通道) +- [x] Step 4: PR(标题 `feat(ia): 备考域重布局 + catalog/成绩第一刀`) +- [x] Step 5: docs commit + +### 全量回归差异表(对比 Task 0 基线) + +| 套件 | 基线 (Task 0) | 收口 (Task 5) | Δ | +|---|---|---|---| +| 全量 pytest | 518 passed | 559 passed | +41 | +| 前端定向(german_workbench+goethe_a1*+prep_matrix+frontend_module_graph+exam_domain+writer_mobile) | 117 | 176 | +59 | +| tools/*.mjs 探针 | 9 绿 | 10/10 绿 | +1(ia_dom_mount_probe) | + +新增测试:test_exam_domain.py 11+8、test_exam_catalog.py 6、test_exam_trials.py 11;全部针对本 Phase 新增/迁移行为。 + +### 收口说明 +- 分支 `feat/ia-nav-exam-domain` 共 10 个 commit(2ae2bc2 → 7a4c658)。 +- README 同步 v5.2.0(badge 559、特性区新增备考域/目录/泛化成绩表、路线图 v5.2.0 条目)。 +- 工作区残留 .gitignore/.gitignore.md/AGENTS.md/CLAUDE.md/WORKMEMORY/GEMINI.md 为**并行会话注入**(非本任务产物),未纳入本分支 commit。 diff --git a/docs/plans/2026-09-04-ia-nav-exam-domain-phase1.md b/docs/plans/2026-09-04-ia-nav-exam-domain-phase1.md new file mode 100644 index 0000000..d956b47 --- /dev/null +++ b/docs/plans/2026-09-04-ia-nav-exam-domain-phase1.md @@ -0,0 +1,220 @@ +# ADR-0005 首刀实施计划:备考域 IA 重布局(Phase 1) + +> **Goal**: 按 ADR-0005 把 DeLector 前端从「场景工具混入 A1 考纲素材」重排为「场景工具 + 独立备考域(等级页签顶层、catalog 驱动)」,并落地目录/成绩第一刀,跑通 A1 全部练习在备考域内可用。 +> **Tech Stack**: Python 3.11 FastAPI + SQLite(server.py/database.py);原生 ES Modules 前端(index.html 单文件 SPA + main.js 根 module);node:vm 探针(仿 tools/wb_sync_probe.mjs) +> **Spec Reference**: `d:/Obsidian/Coding/08-Projects/DeLector/01-ADR/0005-navigation-exam-domain-and-level-scalability.md` +> **Global Constraints**: +> - **分支/PR 流程 (用户指令, MUST)**:基线上 **master @ v5.1.1**(已推送远端)。Task 0 建分支 `feat/ia-nav-exam-domain`;**本计划全部 commit 只落在该分支**,严禁直接写 master;收口 Task 5 push 分支并在远端开 PR(`gh pr create` 或网页)合入 master。 +> - **范围边界**:本计划 = ADR §4 决策 1/2/3 的首刀(导航单源 + 备考域 + A1 四模块迁移 + catalog 目录第一刀 + 成绩泛化第一刀)。**不属于本计划**:听/读组件 level 参数化提取、写作判分提取、`/api/a1/*` 全量切 `/api/exams/{level}/{module}`、背词工作台 token 壳/词表契约——后述 §9 作为后继 sub-plan 触发点(建议 A2 立项或独立 short-plan 再排)。 +> - **跨边界契约纪律 (MUST)**:任何端点/前端 body 契约变更配行为探针(node:vm 桩 fetch),单侧字符串存在断言不算数;需「退回旧实现必红」变异验证。 +> - **字符串断言纪律 (MUST)**:搬 index.html 大 DOM 前先 grep 定位解析 index.html/main.js/a1_*.js 特征串的测试与 tools/*.mjs 探针,迁移同 commit 内同步特征串。 +> - **DB 纪律**:改 database.py 函数用 try/finally + `conn.close()` 确定性关闭;迁移脚本防重入;测试隔离沿用 clean_db + `gc.collect()`,跑前设 `DATABASE_PATH` 防写真实 `delector.db`。 +> - **提交约定**:`feat|fix|test|refactor|docs(ia): 中文描述`,每个 Task 原子提交。 +> - **TDD**:每 Task Red→Verify Red→Green→Verify Green→Refactor→Commit。 +> - 执行环境降级:本会话无写码子代理,`/vault-exec` 降级为「编排者主线程直写 + TDD」;Task 的 Subagent Prompt Scaffold 供有子代理环境或人工核对使用。 + +--- + +### Task 0: 建立分支与回归基线 [Role: Guard] + +**Files:** +- 无源码改动。产出基线记录 `docs/plans/2026-09-04-ia-nav-exam-domain-phase1-ledger.md`。 + +**Interfaces:** +- Consumes: master @ v5.1.1;现有测试套件 +- Produces: 分支 `feat/ia-nav-exam-domain`;基线测试报告(全量服务端 + 前端字符串/探针定向) + +**Subagent Prompt Scaffold:** +> Implement Task 0: 建分支 + 回归基线。 +> Goal: 在 v5.1.1 上开 `feat/ia-nav-exam-domain`,跑一次全量回归并记录通过/失败清单作为本计划基线。 +> TDD Steps: +> 1. `git checkout -b feat/ia-nav-exam-domain v5.1.1` 并确认分支。 +> 2. `export PYTHONIOENCODING=utf-8` 后跑服务端定向子集(a1/hoeren/lesen/cards/writer + 全量 TestClient 主套件),记录数量。 +> 3. 跑依赖 index.html/main.js/workbench.html 特征串的字符串断言与 tools/*.mjs 探针,记录绿/红。 +> 4. 把结果写入 ledger(含“迁移前绿、任务 X 迁移后必须仍绿”的断言清单)。 +> Return: 分支名 + 基线通过计数 + 需在 Task 2/3 同步特征串的断言文件清单。 + +**Step Breakdown:** +- [ ] Step 1: `git checkout -b feat/ia-nav-exam-domain v5.1.1` +- [ ] Step 2: 服务端 + 前端字符串/探针回归,产出基线 +- [ ] Step 3: 建 ledger 并登记「迁移敏感断言」清单 +- [ ] Step 4: 原子 commit `docs(ia): Phase1 基线回归 + ledger` + +--- + +### Task 1: 导航数据化 + 备考域骨架 [Role: Frontend TDD Builder] + +**Files:** +- Modify: `static/js/main.js:150-215`(show/view 切换与点亮)、`static/index.html` 桌面 `nav#nav`(41-89)、移动 dock(2647-2696) +- Create: `static/js/nav.js`(NAV config 与双端渲染,仅被 main.js import) +- Modify: `static/index.html` view 区新增 `
`(置于 view-writer 之后,含等级页签条 `#exam-level-tabs` 与模块卡片区 `#exam-module-grid`) +- Test: 前端字符串断言所在测试文件(Task 0 定位)+ 新增特征断言 + +**Interfaces:** +- Consumes: `main.js show(view)`(view id → `.active`、nav-btn/mob-btn 点亮) +- Produces: `NAV_ITEMS=[{id,label,de,onclick}…]`(含 exam);`renderNav()` 单源渲染桌面 `.nav-links` 与移动 `.mobile-dock`;`renderExamShell(levels)`(本 Task levels 常量 `['A1']`,模块卡片占位入口四张:写作/听力/阅读/口语) +- 语义约束:保留文案标识(SCHREIBTISCH/KARTEI/VOKABELN 及中文文案)以免字符串断言大面积失配;`show('exam')` 须点亮对应 nav/dock 按钮并允许 empty view 显示。 + +**Subagent Prompt Scaffold:** +> Implement Task 1: 导航数据化 + 备考域骨架。 +> Goal: nav config 单源渲染桌面导航与移动 dock,新增「备考」入口与 view-exam 壳(A1 等级页签 + 四模块占位卡片),view 切换可进入。 +> Target Files: Create `static/js/nav.js`;Modify `static/js/main.js` show()、`static/index.html` 导航两处 + 新增 view-exam 容器。 +> TDD Steps: +> 1. RED:更新前端字符串断言(nav 出现「备考/Prüfung」入口;view-exam 容器存在;nav 双端渲染后原有五入口文案仍在)。 +> 2. Verify Red:跑对应断言确认失败信息为“备考入口/容器缺失”。 +> 3. GREEN:最小实现 nav.js + main.js 引入 + index.html 容器;nav 双端渲染。 +> 4. REFACTOR:guard-clause 扁平化;nav config 与 view 初始化联动(show('exam') 无副作用)。 +> 5. 跑 Task 0 登记的全量字符串/探针,确认除“新增入口”预期外无意外失配。 +> 6. 原子 commit `feat(ia): 导航单源化并新增备考域骨架`。 +> Return: 测试执行证据 + 遗留失配清单。 + +**Step Breakdown:** +- [ ] Step 1: nav.js NAV config + 双端渲染,index.html 增 view-exam(等级页签壳 + 4 占位卡片)(RED 测试先行) +- [ ] Step 2: 断言失配验证(仅“缺备考入口/容器”类) +- [ ] Step 3: show('exam') 联动与点亮;最小 GREEN +- [ ] Step 4: 跑全量字符串/探针回归,同步意外失配 +- [ ] Step 5: REFACTOR 扁平化 + 原子 commit + +--- + +### Task 2: A1 四模块 UI 迁入备考域 + 工具视图清理 [Role: Frontend TDD Builder — 高风险 DOM 迁移] + +**Files:** +- Modify: `static/index.html`: + - 删除 `view-writer` 内 A1 区:`.writer-mode-switcher-bar` A1 两按钮(944-970 内 a1 项)、`#a1-formular-view`(973-1037)、`#a1-email-view`(1040-1147) + - 删除 `view-cards` 内 A1 tabs(含 a1-tab-teil2/teil3 等,Task 0 定位的精确行区间;口语问答数据面板) + - 在 `view-exam` 内按模块挂 4 个面板容器:`exam-writing`(formular+email)、`exam-hoeren`、`exam-lesen`、`exam-sprechen`(teil2/teil3)——**DOM 从原 view 同 commit 内搬移,杜绝同 id 双现** +- Modify: `static/js/writer.js:7-29`(若 main.js 不再经 writer.js 导入 a1_writer,调整 re-export 但保留 window 挂载来源)、`static/js/main.js:146-147,726-892`(window 挂载聚合与 import,必要时改引用)、`static/js/cards.js:26-27` +- Create: `tools/ia_dom_mount_probe.mjs`(node:vm 切 index.html+main.js,桩 fetch,断言「a1 面板 DOM 存在于 view-exam、旧 view 内已无 a1 容器、A1Hoeren/A1Lesen window 挂载可达」) +- Test: 前端字符串断言测试 + 探针 + +**Interfaces:** +- Consumes: `window.A1Writer/A1Cards/A1Hoeren/A1Lesen`(a1_*.js 自挂 + main.js 聚合);既有 `/api/a1/*` 端点(本 Task 不改后端契约,前端迁 UI 后仍调原端点取题) +- Produces: view-exam 内 4 面板;`view-writer`=纯 essay 工具(mode-switcher 移除);`view-cards`=纯复习;探针新增并全绿 +- 风险:a1 面板事件由渲染函数注入 `onclick="A1Hoeren.xxx()"`;迁移后 window 挂载必须保持。id 唯一性约束:迁移必须“同一次替换原子完成”。 + +**Subagent Prompt Scaffold:** +> Implement Task 2: A1 四模块 UI 迁入备考域 + 工具视图清理。 +> Goal: A1 写作/听力/阅读/口语面板全部迁到 view-exam 对应容器,旧 view-writer/view-cards 移除 A1 区块;同 id 不得双现;window 挂载与事件注入不破;字符串断言同步。 +> Target Files: Modify `static/index.html`(4 段搬移 + 旧区块删除);Modify `static/js/{writer,cards,main}.js`;Create `tools/ia_dom_mount_probe.mjs`。 +> TDD Steps: +> 1. RED:写 `ia_dom_mount_probe.mjs`——断言“view-exam 内存在 a1 面板、旧 writer/cards view 无 a1 容器、A1 window 挂载键存在”;先跑必红。 +> 2. Verify Red:探针失败信息 = “新位置缺失 / 旧位置仍存在”。 +> 3. GREEN:单次原子替换搬 DOM(先同 commit 里“新增 view-exam 副本 + 删除旧块”一次 diff 完成);修 main.js/writer.js/cards.js 引用。 +> 4. 全量字符串断言回归 + 探针绿;旧 view 清理(mode-switcher 移除 A1 项;cards A1 tab 移除)。 +> 5. REFACTOR + 原子 commit `feat(ia): A1 四模块迁入备考域并清理工具视图`。 +> Return: 探针输出 + 回归结果 + 任何仍调旧 UI 的引用清单。 + +**Step Breakdown:** +- [ ] Step 1: 探针 ia_dom_mount_probe.mjs(RED 必红) +- [ ] Step 2: index.html 原子搬移 4 面板(一次 diff:新容器插入 + 旧块删除),确保同 id 无双现 +- [ ] Step 3: 修 js 引用/挂载,GREEN + 探针绿 +- [ ] Step 4: 字符串断言回归;view-writer/view-cards 清理确认(纯工具语义) +- [ ] Step 5: 原子 commit(含探针文件) + +--- + +### Task 3: exam catalog 目录化(数据模块注册 + catalog 端点 + 等级页签数据驱动)[Role: Backend TDD Builder] + +**Files:** +- Create: `routes_exam.py`(`APIRouter(prefix="/api/exams")`,本 Task 仅 catalog 端点);`exam_catalog.py`(等级目录注册表:`EXAM_CATALOG = {"A1": {"writing":{title,panel,api_prefix:"/api/a1"}, "hoeren":…, "lesen":…, "sprechen":…}, …}`;引用 `a1_writing_dict/a1_dict/a1_hoeren_dict/a1_lesen_dict` 的模块级常量并声明 `{level}_{module}` 归位别名) +- Modify: `server.py`(include exam router;若文件已按 M-split 组织则并入对应模块)——依现状 server.py:234 区 include +- Modify: `static/js/nav.js` 或新增 `exam.js`:Task1 的静态等级/卡片改由 `GET /api/exams/catalog` 渲染(A1 模块卡片的跳转锚 → 对应面板容器) +- Test: `test_exam_catalog.py`(catalog 返回等级 A1、模块含 writing/hoeren/lesen/sprechen;旧 `/api/a1/*` 取题端点照常可用——catalog 只提供导航/发现,**不迁移取题端点**) + +**Interfaces:** +- Consumes: `a1_*_dict` 模块常量(词表/题库/题集元数据);前端 view-exam 渲染 +- Produces: `GET /api/exams/catalog → {levels:[{id,title,modules:[{id,title,type,panel}]}]}`;前端等级页签与模块卡片完全数据驱动 +- 决策(相对 ADR 的实现细化,需用户点头):**题库数据暂不入 SQLite**——catalog 是「代码注册目录」(`exam_catalog.py` 单源,未来 A2 = 追加 `A2` key + 数据模块),YAGNI 于题量级;ADR 的 `exam_sets/exam_items` 表留作“题库规模/服务端出题”触发后的升级路径;**成绩泛化表**才是本计划必落库项(Task 4)。 + +**Subagent Prompt Scaffold:** +> Implement Task 3: exam catalog 目录化。 +> Goal: 新增 /api/exams/catalog 由代码注册目录 EXAM_CATALOG 提供等级→模块导航;前端备考域等级页签与卡片改由 catalog 渲染;旧 /api/a1 取题端点不动。 +> Target Files: Create `routes_exam.py`, `exam_catalog.py`;Modify `server.py` include、前端等级页签渲染源。 +> TDD Steps: +> 1. RED:`test_exam_catalog.py`(catalog 200,含 A1 与四模块;加「插一行 A2 注册 → catalog 多一级」的变异式断言证明扩展点)先跑必红。 +> 2. GREEN:exam_catalog.py + routes_exam.py + server include。 +> 3. 前端把 Task1 占位等级/卡片切到 fetch catalog 渲染(无 catalog 或失败时保留静态回退)。 +> 4. 回归:a1 取题/判分端点子集全绿(确认未动旧契约)。 +> 5. REFACTOR + 原子 commit `feat(ia): exam catalog 目录化驱动备考域导航`。 +> Return: 测试证据 + “加级成本”演示(临时注册 A2 的 catalog 输出或注释说明)。 + +**Step Breakdown:** +- [ ] Step 1: test_exam_catalog.py RED(含 A2 扩展点变异断言) +- [ ] Step 2: exam_catalog.py + routes_exam.py + include GREEN +- [ ] Step 3: 前端等级页签/卡片数据驱动(静态回退兜底) +- [ ] Step 4: 旧端点回归全绿 + 原子 commit + +--- + +### Task 4: 成绩表泛化第一刀 + 旧行迁移 [Role: DB TDD Builder] + +**Files:** +- Modify: `database.py`:新增 `exam_trials(level,module,set_id,score_raw,score_official,details_json,created_at)`(init 建表处 ~database.py:94-105 邻近);新增 `record_exam_trial()`/`get_exam_history(level,module)`;`record_a1_hoeren_trial`/`record_a1_lesen_trial`/`get_a1_hoeren_history`/`get_a1_lesen_history`(database.py:909-956)改为内部透传泛化函数并保留签名(**不删旧名**,避免前端/测试大面积契约变更) +- Modify: 一次性迁移函数 `migrate_a1_records_to_exam_trials()`:把 `a1_hoeren_records/a1_lesen_records` 存量行写入 exam_trials(level='A1'),防重入(存在则跳过);旧表保留(读历史兼容期),不物理删除——ADR 语境下旧表退役放 Phase 2 端点切完再删 +- Test: `test_exam_trials.py`(迁移后计数一致、字段映射正确、防重入幂等;新 trial 写入即读回;旧 record_a1_* 函数行为不变——透传契约) + +**Interfaces:** +- Consumes: 既有 `record_a1_*_trial/get_a1_*_history` 调用方(routes_a1_hoeren/lesen 内部) +- Produces: `exam_trials` 表 + 泛化读写 + 幂等迁移;旧函数透传不破契约 +- 风险:迁移 SQL 在 test clean_db 与真实库都要跑;沿用 `gc.collect()`/确定性 close 纪律;迁移函数幂等(重复跑无副作用)。 + +**Subagent Prompt Scaffold:** +> Implement Task 4: 成绩表泛化 + 旧行迁移。 +> Goal: exam_trials(level,…) 泛化表上线;存量 a1_hoeren/a1_lesen 成绩幂等迁入;旧 record/get 函数透传泛化实现,契约不破。 +> Target Files: Modify `database.py`;Create `test_exam_trials.py`。 +> TDD Steps: +> 1. RED:test_exam_trials(写读回 + 旧函数行为保持 + 迁移幂等)。 +> 2. GREEN:建表/函数/迁移;旧函数改透传。 +> 3. 回归:routes_a1_hoeren/lesen 提交判分子集 + 历史读回全绿;`test_server` 受影响 `-k` 定向。 +> 4. REFACTOR + 原子 commit `refactor(db): 成绩表泛化 exam_trials 并幂等迁移 A1 存量`。 +> Return: 迁移幂等验证 + 定向回归证据。 + +**Step Breakdown:** +- [ ] Step 1: test_exam_trials RED(迁移幂等/旧函数透传/新写读回) +- [ ] Step 2: 泛化表 + 函数 + 幂等迁移 GREEN +- [ ] Step 3: 旧 record/get 透传;hoeren/lesen 提交与历史回归 +- [ ] Step 4: 原子 commit + +--- + +### Task 5: 收口:全量回归 + ledger + 分支上传 + PR [Role: Guard] + +**Files:** +- Modify: `docs/plans/2026-09-04-ia-nav-exam-domain-phase1-ledger.md`(勾选全部 Step、记录偏差与证据) +- Modify(如适用): 废弃/停用标注遗留 UI 或死按钮(若 Task 2 后出现不可达入口,走“禁用标注”而非静默保留) + +**Interfaces:** +- Consumes: master 基线(Task 0 记录) +- Produces: 与基线对比的回归报告;已 push 分支 + PR + +**Subagent Prompt Scaffold:** +> Implement Task 5: 收口回归 + 分支上传 + PR。 +> Goal: 全量回归对比 Task 0 基线;ledger 回填勾选;push `feat/ia-nav-exam-domain` 到 origin;在远端开 PR 到 master(描述引用 ADR-0005,标题 `feat(ia): 备考域重布局 + catalog/成绩第一刀`)。 +> Steps: +> 1. 服务端全量 + 前端字符串/探针定向回归,输出与基线差异表。 +> 2. ledger 勾选 Task 0-4 Step + 偏差注记。 +> 3. `git push -u origin feat/ia-nav-exam-domain`。 +> 4. 开 PR:`gh pr create --base master` 可用则 CLI,否则给出网页链接与 PR 描述草稿(含测试证据、ADR-0005 引用、Phase 2 范围声明)。 +> 5. commit `docs(ia): Phase1 ledger 收口 + PR 就绪`(在分支上)。 +> Return: PR URL 或待开 PR 的完整描述。 + +**Step Breakdown:** +- [ ] Step 1: 回归差异表(对比 Task 0) +- [ ] Step 2: ledger 勾选/偏差注记 +- [ ] Step 3: push 分支 +- [ ] Step 4: PR 就绪(CLI 或草稿) +- [ ] Step 5: 文档 commit + +--- + +## 9. 后继 sub-plan 触发点(不在本计划执行) + +| 触发 | 内容 | ADR 引用 | +|---|---|---| +| A2 立项时 | 听/读组件 level 参数化提取(先听/读,写作判分延后);`/api/a1/*` 全量切 `/api/exams/{level}/{module}`;旧 a1 端点与旧成绩表退役删除 | ADR-0005 §4.3 后续刀 | +| 独立 short-plan(可在本计划 PR 合并后随时启动) | 背词工作台视觉统一:共享设计 token(主站 `style.css` 暖纸 `--paper/--ink/--rule` 体系 vs workbench 冷灰 `--bg/--accent`——**色系冷暖相反且 token 名异构,需先出视觉小样定语义**,保留 good/hard/again 状态色)+ 词表来源契约 `vocab_cards.cefr_level` 过滤 | ADR-0005 §4.5/Q4C | + +--- + +*计划 schema 依据 vault-plan;ADR 依据 vault-grill 共识(ADR-0005, status=proposed, 2026-09-04)。* diff --git a/exam_catalog.py b/exam_catalog.py new file mode 100644 index 0000000..58cfac5 --- /dev/null +++ b/exam_catalog.py @@ -0,0 +1,104 @@ +"""Exam catalog 目录注册表(ADR-0005 Task 3)—— 等级→模块 的导航单源。 + +设计决策(用户已拍板): +- **题库数据不入 SQLite**:本文件是「代码注册目录」。加一个等级 = + 在 EXAM_CATALOG 插一个 key(配合数据模块常量),未来 A2 立项即插行。 +- **只引用数据模块的常量,不复制数据**:count 通过 count_fn 从数据模块 + 实时推导。**防失败面仅限常量访问**:模块级 `import a1_dict` 等仍是 + 硬依赖(模块被删/改名 import 期即崩,server 起不来);单个 count_fn + 抛错(常量重命名、数据坏形)→ count 记 0 + logger.warning 留痕, + catalog 端点与 server 启动均不受牵连。 +- **旧 /api/a1/* 端点不迁移**:catalog 只做导航发现(api_prefix 指向 + 既有取题端点前缀),panel 指向 index.html 里的面板容器 id。 +""" +import logging +from typing import Any, Callable, Dict, List, Optional + +import a1_dict +import a1_hoeren_dict +import a1_lesen_dict +import a1_writing_dict + +logger = logging.getLogger("delector") + +# count_fn: 零参调用返回该模块题量。try/except 覆盖「数据模块常量改名」 +# 场景(模块 import 仍为硬依赖,见 docstring):常量缺失时 count 记 0 +# 且 logger.warning 记录异常——零静默吞异常(测试 +# test_catalog_survives_broken_count_fn 钉住 count 行为)。 +EXAM_CATALOG: Dict[str, Dict[str, Any]] = { + "A1": { + "title": "A1", + "modules": { + "writing": { + "title": "📝 写作专项 (Schreiben)", + "panel": "exam-writing", + "api_prefix": "/api/a1", + "count_fn": lambda: ( + len(a1_writing_dict.A1_SCHREIBEN_TEIL1_EXERCISES) + + len(a1_writing_dict.A1_SCHREIBEN_TEIL2_PROMPTS) + ), + }, + "hoeren": { + "title": "🎧 听力模考 (Hörverstehen)", + "panel": "exam-cards-family", + "api_prefix": "/api/a1", + "count_fn": lambda: len(a1_hoeren_dict.A1_HOEREN_SETS), + }, + "lesen": { + "title": "📖 阅读工坊 (Leseverstehen)", + "panel": "exam-cards-family", + "api_prefix": "/api/a1", + "count_fn": lambda: len(a1_lesen_dict.A1_LESEN_SETS), + }, + "sprechen": { + "title": "💬 口语问答 (Sprechen)", + "panel": "exam-cards-family", + "api_prefix": "/api/a1", + "count_fn": lambda: ( + len(a1_dict.A1_SPRECHEN_TEIL2) + len(a1_dict.A1_SPRECHEN_TEIL3) + ), + }, + "vocab": { + "title": "📖 官方考纲词表 (Wortliste)", + "panel": "exam-cards-family", + "api_prefix": "/api/a1", + "count_fn": lambda: len(a1_dict.GOETHE_A1_VOCAB), + }, + }, + }, +} + + +def _safe_count(count_fn: Optional[Callable[[], int]]) -> int: + """count 推导失败(数据模块常量重命名/缺常量)→ 记 0 并 warning 留痕。""" + if count_fn is None: + return 0 + try: + return int(count_fn()) + except Exception: + logger.warning("[exam-catalog] 模块题量推导失败,count 记 0", exc_info=True) + return 0 + + +def get_catalog() -> Dict[str, Any]: + """序列化 catalog:{"levels": [{"id", "title", "modules": [ + {"id", "title", "panel", "api_prefix", "count"}]}]}。 + + count_fn 是 Python 可调用、不可 JSON 化,故此层负责把注册表扁平化为 + 纯数据(count 取 _safe_count 结果)。等级顺序 = 注册序(dict 插入序)。 + 返回 fresh dict,调用方随意改。 + """ + levels: List[Dict[str, Any]] = [] + for lid, reg in EXAM_CATALOG.items(): + modules = [ + { + "id": mid, + "title": mod.get("title", mid), + "panel": mod.get("panel", ""), + "api_prefix": mod.get("api_prefix", ""), + "count": _safe_count(mod.get("count_fn")), + } + for mid, mod in reg.get("modules", {}).items() + ] + levels.append({"id": lid, "title": reg.get("title", lid), "modules": modules}) + return {"levels": levels} diff --git a/routes_exam.py b/routes_exam.py new file mode 100644 index 0000000..c747a81 --- /dev/null +++ b/routes_exam.py @@ -0,0 +1,15 @@ +"""Exam catalog 路由(ADR-0005 Task 3)—— 等级→模块 导航发现。 + +纯只读端点:无写操作、无 _require_localhost 闸。旧 /api/a1/* 取题端点 +**不迁移不改动**,本 router 只挂目录发现。 +""" +from fastapi import APIRouter + +from exam_catalog import get_catalog + +router = APIRouter(prefix="/api/exams", tags=["exam"]) + + +@router.get("/catalog") +def get_exam_catalog(): + return get_catalog() diff --git a/server.py b/server.py index 7871683..0797d06 100644 --- a/server.py +++ b/server.py @@ -88,6 +88,7 @@ def load_env(): _take_pending, _db_snapshot_guard, _replace_tables, + migrate_a1_records_to_exam_trials, get_prep_saved, add_prep_saved, ) @@ -199,6 +200,7 @@ def _attachment_headers(filename: str) -> Dict[str, str]: "_take_pending", "_db_snapshot_guard", "_replace_tables", + "migrate_a1_records_to_exam_trials", "get_prep_saved", "add_prep_saved", "_resolve_ssrf_targets", @@ -228,6 +230,7 @@ def _attachment_headers(filename: str) -> Dict[str, str]: from routes_corpus import router as corpus_router from routes_a1_hoeren import hoeren_router from routes_a1_lesen import lesen_router +from routes_exam import router as exam_router # --- 4. FastAPI Application --- app = FastAPI(title="DeLector") @@ -237,6 +240,7 @@ def _attachment_headers(filename: str) -> Dict[str, str]: app.include_router(corpus_router) app.include_router(hoeren_router) app.include_router(lesen_router) +app.include_router(exam_router) init_db() seed_preset_articles() @@ -1587,6 +1591,8 @@ class RestoreReq(BaseModel): daily_summary: List[Dict[str, Any]] = [] a1_hoeren_records: List[Dict[str, Any]] = [] a1_lesen_records: List[Dict[str, Any]] = [] + # v5.2 泛化成绩表;v1/v5.1 备份没有此字段,缺省为空(向后兼容同上) + exam_trials: List[Dict[str, Any]] = [] local_storage: Dict[str, Any] = {} @@ -1631,6 +1637,12 @@ def restore_database_backup(req: RestoreReq, request: Request): finally: pconn.close() + # v5.1 及更早的备份里 A1 成绩只存在旧表(exam_trials 尚未存在); + # 透传读路径已切到泛化表,还原后必须把旧表行迁过来,否则历史"看着 + # 还原成功却读不出来"。迁移按行数对账幂等:v2 备份(两表行数已齐) + # 到这里就是零写入 no-op。 + migrate_a1_records_to_exam_trials() + return {"status": "ok", "message": "全量备份恢复成功"} diff --git a/static/index.html b/static/index.html index 8bc9637..412607a 100644 --- a/static/index.html +++ b/static/index.html @@ -60,6 +60,10 @@ SCHREIBTISCH 写作润色 + -
@@ -376,65 +371,7 @@

卡片库 · KARTEIKARTEN

/>
- -
- -
@@ -953,197 +890,6 @@

德语写作诊断与语法润色

> ✍️ 自由写作与研磨 (Freies Schreiben) - - - - - - - - - @@ -2633,6 +2379,304 @@

考验完成!

+ +
+
+
+ +

备考 · PRÜFUNGSDOMÄNE

+
+
+ +
+
+ + + + + +
+ + +
+
+ + +
+ + + + + + +
+ + +
+
+