Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
<img src="https://img.shields.io/badge/spaCy-German%20NLP-09A3D5?style=flat-square&logo=spacy&logoColor=white" alt="spaCy" />
<img src="https://img.shields.io/badge/CEFR-A1~C1%20Goethe-E63946?style=flat-square" alt="CEFR Ladder" />
<img src="https://img.shields.io/badge/AI%20Model-deepseek--v4--flash-brightgreen?style=flat-square" alt="AI Model" />
<img src="https://img.shields.io/badge/Tests-453%2F453%20Passed-2EA44F?style=flat-square" alt="Pytest" />
<img src="https://img.shields.io/badge/Tests-559%2F559%20Passed-2EA44F?style=flat-square" alt="Pytest" />
<img src="https://img.shields.io/badge/License-MIT-gray?style=flat-square" alt="License" />
</p>

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 测试全绿。
Expand Down
164 changes: 131 additions & 33 deletions database.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,13 +113,28 @@ 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);
""")
finally:
# 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):
Expand Down Expand Up @@ -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": "[]"},
),
}


Expand Down Expand Up @@ -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}"
Comment on lines +951 to +954
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__ = [
Expand Down Expand Up @@ -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",
]
Loading