From cfdacf1fb51419e3e81ddb4a8971b00a5924be92 Mon Sep 17 00:00:00 2001 From: TD_Xu Date: Mon, 20 Jul 2026 17:47:46 +0800 Subject: [PATCH 01/14] =?UTF-8?q?feat(display):=20add=20kc=5Flabel=20bilin?= =?UTF-8?q?gual=20helper=20(kc=5Fid=EF=BC=88=E4=B8=AD=E6=96=87=E5=90=8D?= =?UTF-8?q?=EF=BC=89)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- scripts/studylib/display.py | 10 ++++++++++ tests/test_display.py | 22 ++++++++++++++++++++++ 2 files changed, 32 insertions(+) create mode 100644 scripts/studylib/display.py create mode 100644 tests/test_display.py diff --git a/scripts/studylib/display.py b/scripts/studylib/display.py new file mode 100644 index 0000000..491c861 --- /dev/null +++ b/scripts/studylib/display.py @@ -0,0 +1,10 @@ +from __future__ import annotations + + +def kc_label(kc_id: str, kcs: dict[str, dict] | None = None) -> str: + """User-facing KC label: 'kc_id(中文名)'. Falls back to bare kc_id.""" + kc = (kcs or {}).get(kc_id) or {} + name = kc.get("name") + if not name or name == kc_id: + return kc_id + return f"{kc_id}({name})" diff --git a/tests/test_display.py b/tests/test_display.py new file mode 100644 index 0000000..dbac115 --- /dev/null +++ b/tests/test_display.py @@ -0,0 +1,22 @@ +from studylib.display import kc_label + + +def test_label_with_chinese_name(): + kcs = {"mao_living_soul": {"name": "毛泽东思想活的灵魂"}} + assert kc_label("mao_living_soul", kcs) == "mao_living_soul(毛泽东思想活的灵魂)" + + +def test_label_missing_kc_falls_back_to_id(): + assert kc_label("orphan", {"x": {"name": "X"}}) == "orphan" + + +def test_label_no_kcs_arg(): + assert kc_label("orphan") == "orphan" + + +def test_label_name_equals_id(): + assert kc_label("x", {"x": {"name": "x"}}) == "x" + + +def test_label_empty_name_falls_back(): + assert kc_label("x", {"x": {"name": ""}}) == "x" From 1d68ed0e5991d75ae39c9f3bedd805faecb7094d Mon Sep 17 00:00:00 2001 From: TD_Xu Date: Mon, 20 Jul 2026 17:53:37 +0800 Subject: [PATCH 02/14] =?UTF-8?q?feat(display):=20show=20kc=5Fid=EF=BC=88?= =?UTF-8?q?=E4=B8=AD=E6=96=87=E5=90=8D=EF=BC=89in=20next-step,=20dashboard?= =?UTF-8?q?,=20echo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude --- scripts/studylib/cli_common.py | 2 +- scripts/studylib/dashboard.py | 7 +++++-- scripts/studylib/nextstep.py | 6 +++++- templates/dashboard.md.j2 | 2 +- tests/test_nextstep.py | 13 +++++++++++++ 5 files changed, 25 insertions(+), 5 deletions(-) diff --git a/scripts/studylib/cli_common.py b/scripts/studylib/cli_common.py index 432b83f..a929a03 100644 --- a/scripts/studylib/cli_common.py +++ b/scripts/studylib/cli_common.py @@ -29,7 +29,7 @@ def echo_next(state: dict) -> None: if nbs["action"] == "rest": typer.echo(f"下一步建议:rest —— {nbs['reasons'][0]}") return - target = nbs.get("kc_name") or "到期复习" + target = nbs.get("kc_label") or nbs.get("kc_name") or "到期复习" typer.echo(f"下一步建议:{nbs['action']}「{target}」(约 {nbs['estimated_minutes']} 分钟)") typer.echo("原因:") for r in nbs["reasons"]: diff --git a/scripts/studylib/dashboard.py b/scripts/studylib/dashboard.py index 06f0760..d0f0810 100644 --- a/scripts/studylib/dashboard.py +++ b/scripts/studylib/dashboard.py @@ -4,17 +4,20 @@ from jinja2 import Template +from .display import kc_label + TEMPLATE_PATH = Path(__file__).resolve().parents[2] / "templates" / "dashboard.md.j2" def build_risks(kc_states: dict, miscs: dict, due: list) -> list[str]: risks: list[str] = [] for kc in kc_states.values(): + label = kc_label(kc.get("kc_id", ""), kc_states) gap = kc["calibration"].get("gap") if kc["teaching_state"] == "weak" and gap is not None and gap >= 0.3: - risks.append(f"「{kc['name']}」:高置信度盲区") + risks.append(f"{label}:高置信度盲区") elif kc["teaching_state"] == "blocked": - risks.append(f"「{kc['name']}」:前置未稳定") + risks.append(f"{label}:前置未稳定") for m in miscs.values(): if m["repair_status"] != "resolved" and m.get("recurrence_count", 1) >= 2: risks.append(f"错因复发:{m['error_type']}(×{m['recurrence_count']})") diff --git a/scripts/studylib/nextstep.py b/scripts/studylib/nextstep.py index 74df560..8a49706 100644 --- a/scripts/studylib/nextstep.py +++ b/scripts/studylib/nextstep.py @@ -2,6 +2,8 @@ from datetime import date +from .display import kc_label + DEFAULT_WEIGHTS = { "exam_weight": 1.0, "urgency": 1.0, "weakness": 1.5, "prereq_centrality": 0.8, "forgetting_risk": 1.0, "transfer_gap": 0.8, "blind_spot": 1.2, "expected_time": 0.5, @@ -110,7 +112,9 @@ def compute_next_best_step( reasons.append(f"距考试 {days} 天") candidates.append({ - "action": action, "kc_id": kc_id, "kc_name": kc.get("name", kc_id), + "action": action, "kc_id": kc_id, + "kc_name": kc.get("name", kc_id), + "kc_label": kc_label(kc_id, kc_states), "estimated_minutes": minutes, "priority_score": round(score, 4), "reasons": reasons, }) diff --git a/templates/dashboard.md.j2 b/templates/dashboard.md.j2 index 4481eec..805aa57 100644 --- a/templates/dashboard.md.j2 +++ b/templates/dashboard.md.j2 @@ -3,7 +3,7 @@ {% if s.next_best_step.action == "rest" -%} 暂无紧急任务。可以休息,或自由推进新章节。 {%- else -%} -**{{ s.next_best_step.action }}**:{{ s.next_best_step.kc_name or "到期复习" }} +**{{ s.next_best_step.action }}**:{{ s.next_best_step.kc_label or s.next_best_step.kc_name or "到期复习" }} 预计:{{ s.next_best_step.estimated_minutes }} 分钟 为什么: diff --git a/tests/test_nextstep.py b/tests/test_nextstep.py index 98e9aeb..607c6c9 100644 --- a/tests/test_nextstep.py +++ b/tests/test_nextstep.py @@ -17,6 +17,10 @@ def _kc_state(kc_id, state, *, weight=0.5, blind=0.0, t1=None, t2=None, } +def _patch_name(kc_states, kc_id, name): + kc_states[kc_id]["name"] = name + + def test_repair_beats_confirmed(): from studylib.nextstep import compute_next_best_step kc_states = { @@ -60,3 +64,12 @@ def test_advance_for_unseen(): rec = compute_next_best_step(COURSE, {"u": _kc_state("u", "unseen")}, {}, []) assert rec["action"] == "advance" assert rec["kc_id"] == "u" + + +def test_next_step_emits_kc_label(): + from studylib.nextstep import compute_next_best_step + kc_states = {"u": _kc_state("u", "unseen")} + _patch_name(kc_states, "u", "未知点") + rec = compute_next_best_step(COURSE, kc_states, {}, []) + assert rec["kc_label"] == "u(未知点)" + assert rec["kc_name"] == "未知点" # back-compat retained From f7a2cd4a559b53fdc02aefd57350b10973bd7c5d Mon Sep 17 00:00:00 2001 From: TD_Xu Date: Mon, 20 Jul 2026 18:03:41 +0800 Subject: [PATCH 03/14] feat(display): KC labels in evidence/misconception CLI + read_json util Co-Authored-By: Claude --- CHANGELOG.md | 4 ++++ SKILL.md | 1 + scripts/evidence.py | 7 +++++-- scripts/misconception.py | 7 +++++-- scripts/studylib/ioutils.py | 7 +++++++ tests/test_cli_smoke.py | 31 +++++++++++++++++++++++++++++++ tests/test_ioutils.py | 12 ++++++++++++ 7 files changed, 65 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index d8099c8..47043da 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,10 @@ ## [Unreleased] +### 2026-07-20 — `feat` — F1 KC 中英对照显示 +- 新增 `studylib.display.kc_label`;接入 next_step / dashboard / evidence / misconception 输出,统一 `kc_id(中文名)`。 +- `ioutils` 新增 `read_json`。涉及:`nextstep.py` `dashboard.py` `cli_common.py` `templates/dashboard.md.j2` `scripts/evidence.py` `scripts/misconception.py`。 + ### 2026-07-20 — `chore` — 建立版本控制与变更纪律基线 - 将本地 git 纳入「每次修改可追踪」的工作流:新增本文件 `CHANGELOG.md` 与 `CLAUDE.md`。 diff --git a/SKILL.md b/SKILL.md index 43d807a..ed04f29 100644 --- a/SKILL.md +++ b/SKILL.md @@ -14,6 +14,7 @@ description: 面向大学课程的本地优先持续学习 Agent。当用户说 3. 听懂≠掌握:升级 checked/confirmed 的规则由脚本执行,你不得口头宣布掌握。 4. AI 生成题必须走 agents/ 三卡流程 + `validate_question.py` 闸门,通过才存在。 5. 原题(真题/课后题)优先于 AI 生成题,且必须进 FSRS。 +6. 面向用户提到知识点时,一律用 `kc_id(中文名)` 形式(脚本已自动生成,你照着念)。 ## 会话开场(/study 默认行为) diff --git a/scripts/evidence.py b/scripts/evidence.py index 210db17..18cf1b5 100644 --- a/scripts/evidence.py +++ b/scripts/evidence.py @@ -7,7 +7,8 @@ import typer from studylib.cli_common import guard, resolve_root -from studylib.ioutils import read_jsonl +from studylib.display import kc_label +from studylib.ioutils import read_json, read_jsonl app = typer.Typer(add_completion=False) @@ -19,10 +20,12 @@ def list_cmd( course: Path = typer.Option(None, "--course"), ): root = resolve_root(course) + kcs = read_json(root / ".study" / "kc.json") rows = [r for r in read_jsonl(root / ".study" / "evidence.jsonl") if kc in r["kc_ids"]] if not rows: - typer.echo(f"KC {kc} 暂无证据") + typer.echo(f"{kc_label(kc, kcs)} 暂无证据") return + typer.echo(f"{kc_label(kc, kcs)}(共 {len(rows)} 条证据)") for r in rows: mark = "✓" if r["result"]["correct"] else "✗" conf = r.get("confidence_before") diff --git a/scripts/misconception.py b/scripts/misconception.py index ccbab9f..bef1961 100644 --- a/scripts/misconception.py +++ b/scripts/misconception.py @@ -7,7 +7,8 @@ import typer from studylib.cli_common import guard, resolve_root -from studylib.ioutils import read_jsonl +from studylib.display import kc_label +from studylib.ioutils import read_json, read_jsonl app = typer.Typer(add_completion=False) @@ -21,9 +22,11 @@ def list_cmd(course: Path = typer.Option(None, "--course")): if not active: typer.echo("没有活跃错因") return + kcs = read_json(root / ".study" / "kc.json") for m in active: + kc_disp = " / ".join(kc_label(k, kcs) for k in m["kc_ids"]) typer.echo(f"{m['error_id']} [{m['repair_status']}] {m['error_type']} " - f"kc={','.join(m['kc_ids'])} ×{m.get('recurrence_count', 1)}") + f"kc={kc_disp} ×{m.get('recurrence_count', 1)}") typer.echo(f" 错误假设:{m.get('wrong_assumption', '')}") diff --git a/scripts/studylib/ioutils.py b/scripts/studylib/ioutils.py index 85cfe26..7a49e86 100644 --- a/scripts/studylib/ioutils.py +++ b/scripts/studylib/ioutils.py @@ -55,6 +55,13 @@ def read_jsonl(path: Path) -> list[dict]: return rows +def read_json(path: Path) -> dict: + path = Path(path) + if not path.exists(): + return {} + return json.loads(path.read_text(encoding="utf-8")) + + class _CourseLock: """Non-reentrant course-level lock; raises StateLockTimeout on timeout.""" diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index f9bc766..eb7f886 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -67,3 +67,34 @@ def test_cli_friendly_error_outside_course(tmp_path): r = run([SCRIPTS / "next_step.py"], tmp_path, tmp_path / "home") assert r.returncode == 1 assert "course.yaml" in r.stderr + r.stdout + + +def test_evidence_and_misconception_show_labels(tmp_path): + home = tmp_path / "home" + course_dir = tmp_path / "模电" + r = run([SCRIPTS / "init_course.py", str(course_dir), "--course-id", "analog", + "--name", "模拟电子技术"], tmp_path, home) + assert r.returncode == 0, r.stderr + r = run([SCRIPTS / "event.py", "kc-add", "--kc-id", "feedback_topology", + "--name", "反馈组态判断", "--exam-weight", "0.9"], course_dir, home) + assert r.returncode == 0, r.stderr + cand = course_dir / "q.json" + cand.write_text(json.dumps({ + "question_id": "q1", "kc_ids": ["feedback_topology"], "source_type": "past_exam", + "transfer_level": "T0", "stem": "判断反馈组态", "answer": "A", + }, ensure_ascii=False), encoding="utf-8") + assert run([SCRIPTS / "validate_question.py", str(cand)], course_dir, home).returncode == 0 + assert run([SCRIPTS / "event.py", "attempt", "--question-id", "q1", + "--wrong", "--confidence", "0.9"], course_dir, home).returncode == 0 + assert run([SCRIPTS / "event.py", "misconception", "--error-id", "err_001", + "--kc", "feedback_topology", "--question", "q1", + "--wrong-assumption", "x", "--missing-premise", "y", + "--error-type", "concept_misconception"], course_dir, home).returncode == 0 + + r = run([SCRIPTS / "evidence.py", "--kc", "feedback_topology"], course_dir, home) + assert r.returncode == 0, r.stderr + assert "feedback_topology(反馈组态判断)" in r.stdout + + r = run([SCRIPTS / "misconception.py"], course_dir, home) + assert r.returncode == 0, r.stderr + assert "feedback_topology(反馈组态判断)" in r.stdout diff --git a/tests/test_ioutils.py b/tests/test_ioutils.py index 7582845..5f51f4f 100644 --- a/tests/test_ioutils.py +++ b/tests/test_ioutils.py @@ -53,3 +53,15 @@ def test_course_lock_timeout(tmp_path): pass finally: outer.release() + + +def test_read_json_roundtrip(tmp_path): + from studylib.ioutils import atomic_write_json, read_json + p = tmp_path / "x.json" + atomic_write_json(p, {"a": 1, "中文": "好"}) + assert read_json(p) == {"a": 1, "中文": "好"} + + +def test_read_json_missing_returns_empty(tmp_path): + from studylib.ioutils import read_json + assert read_json(tmp_path / "nope.json") == {} From 6a52ced5422feb1e813fc9945f561c8adab0802d Mon Sep 17 00:00:00 2001 From: TD_Xu Date: Mon, 20 Jul 2026 18:13:39 +0800 Subject: [PATCH 04/14] feat(drill): add drill-manifest contract with KC labels Co-Authored-By: Claude --- scripts/studylib/manifest.py | 34 ++++++++++++++++++++++++++++++++++ tests/test_manifest.py | 25 +++++++++++++++++++++++++ 2 files changed, 59 insertions(+) create mode 100644 scripts/studylib/manifest.py create mode 100644 tests/test_manifest.py diff --git a/scripts/studylib/manifest.py b/scripts/studylib/manifest.py new file mode 100644 index 0000000..fb68f21 --- /dev/null +++ b/scripts/studylib/manifest.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from .display import kc_label +from .ioutils import now_iso +from .schemas import SCHEMA_VERSION + + +def _question_view(q: dict, kcs: dict | None) -> dict: + kc_ids = list(q.get("kc_ids", [])) + return { + "question_id": q["question_id"], + "kc_ids": kc_ids, + "kc_labels": [kc_label(k, kcs) for k in kc_ids], + "stem": q.get("stem", ""), + "answer": q.get("answer", ""), + "solution": q.get("solution", ""), + "difficulty": q.get("difficulty", 0.5), + "transfer_level": q.get("transfer_level", "T0"), + } + + +def build_manifest(course: dict, mode: str, count: int, questions: list[dict], + kcs: dict | None = None) -> dict: + return { + "schema_version": SCHEMA_VERSION, + "meta": { + "course_id": course["id"], + "course_name": course["name"], + "mode": mode, + "count": count, + "generated_at": now_iso(), + }, + "questions": [_question_view(q, kcs) for q in questions], + } diff --git a/tests/test_manifest.py b/tests/test_manifest.py new file mode 100644 index 0000000..2767096 --- /dev/null +++ b/tests/test_manifest.py @@ -0,0 +1,25 @@ +from studylib.manifest import build_manifest + + +def test_manifest_shape_and_meta(): + course = {"id": "mao-zhongte", "name": "毛中特"} + qs = [{"question_id": "q1", "kc_ids": ["mao_living_soul"], "stem": "s", "answer": "A", + "solution": "sol", "difficulty": 0.4, "transfer_level": "T0", + "validation": {"generator": {}}}] + m = build_manifest(course, "diagnostic", 5, qs, + kcs={"mao_living_soul": {"name": "毛泽东思想活的灵魂"}}) + assert m["meta"] == {k: m["meta"][k] for k in + ("course_id", "course_name", "mode", "count", "generated_at")} + assert m["meta"]["course_name"] == "毛中特" and m["meta"]["count"] == 5 + assert len(m["questions"]) == 1 + q = m["questions"][0] + assert q["question_id"] == "q1" + assert q["kc_labels"] == ["mao_living_soul(毛泽东思想活的灵魂)"] + assert "validation" not in q # rendering does not need it + assert q["answer"] == "A" and q["solution"] == "sol" + + +def test_manifest_no_kcs_falls_back_to_ids(): + m = build_manifest({"id": "c", "name": "C"}, "syllabus", 3, + [{"question_id": "q", "kc_ids": ["k"], "stem": "", "answer": "B"}]) + assert m["questions"][0]["kc_labels"] == ["k"] From d153f8abfe7dcc5dc2f2da8b9c2f432e36ec8bfe Mon Sep 17 00:00:00 2001 From: TD_Xu Date: Mon, 20 Jul 2026 18:22:24 +0800 Subject: [PATCH 05/14] feat(output): self-contained interactive HTML quiz renderer + template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - scripts/studylib/render_html.py: parse_options + render_quiz_html - templates/quiz.html.j2: inline CSS+JS, reveal toggle, per-question grading - tests/test_render_html.py: 6 tests (half/full-width, none, continuation, embed, reveal off) parse_options accepts both half-width (A-H/a-h) and full-width (A-Ha-h) option letters, normalizing to ASCII uppercase via NFKC so downstream rendering and tuples always get A/B/…. Co-Authored-By: Claude --- scripts/studylib/render_html.py | 53 ++++++++++++++ templates/quiz.html.j2 | 121 ++++++++++++++++++++++++++++++++ tests/test_render_html.py | 55 +++++++++++++++ 3 files changed, 229 insertions(+) create mode 100644 scripts/studylib/render_html.py create mode 100644 templates/quiz.html.j2 create mode 100644 tests/test_render_html.py diff --git a/scripts/studylib/render_html.py b/scripts/studylib/render_html.py new file mode 100644 index 0000000..bf26fb3 --- /dev/null +++ b/scripts/studylib/render_html.py @@ -0,0 +1,53 @@ +from __future__ import annotations + +import re +import unicodedata +from pathlib import Path + +from jinja2 import Template + +TEMPLATE_PATH = Path(__file__).resolve().parents[2] / "templates" / "quiz.html.j2" + +_OPT_RE = re.compile(r"^\s*([A-Ha-hA-Ha-h])[..、)]\s*(.*)$") + + +def parse_options(stem: str) -> tuple[str, list[tuple[str, str]]]: + lines = stem.splitlines() + body: list[str] = [] + opts: list[list[str]] = [] # each: [letter, text] + in_opts = False + for ln in lines: + m = _OPT_RE.match(ln) + if m: + in_opts = True + letter = unicodedata.normalize("NFKC", m.group(1)).upper() + opts.append([letter, m.group(2).strip()]) + elif not in_opts: + body.append(ln) + else: + opts[-1][1] = (opts[-1][1] + " " + ln.strip()).strip() + return "\n".join(body).strip(), [(o[0], o[1]) for o in opts] + + +def _question_for_render(q: dict) -> dict: + body, opts = parse_options(q.get("stem", "")) + ans = q.get("answer", "") or "" + letters = sorted({ch for ch in ans if ch.strip()}) + return { + "stem": body, + "options": [{"letter": l, "text": t} for l, t in opts], + "answer": ans, + "answer_letters": letters, + "multi": len(letters) > 1, + "solution": q.get("solution", ""), + "kc_labels": q.get("kc_labels") or q.get("kc_ids", []), + } + + +def render_quiz_html(manifest: dict, reveal_default: bool = True) -> str: + tpl = Template(TEMPLATE_PATH.read_text(encoding="utf-8")) + view = { + "meta": manifest["meta"], + "questions": [_question_for_render(q) for q in manifest["questions"]], + } + return tpl.render(m=view, reveal_default=("on" if reveal_default else "off")) diff --git a/templates/quiz.html.j2 b/templates/quiz.html.j2 new file mode 100644 index 0000000..fac97a1 --- /dev/null +++ b/templates/quiz.html.j2 @@ -0,0 +1,121 @@ + + + + +{{ m.meta.course_name }} · 练习 + + + +

{{ m.meta.course_name }} · 练习卷

+
{{ m.meta.mode }} 模式 · {{ m.questions | count }} 题 · 生成于 {{ m.meta.generated_at }}
+ +
+ + + + + + + +
+ +{% for q in m.questions %} +{% set qnum = loop.index %} +
+
{% for l in q.kc_labels %}{{ l }}{% if not loop.last %} · {% endif %}{% endfor %}
+
{{ qnum }}. {{ q.stem }}
+
+ {% for o in q.options %} + + {% endfor %} +
+
+
答案:{{ q.answer }}
+
{{ q.solution }}
+
+
+{% endfor %} + + + + diff --git a/tests/test_render_html.py b/tests/test_render_html.py new file mode 100644 index 0000000..0f6d429 --- /dev/null +++ b/tests/test_render_html.py @@ -0,0 +1,55 @@ +from studylib.render_html import parse_options, render_quiz_html + + +def test_parse_options_half_and_full_width(): + body, opts = parse_options("毛泽东思想活的灵魂是( )\nA.实事求是\nB.群众路线\nC.独立自主\nD.统一战线") + assert body == "毛泽东思想活的灵魂是( )" + assert opts == [("A", "实事求是"), ("B", "群众路线"), ("C", "独立自主"), ("D", "统一战线")] + + +def test_parse_options_full_width_dot(): + _, opts = parse_options("Q\nA.甲\nB.乙") + assert opts == [("A", "甲"), ("B", "乙")] + + +def test_parse_options_none(): + body, opts = parse_options("简答题:论述…") + assert body == "简答题:论述…" and opts == [] + + +def test_parse_option_continuation_line(): + _, opts = parse_options("Q\nA.第一行\n续行\nB.第二项") + assert opts == [("A", "第一行 续行"), ("B", "第二项")] + + +def _manifest(multi=False): + return { + "meta": {"course_name": "毛中特", "mode": "diagnostic", "count": 1, + "generated_at": "2026-07-20T00:00:00+08:00"}, + "questions": [{ + "kc_labels": ["mao_living_soul(毛泽东思想活的灵魂)"], + "stem": "活的灵魂三个方面是( )\nA.实事求是\nB.群众路线\nC.独立自主", + "answer": "ABC" if multi else "A", + "solution": "实事求是/群众路线/独立自主。", + }], + } + + +def test_render_html_embeds_toggle_options_answers(): + html = render_quiz_html(_manifest(multi=True), reveal_default=True) + assert 'id="revealToggle"' in html + # toggle ON -> the toggle input carries 'checked' + assert 'revealToggle" checked' in html + assert "mao_living_soul(毛泽东思想活的灵魂)" in html + # multi -> checkbox; single would be radio + assert 'type="checkbox"' in html + # answer + solution embedded (hidden by CSS until reveal) + assert "ABC" in html and "实事求是/群众路线/独立自主" in html + + +def test_render_html_reveal_default_off_not_checked(): + html = render_quiz_html(_manifest(multi=False), reveal_default=False) + assert 'type="radio"' in html + # toggle OFF -> the toggle input must NOT carry 'checked' + toggle_line = html.split('id="revealToggle"', 1)[1].split(">", 1)[0] + assert "checked" not in toggle_line From b586d2a6b50ba47d9508040c7e145bdd178afd57 Mon Sep 17 00:00:00 2001 From: TD_Xu Date: Mon, 20 Jul 2026 18:31:26 +0800 Subject: [PATCH 06/14] fix(output): normalize answer letters to match option-case for grading MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In _question_for_render, option letters were normalized to ASCII uppercase (via unicodedata.normalize('NFKC', ...).upper()), but the answer letters used for grading (data-answer) and the displayed answer were not. A question whose answer was lowercase (e.g. 'abc') or full-width would silently grade wrong: the JS compares uppercase option letters against un-normalized answer letters. Normalize answer letters the same way, and join them so the displayed '答案:{{ q.answer }}' is consistent with the uppercase option letters and the data-answer grading attribute. Adds regression test test_lowercase_answer_normalized_for_grading. Co-Authored-By: Claude --- scripts/studylib/render_html.py | 4 ++-- tests/test_render_html.py | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) diff --git a/scripts/studylib/render_html.py b/scripts/studylib/render_html.py index bf26fb3..8ca8b9a 100644 --- a/scripts/studylib/render_html.py +++ b/scripts/studylib/render_html.py @@ -32,11 +32,11 @@ def parse_options(stem: str) -> tuple[str, list[tuple[str, str]]]: def _question_for_render(q: dict) -> dict: body, opts = parse_options(q.get("stem", "")) ans = q.get("answer", "") or "" - letters = sorted({ch for ch in ans if ch.strip()}) + letters = sorted({unicodedata.normalize("NFKC", ch).upper() for ch in ans if ch.strip()}) return { "stem": body, "options": [{"letter": l, "text": t} for l, t in opts], - "answer": ans, + "answer": "".join(letters), "answer_letters": letters, "multi": len(letters) > 1, "solution": q.get("solution", ""), diff --git a/tests/test_render_html.py b/tests/test_render_html.py index 0f6d429..05fc869 100644 --- a/tests/test_render_html.py +++ b/tests/test_render_html.py @@ -53,3 +53,17 @@ def test_render_html_reveal_default_off_not_checked(): # toggle OFF -> the toggle input must NOT carry 'checked' toggle_line = html.split('id="revealToggle"', 1)[1].split(">", 1)[0] assert "checked" not in toggle_line + + +def test_lowercase_answer_normalized_for_grading(): + # options normalized to uppercase; answer must match that normalization + m = { + "meta": {"course_name": "C", "mode": "syllabus", "count": 1, + "generated_at": "2026-07-20T00:00:00+08:00"}, + "questions": [{"kc_labels": ["k(名)"], "stem": "Q\nA.x\nB.y\nC.z", + "answer": "abc", "solution": "s"}], + } + html = render_quiz_html(m, reveal_default=True) + # grading attribute uses uppercase; displayed answer is uppercase too + assert 'data-answer="A,B,C"' in html + assert "答案:ABC" in html From c39984d8ff40504a4b2fba120737fddc350b455d Mon Sep 17 00:00:00 2001 From: TD_Xu Date: Mon, 20 Jul 2026 18:36:08 +0800 Subject: [PATCH 07/14] feat(output): render_quiz_html CLI Thin typer wrapper around studylib.render_html.render_quiz_html: reads a manifest JSON via ioutils.read_json, renders the interactive HTML quiz, and writes to --out (default .html). Co-Authored-By: Claude --- scripts/render_quiz_html.py | 34 ++++++++++++++++++++++++++++++++++ tests/test_cli_smoke.py | 20 ++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100755 scripts/render_quiz_html.py diff --git a/scripts/render_quiz_html.py b/scripts/render_quiz_html.py new file mode 100755 index 0000000..fc95b54 --- /dev/null +++ b/scripts/render_quiz_html.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python3 +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import typer + +from studylib.cli_common import guard +from studylib.ioutils import atomic_write_text, read_json +from studylib.render_html import render_quiz_html + +app = typer.Typer(add_completion=False) + + +@app.command() +@guard +def main( + manifest: Path = typer.Option(..., "--manifest"), + out: Path = typer.Option(None, "--out"), + reveal_default: str = typer.Option("on", "--reveal-default"), +): + m = read_json(manifest) + if not m: + typer.echo(f"错误:manifest 不存在或为空:{manifest}", err=True) + raise typer.Exit(code=1) + html = render_quiz_html(m, reveal_default == "on") + out = out or manifest.with_suffix(".html") + atomic_write_text(out, html) + typer.echo(f"已生成交互测验页:{out}") + + +if __name__ == "__main__": + app() diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index eb7f886..a0abc16 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -98,3 +98,23 @@ def test_evidence_and_misconception_show_labels(tmp_path): r = run([SCRIPTS / "misconception.py"], course_dir, home) assert r.returncode == 0, r.stderr assert "feedback_topology(反馈组态判断)" in r.stdout + + +def test_render_quiz_html_cli(tmp_path): + home = tmp_path / "home" + course_dir = tmp_path / "模电" + assert run([SCRIPTS / "init_course.py", str(course_dir), "--course-id", "analog", + "--name", "模拟电子技术"], tmp_path, home).returncode == 0 + manifest = course_dir / "m.json" + manifest.write_text(json.dumps({ + "meta": {"course_id": "analog", "course_name": "模拟电子技术", "mode": "syllabus", + "count": 1, "generated_at": "2026-07-20T00:00:00+08:00"}, + "questions": [{"question_id": "q1", "kc_labels": ["k(名)"], + "stem": "Q\nA.x\nB.y", "answer": "A", "solution": "s"}], + }, ensure_ascii=False), encoding="utf-8") + out = course_dir / "quiz.html" + r = run([SCRIPTS / "render_quiz_html.py", "--manifest", str(manifest), + "--out", str(out), "--reveal-default", "on"], course_dir, home) + assert r.returncode == 0, r.stderr + text = out.read_text(encoding="utf-8") + assert "id=\"revealToggle\"" in text and "模拟电子技术" in text From bbbe0e1337269816ad5d080779acb7ae745f225c Mon Sep 17 00:00:00 2001 From: TD_Xu Date: Mon, 20 Jul 2026 21:50:41 +0800 Subject: [PATCH 08/14] feat(output): PDF paper renderer (questions/answers) replacing md_to_pdf - studylib/render_paper.py: manifest_to_markdown(variant) + markdown_to_pdf(fonts_dir) with CID-fallback font strategy (no font files needed for default path). - scripts/render_paper.py: CLI --manifest [--variant questions|answers|both] [--out-dir] [--fonts-dir]. - requirements.txt: +reportlab>=4.0. - tests/test_render_paper.py: pure-function + PDF smoke (CID fallback). - CHANGELOG: F3 entry; remove scripts/md_to_pdf.py (folded into render_paper). Co-Authored-By: Claude --- CHANGELOG.md | 5 ++ requirements.txt | 1 + scripts/md_to_pdf.py | 108 -------------------------- scripts/render_paper.py | 44 +++++++++++ scripts/studylib/render_paper.py | 128 +++++++++++++++++++++++++++++++ tests/test_render_paper.py | 28 +++++++ 6 files changed, 206 insertions(+), 108 deletions(-) delete mode 100644 scripts/md_to_pdf.py create mode 100644 scripts/render_paper.py create mode 100644 scripts/studylib/render_paper.py create mode 100644 tests/test_render_paper.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 47043da..e0ac316 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,11 @@ ## [Unreleased] +### 2026-07-20 — `feat` — F3 选择题输出(网页 / PDF 试卷) +- 新增 `studylib.manifest`(drill manifest 契约)、`studylib.render_html` + `templates/quiz.html.j2`(自包含交互测验页,运行时解析开关)、`studylib.render_paper`(manifest→PDF,支持题目卷/答案解析卷,CID 字体回退免装字体)。 +- 新增 CLI:`scripts/render_quiz_html.py`、`scripts/render_paper.py`;移除 `scripts/md_to_pdf.py`(其能力并入 render_paper)。 +- `requirements` 加 `reportlab>=4.0`。 + ### 2026-07-20 — `feat` — F1 KC 中英对照显示 - 新增 `studylib.display.kc_label`;接入 next_step / dashboard / evidence / misconception 输出,统一 `kc_id(中文名)`。 - `ioutils` 新增 `read_json`。涉及:`nextstep.py` `dashboard.py` `cli_common.py` `templates/dashboard.md.j2` `scripts/evidence.py` `scripts/misconception.py`。 diff --git a/requirements.txt b/requirements.txt index 28e5783..bdf1d8b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -5,3 +5,4 @@ jinja2>=3.1 filelock>=3.12 pyyaml>=6.0 pytest>=8.0 +reportlab>=4.0 diff --git a/scripts/md_to_pdf.py b/scripts/md_to_pdf.py deleted file mode 100644 index 1fdde27..0000000 --- a/scripts/md_to_pdf.py +++ /dev/null @@ -1,108 +0,0 @@ -#!/usr/bin/env python3 -"""Render the exam markdown files to clean CJK PDFs with reportlab.""" -import re, sys, html -from reportlab.lib.pagesizes import A4 -from reportlab.lib.units import mm -from reportlab.lib import colors -from reportlab.pdfbase import pdfmetrics -from reportlab.pdfbase.ttfonts import TTFont -from reportlab.platypus import (SimpleDocTemplate, Paragraph, Spacer, Table, - TableStyle, HRFlowable, PageBreak) -from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle -from reportlab.lib.enums import TA_LEFT - -# ---- register CJK fonts (Heiti Light body, Heiti Medium bold/heading) ---- -F = "/Users/td_xu/Desktop/SKill/study-loop/tmp/fonts/" -pdfmetrics.registerFont(TTFont("Body", F+"STHeitiLight.ttc", subfontIndex=0)) -pdfmetrics.registerFont(TTFont("HeitiM", F+"STHeitiMedium.ttc", subfontIndex=0)) -from reportlab.pdfbase.pdfmetrics import registerFontFamily -registerFontFamily("Body", normal="Body", bold="HeitiM", italic="Body", boldItalic="HeitiM") - -SS = getSampleStyleSheet() -H1 = ParagraphStyle("H1", parent=SS["Title"], fontName="HeitiM", fontSize=16, leading=22, spaceAfter=6, textColor=colors.HexColor("#1a1a1a")) -H2 = ParagraphStyle("H2", fontName="HeitiM", fontSize=12.5, leading=18, spaceBefore=10, spaceAfter=4, textColor=colors.HexColor("#0b5394")) -H3 = ParagraphStyle("H3", fontName="HeitiM", fontSize=11, leading=16, spaceBefore=6, spaceAfter=3, textColor=colors.HexColor("#333333")) -BODY = ParagraphStyle("BODY", fontName="Body", fontSize=10.5, leading=16.5, alignment=TA_LEFT, spaceAfter=3) -OPT = ParagraphStyle("OPT", fontName="Body", fontSize=10.5, leading=15, leftIndent=14, spaceAfter=1) -QUOTE = ParagraphStyle("QUOTE", fontName="Body", fontSize=10, leading=15, leftIndent=16, textColor=colors.HexColor("#444444"), spaceAfter=3) - -def inline(t): - t = t.replace("\\_", "_") # un-escape underscores (blank-fill lines) - t = html.escape(t) # single escape: & < > " - parts = t.split("**") # toggle bold on each ** pair - if len(parts) > 1: - out = [] - for i, p in enumerate(parts): - if i % 2 == 1: - out += ["", p, ""] - else: - out.append(p) - t = "".join(out) - return t - -def parse(md): - flows = [] - lines = md.split("\n") - i = 0 - n = len(lines) - while i < n: - line = lines[i].rstrip("\n") - s = line.strip() - if s == "" : - i += 1; continue - if s == "---": - flows.append(HRFlowable(width="100%", thickness=0.6, color=colors.HexColor("#bbbbbb"), spaceBefore=4, spaceAfter=4)); i+=1; continue - if s.startswith("# "): - flows.append(Paragraph(s[2:].strip(), H1)); i+=1; continue - if s.startswith("## "): - flows.append(Paragraph(s[3:].strip(), H2)); i+=1; continue - if s.startswith("### "): - flows.append(Paragraph(s[4:].strip(), H3)); i+=1; continue - if s.startswith("> "): - blk=[] - while i"): - blk.append(lines[i].strip()[1:].strip()); i+=1 - flows.append(Paragraph("
".join(inline(x) for x in blk), QUOTE)); continue - # table block - if s.startswith("|"): - rows=[] - while i=3: - widths=[14*mm]+[(168-14)/ (ncols-1) *mm]*(ncols-1) - t._argW=widths - flows.append(t); flows.append(Spacer(1,4)) - continue - # option lines start with full-width space or " A." style - if re.match(r"^[ \s]*[A-E][..、]", s): - flows.append(Paragraph(inline(s), OPT)); i+=1; continue - flows.append(Paragraph(inline(s), BODY)); i+=1 - return flows - -def build(md_path, pdf_path): - md=open(md_path,encoding="utf-8").read() - doc=SimpleDocTemplate(pdf_path, pagesize=A4, - leftMargin=20*mm,rightMargin=20*mm,topMargin=18*mm,bottomMargin=18*mm, - title=pdf_path.split("/")[-1]) - doc.build(parse(md)) - print("wrote", pdf_path) - -if __name__=="__main__": - base="/Users/td_xu/courses/毛中特/output/" - build(base+"毛中特期末模拟卷.md", base+"毛中特期末模拟卷.pdf") - build(base+"毛中特期末模拟卷-答案解析.md", base+"毛中特期末模拟卷-答案解析.pdf") diff --git a/scripts/render_paper.py b/scripts/render_paper.py new file mode 100644 index 0000000..ace9c4d --- /dev/null +++ b/scripts/render_paper.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import typer + +from studylib.cli_common import guard +from studylib.ioutils import read_json +from studylib.render_paper import manifest_to_markdown, markdown_to_pdf + +app = typer.Typer(add_completion=False) + + +@app.command() +@guard +def main( + manifest: Path = typer.Option(..., "--manifest"), + variant: str = typer.Option("both", "--variant"), + out_dir: Path = typer.Option(None, "--out-dir"), + fonts_dir: Path = typer.Option(None, "--fonts-dir"), +): + if variant not in ("questions", "answers", "both"): + typer.echo("错误:--variant 必须是 questions/answers/both", err=True) + raise typer.Exit(code=1) + m = read_json(manifest) + if not m: + typer.echo(f"错误:manifest 不存在或为空:{manifest}", err=True) + raise typer.Exit(code=1) + out_dir = out_dir or manifest.parent + out_dir.mkdir(parents=True, exist_ok=True) + base = m["meta"].get("course_name", "quiz") + variants = ["questions", "answers"] if variant == "both" else [variant] + for v in variants: + md = manifest_to_markdown(m, v) + tag = "题目" if v == "questions" else "答案解析" + pdf = out_dir / f"{base}-{tag}.pdf" + markdown_to_pdf(md, pdf, fonts_dir=fonts_dir) + typer.echo(f"已生成:{pdf}") + + +if __name__ == "__main__": + app() diff --git a/scripts/studylib/render_paper.py b/scripts/studylib/render_paper.py new file mode 100644 index 0000000..85e67a6 --- /dev/null +++ b/scripts/studylib/render_paper.py @@ -0,0 +1,128 @@ +from __future__ import annotations + +import html as _html +from pathlib import Path + +from reportlab.lib import colors +from reportlab.lib.enums import TA_LEFT +from reportlab.lib.pagesizes import A4 +from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet +from reportlab.lib.units import mm +from reportlab.pdfbase import pdfmetrics +from reportlab.pdfbase.cidfonts import UnicodeCIDFont +from reportlab.pdfbase.pdfmetrics import registerFontFamily +from reportlab.pdfbase.ttfonts import TTFont +from reportlab.platypus import (HRFlowable, PageBreak, Paragraph, SimpleDocTemplate, + Spacer, Table, TableStyle) + +BODY_FONT = "Body" +BOLD_FONT = "HeitiM" +_REGISTERED_KEY: str | None = None # guards repeated registration in one process + + +def _register_fonts(fonts_dir: Path | None) -> None: + """Register CJK fonts. Prefer Heiti .ttc from fonts_dir; else fall back to + reportlab's built-in CID font STSong-Light (no font files needed). Sets the + module-level BODY_FONT/BOLD_FONT so styles resolve correctly either way.""" + global BODY_FONT, BOLD_FONT, _REGISTERED_KEY + fonts_dir = Path(fonts_dir) if fonts_dir else None + use_heiti = bool(fonts_dir and (fonts_dir / "STHeitiLight.ttc").exists() + and (fonts_dir / "STHeitiMedium.ttc").exists()) + key = f"heiti:{fonts_dir}" if use_heiti else "cid" + if _REGISTERED_KEY == key: + return # already registered with this config in this process + if use_heiti: + pdfmetrics.registerFont(TTFont("Body", str(fonts_dir / "STHeitiLight.ttc"), subfontIndex=0)) + pdfmetrics.registerFont(TTFont("HeitiM", str(fonts_dir / "STHeitiMedium.ttc"), subfontIndex=0)) + BODY_FONT, BOLD_FONT = "Body", "HeitiM" + else: + # UnicodeCIDFont registers under its face name; reuse that name for both. + pdfmetrics.registerFont(UnicodeCIDFont("STSong-Light")) + BODY_FONT = BOLD_FONT = "STSong-Light" + registerFontFamily(BODY_FONT, normal=BODY_FONT, bold=BOLD_FONT, + italic=BODY_FONT, boldItalic=BOLD_FONT) + _REGISTERED_KEY = key + + +def _styles(): + ss = getSampleStyleSheet() + return { + "h1": ParagraphStyle("h1", parent=ss["Title"], fontName=BOLD_FONT, fontSize=16, + leading=22, spaceAfter=6, textColor=colors.HexColor("#1a1a1a")), + "body": ParagraphStyle("body", fontName=BODY_FONT, fontSize=10.5, leading=16.5, + alignment=TA_LEFT, spaceAfter=3), + "opt": ParagraphStyle("opt", fontName=BODY_FONT, fontSize=10.5, leading=15, + leftIndent=14, spaceAfter=1), + "quote": ParagraphStyle("quote", fontName=BODY_FONT, fontSize=10, leading=15, + leftIndent=16, textColor=colors.HexColor("#444444"), spaceAfter=3), + } + + +def _inline(t: str) -> str: + t = t.replace("\\_", "_") + t = _html.escape(t) + parts = t.split("**") + if len(parts) > 1: + out = [] + for i, p in enumerate(parts): + out += ["", p, ""] if i % 2 else [p] + t = "".join(out) + return t + + +def manifest_to_markdown(manifest: dict, variant: str) -> str: + if variant not in ("questions", "answers"): + raise ValueError(f"unknown variant: {variant}") + meta = manifest["meta"] + lines = [f"# {meta['course_name']} · 模拟卷", + f"> {meta['mode']} 模式 · {len(manifest['questions'])} 题 · 生成于 {meta['generated_at']}", ""] + for i, q in enumerate(manifest["questions"], 1): + kcs = " · ".join(q.get("kc_labels") or q.get("kc_ids", [])) + lines.append(f"### 第 {i} 题 {kcs}") + lines.append(q.get("stem", "")) + if variant == "answers": + lines += ["", f"**答案:{q.get('answer', '')}**", f"> 解析:{q.get('solution', '')}"] + lines.append("") + return "\n".join(lines) + + +def _parse(md: str, st: dict) -> list: + import re + flows, lines, i, n = [], md.split("\n"), 0, len(md.split("\n")) + while i < n: + line = lines[i].rstrip("\n") + s = line.strip() + if s == "": + i += 1 + continue + if s == "---": + flows.append(HRFlowable(width="100%", thickness=0.6, + color=colors.HexColor("#bbbbbb"), spaceBefore=4, spaceAfter=4)) + i += 1 + continue + if s.startswith("# "): + flows.append(Paragraph(_inline(s[2:].strip()), st["h1"])); i += 1; continue + if s.startswith("### "): + flows.append(Paragraph(_inline(s[4:].strip()), + ParagraphStyle("h3", parent=st["body"], fontName=BOLD_FONT, + textColor=colors.HexColor("#0b5394"), spaceBefore=6))) + i += 1; continue + if s.startswith("> "): + blk = [] + while i < n and lines[i].strip().startswith(">"): + blk.append(lines[i].strip()[1:].strip()); i += 1 + flows.append(Paragraph("
".join(_inline(x) for x in blk), st["quote"])); continue + if re.match(r"^[ \s]*[A-H][..、]", s): + flows.append(Paragraph(_inline(s), st["opt"])); i += 1; continue + flows.append(Paragraph(_inline(s), st["body"])); i += 1 + return flows + + +def markdown_to_pdf(md: str, pdf_path: Path, fonts_dir: Path | None = None) -> Path: + _register_fonts(fonts_dir) + doc = SimpleDocTemplate(str(pdf_path), pagesize=A4, + leftMargin=20 * mm, rightMargin=20 * mm, + topMargin=18 * mm, bottomMargin=18 * mm, + title=Path(pdf_path).name) + doc.build(_parse(md, _styles())) + return Path(pdf_path) diff --git a/tests/test_render_paper.py b/tests/test_render_paper.py new file mode 100644 index 0000000..f15a119 --- /dev/null +++ b/tests/test_render_paper.py @@ -0,0 +1,28 @@ +from studylib.render_paper import manifest_to_markdown + +M = { + "meta": {"course_name": "毛中特", "mode": "syllabus", "count": 1, + "generated_at": "2026-07-20T00:00:00+08:00"}, + "questions": [{"question_id": "q1", "kc_labels": ["k(名)"], + "stem": "活的灵魂三方面是( )\nA.实事求是\nB.群众路线", + "answer": "A", "solution": "实事求是是根本观点。"}], +} + + +def test_questions_variant_has_no_answer(): + md = manifest_to_markdown(M, "questions") + assert "活的灵魂三方面是" in md and "A.实事求是" in md + assert "答案" not in md and "实事求是是根本观点" not in md + + +def test_answers_variant_has_answer_and_solution(): + md = manifest_to_markdown(M, "answers") + assert "答案:A" in md and "实事求是是根本观点" in md + + +def test_markdown_to_pdf_produces_file(tmp_path): + from studylib.render_paper import manifest_to_markdown, markdown_to_pdf + md = manifest_to_markdown(M, "answers") + pdf = tmp_path / "out.pdf" + markdown_to_pdf(md, pdf, fonts_dir=None) # CID fallback, no font files needed + assert pdf.exists() and pdf.stat().st_size > 0 From 5c2dd1e74df2903afc5bce7214fa3eb82c984da8 Mon Sep 17 00:00:00 2001 From: TD_Xu Date: Mon, 20 Jul 2026 21:57:03 +0800 Subject: [PATCH 09/14] feat(drill): select_kcs (syllabus weighted / diagnostic adaptive) Implements weighted sampling without replacement via the Efraimidis-Spirakis keyed method (key = u**(1/w), take top-k): - syllabus mode weights by exam_weight - diagnostic mode weights by WEAKNESS_SCORE when any KC has a learning record, falling back to exam_weight when all KCs are unseen - result is deterministic given seed; count is capped to available KCs; unknown modes raise ValueError Co-Authored-By: Claude --- scripts/studylib/drill.py | 37 ++++++++++++++++++++++++++++ tests/test_drill_select.py | 50 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 scripts/studylib/drill.py create mode 100644 tests/test_drill_select.py diff --git a/scripts/studylib/drill.py b/scripts/studylib/drill.py new file mode 100644 index 0000000..815984c --- /dev/null +++ b/scripts/studylib/drill.py @@ -0,0 +1,37 @@ +from __future__ import annotations + +import random + +from .nextstep import WEAKNESS_SCORE + + +def _weighted_sample_no_replace( + rng: random.Random, items: list[str], weights: list[float], k: int +) -> list[str]: + if k >= len(items): + return list(items) + # Efraimidis–Spirakis: key = u**(1/w); take the k largest keys. + pairs = [] + for it, w in zip(items, weights): + ww = w if w and w > 0 else 1e-9 + pairs.append((rng.random() ** (1.0 / ww), it)) + pairs.sort(key=lambda p: p[0], reverse=True) + return [it for _, it in pairs[:k]] + + +def select_kcs(kc_states: dict[str, dict], mode: str, count: int, seed: int = 0) -> list[str]: + if mode not in ("syllabus", "diagnostic"): + raise ValueError(f"unknown mode: {mode}") + ids = list(kc_states) + if not ids or count <= 0: + return [] + rng = random.Random(seed) + if mode == "syllabus": + weights = [kc_states[i].get("exam_weight", 0.5) for i in ids] + else: # diagnostic, adaptive + has_record = any(kc_states[i].get("teaching_state") != "unseen" for i in ids) + if has_record: + weights = [WEAKNESS_SCORE.get(kc_states[i].get("teaching_state"), 0.0) for i in ids] + else: + weights = [kc_states[i].get("exam_weight", 0.5) for i in ids] + return _weighted_sample_no_replace(rng, ids, weights, min(count, len(ids))) diff --git a/tests/test_drill_select.py b/tests/test_drill_select.py new file mode 100644 index 0000000..4528e45 --- /dev/null +++ b/tests/test_drill_select.py @@ -0,0 +1,50 @@ +import pytest + +from studylib.nextstep import WEAKNESS_SCORE + + +def _kc(state, weight=0.5): + return {"kc_id": state, "name": state, "teaching_state": state, "exam_weight": weight, + "prerequisites": [], "transfer": {}, "calibration": {}, "retention": {}} + + +def test_empty_and_zero_count(): + from studylib.drill import select_kcs + assert select_kcs({}, "syllabus", 5) == [] + assert select_kcs({"a": _kc("unseen")}, "syllabus", 0) == [] + + +def test_count_capped_to_available(): + from studylib.drill import select_kcs + kcs = {"a": _kc("unseen"), "b": _kc("unseen")} + assert set(select_kcs(kcs, "syllabus", 10, seed=1)) == {"a", "b"} + + +def test_deterministic_with_seed(): + from studylib.drill import select_kcs + kcs = {f"k{i}": _kc("unseen", weight=(i + 1) / 5) for i in range(6)} + r1 = select_kcs(kcs, "syllabus", 3, seed=42) + r2 = select_kcs(kcs, "syllabus", 3, seed=42) + assert r1 == r2 and len(r1) == 3 + + +def test_unknown_mode_raises(): + from studylib.drill import select_kcs + with pytest.raises(ValueError): + select_kcs({"a": _kc("unseen")}, "bogus", 1) + + +def test_diagnostic_prefers_weak_when_record_exists(): + from studylib.drill import select_kcs + kcs = {"weak": _kc("weak"), "conf": _kc("confirmed"), "unseen": _kc("unseen")} + # weak has the highest weakness weight; with count covering all, weak is always in + chosen = select_kcs(kcs, "diagnostic", 1, seed=0) + assert chosen == ["weak"] + + +def test_diagnostic_falls_back_when_all_unseen(): + from studylib.drill import select_kcs + kcs = {"a": _kc("unseen", weight=0.9), "b": _kc("unseen", weight=0.1)} + # all unseen -> behaves like syllabus; high-weight 'a' strongly preferred + chosen = select_kcs(kcs, "diagnostic", 1, seed=0) + assert chosen == ["a"] From f4bf306b09880706cc2e15bd07f5716f5e69b615 Mon Sep 17 00:00:00 2001 From: TD_Xu Date: Mon, 20 Jul 2026 22:02:09 +0800 Subject: [PATCH 10/14] test(drill): assert diagnostic weakness-preference as a distribution, not certainty MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the pinned seed=0 assertion (which claimed select_kcs returns ["weak"] with certainty) with a multi-seed distribution test. The Efraimidis-Spirakis sampler only guarantees higher probability for higher weights, not certainty — the old test was deterministic but dishonest. The new test sweeps 400 seeds and asserts the frequency ordering weak > unseen > confirmed that the weights (1.0/0.4/0.0) actually imply, plus a >200 majority threshold for weak (observed 274, theoretical ~284). Also drop the unused `from studylib.nextstep import WEAKNESS_SCORE` module-level import. Production code (scripts/studylib/drill.py) untouched — test-honesty fix only. Co-Authored-By: Claude --- tests/test_drill_select.py | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/tests/test_drill_select.py b/tests/test_drill_select.py index 4528e45..83c935d 100644 --- a/tests/test_drill_select.py +++ b/tests/test_drill_select.py @@ -1,7 +1,5 @@ import pytest -from studylib.nextstep import WEAKNESS_SCORE - def _kc(state, weight=0.5): return {"kc_id": state, "name": state, "teaching_state": state, "exam_weight": weight, @@ -34,12 +32,17 @@ def test_unknown_mode_raises(): select_kcs({"a": _kc("unseen")}, "bogus", 1) -def test_diagnostic_prefers_weak_when_record_exists(): +def test_diagnostic_prefers_weak_in_distribution(): + # ES gives higher probability to higher weights, not certainty. Sample many + # seeds and assert the frequency ordering the weights imply. + from collections import Counter from studylib.drill import select_kcs kcs = {"weak": _kc("weak"), "conf": _kc("confirmed"), "unseen": _kc("unseen")} - # weak has the highest weakness weight; with count covering all, weak is always in - chosen = select_kcs(kcs, "diagnostic", 1, seed=0) - assert chosen == ["weak"] + picks = Counter(select_kcs(kcs, "diagnostic", 1, seed=s)[0] for s in range(400)) + # weak (weight 1.0) selected most; unseen (0.4) next; confirmed (0.0) ~never + assert picks["weak"] > picks["unseen"] > picks.get("confirmed", 0) + # weak should win a clear majority (theoretical ~71%); >50% with large margin + assert picks["weak"] > 200 def test_diagnostic_falls_back_when_all_unseen(): From 8be4f9b2b5a6651b4f27ec04e378d07ec0d8ea38 Mon Sep 17 00:00:00 2001 From: TD_Xu Date: Mon, 20 Jul 2026 22:05:10 +0800 Subject: [PATCH 11/14] feat(drill): gather_questions with shortfall detection Co-Authored-By: Claude --- scripts/studylib/drill.py | 26 ++++++++++++++++++++++++++ tests/test_drill_gather.py | 30 ++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+) create mode 100644 tests/test_drill_gather.py diff --git a/scripts/studylib/drill.py b/scripts/studylib/drill.py index 815984c..e2ddc7a 100644 --- a/scripts/studylib/drill.py +++ b/scripts/studylib/drill.py @@ -35,3 +35,29 @@ def select_kcs(kc_states: dict[str, dict], mode: str, count: int, seed: int = 0) else: weights = [kc_states[i].get("exam_weight", 0.5) for i in ids] return _weighted_sample_no_replace(rng, ids, weights, min(count, len(ids))) + + +def gather_questions( + questions: dict, kc_ids: list[str], per_kc: int = 2, total: int | None = None +) -> tuple[list[dict], dict[str, int]]: + total = total if total is not None else per_kc * max(1, len(kc_ids)) + by_kc: dict[str, list[dict]] = {k: [] for k in kc_ids} + for q in questions.values(): + for k in q.get("kc_ids", []): + if k in by_kc and len(by_kc[k]) < per_kc: + by_kc[k].append(q) + picked: list[dict] = [] + i = 0 + while len(picked) < total: + progressed = False + for k in kc_ids: + if i < len(by_kc[k]): + picked.append(by_kc[k][i]) + progressed = True + if len(picked) >= total: + break + if not progressed: + break + i += 1 + shortfall = {k: per_kc - len(by_kc[k]) for k in kc_ids if len(by_kc[k]) < per_kc} + return picked, shortfall diff --git a/tests/test_drill_gather.py b/tests/test_drill_gather.py new file mode 100644 index 0000000..65fb519 --- /dev/null +++ b/tests/test_drill_gather.py @@ -0,0 +1,30 @@ +def _q(qid, kcs): + return {"question_id": qid, "kc_ids": kcs, "stem": "", "answer": "A"} + + +def test_enough_questions_no_shortfall(): + from studylib.drill import gather_questions + questions = { + "a1": _q("a1", ["k1"]), "a2": _q("a2", ["k1"]), "a3": _q("a3", ["k1"]), + "b1": _q("b1", ["k2"]), "b2": _q("b2", ["k2"]), + } + picked, short = gather_questions(questions, ["k1", "k2"], per_kc=2, total=4) + assert len(picked) == 4 + assert short == {} + + +def test_shortfall_when_kc_has_no_questions(): + from studylib.drill import gather_questions + questions = {"a1": _q("a1", ["k1"]), "a2": _q("a2", ["k1"])} + picked, short = gather_questions(questions, ["k1", "k2"], per_kc=2, total=4) + assert short == {"k2": 2} + assert all(q["kc_ids"] == ["k1"] for q in picked) + + +def test_total_cap_respected(): + from studylib.drill import gather_questions + questions = {f"a{i}": _q(f"a{i}", ["k1"]) for i in range(5)} + questions.update({f"b{i}": _q(f"b{i}", ["k2"]) for i in range(5)}) + picked, short = gather_questions(questions, ["k1", "k2"], per_kc=2, total=2) + assert len(picked) == 2 + assert short == {} From 5dd9e32b1d0180ee21ce358ccc901ff615e9331e Mon Sep 17 00:00:00 2001 From: TD_Xu Date: Mon, 20 Jul 2026 22:10:29 +0800 Subject: [PATCH 12/14] fix(drill): dedup questions tagged with multiple KCs in gather_questions A question tagged with N selected KCs was placed into N by_kc buckets, then emitted once per bucket by the round-robin, producing duplicate stems in the drill output. Track seen question_ids during round-robin and skip already-picked questions while still advancing the slot. Co-Authored-By: Claude --- scripts/studylib/drill.py | 11 ++++++++--- tests/test_drill_gather.py | 13 +++++++++++++ 2 files changed, 21 insertions(+), 3 deletions(-) diff --git a/scripts/studylib/drill.py b/scripts/studylib/drill.py index e2ddc7a..b6168e8 100644 --- a/scripts/studylib/drill.py +++ b/scripts/studylib/drill.py @@ -47,15 +47,20 @@ def gather_questions( if k in by_kc and len(by_kc[k]) < per_kc: by_kc[k].append(q) picked: list[dict] = [] + seen: set = set() i = 0 while len(picked) < total: progressed = False for k in kc_ids: if i < len(by_kc[k]): - picked.append(by_kc[k][i]) progressed = True - if len(picked) >= total: - break + q = by_kc[k][i] + qid = q.get("question_id") + if qid not in seen: + seen.add(qid) + picked.append(q) + if len(picked) >= total: + break if not progressed: break i += 1 diff --git a/tests/test_drill_gather.py b/tests/test_drill_gather.py index 65fb519..d2edf7e 100644 --- a/tests/test_drill_gather.py +++ b/tests/test_drill_gather.py @@ -28,3 +28,16 @@ def test_total_cap_respected(): picked, short = gather_questions(questions, ["k1", "k2"], per_kc=2, total=2) assert len(picked) == 2 assert short == {} + + +def test_multi_kc_question_not_duplicated(): + from studylib.drill import gather_questions + questions = { + "q1": _q("q1", ["k1", "k2"]), + "q2": _q("q2", ["k1"]), + "q3": _q("q3", ["k2"]), + } + picked, short = gather_questions(questions, ["k1", "k2"], per_kc=2, total=4) + ids = [q["question_id"] for q in picked] + assert len(ids) == len(set(ids)), f"duplicate questions: {ids}" + assert set(ids) == {"q1", "q2", "q3"} From a79ea2dd8ca51e6280ebac7998eea0183ed48009 Mon Sep 17 00:00:00 2001 From: TD_Xu Date: Mon, 20 Jul 2026 22:19:19 +0800 Subject: [PATCH 13/14] feat(drill): one-command drill (mode+count) wiring renderers; SKILL routing Co-Authored-By: Claude --- CHANGELOG.md | 7 ++++ SKILL.md | 1 + scripts/drill.py | 88 +++++++++++++++++++++++++++++++++++++++++ tests/test_cli_smoke.py | 23 +++++++++++ 4 files changed, 119 insertions(+) create mode 100644 scripts/drill.py diff --git a/CHANGELOG.md b/CHANGELOG.md index e0ac316..be1208c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,13 @@ ## [Unreleased] +### 2026-07-20 — `feat` — F2 学习模式引擎 + 一站式 drill 命令 +- 新增 `studylib.drill`:`select_kcs`(考纲加权 / 诊断自适应,seed 确定性)、`gather_questions`(凑题 + 缺口检测)。 +- 新增 CLI `scripts/drill.py`:选题→凑题→manifest→渲染(html/paper/md),打印 KC 标签、缺口、下一步建议。 +- `SKILL.md` 路由表新增「刷题/出题/模拟卷」意图(先问模式/题量/形态再出)。 + +## [V2.0-rc1] - 2026-07-20 + ### 2026-07-20 — `feat` — F3 选择题输出(网页 / PDF 试卷) - 新增 `studylib.manifest`(drill manifest 契约)、`studylib.render_html` + `templates/quiz.html.j2`(自包含交互测验页,运行时解析开关)、`studylib.render_paper`(manifest→PDF,支持题目卷/答案解析卷,CID 字体回退免装字体)。 - 新增 CLI:`scripts/render_quiz_html.py`、`scripts/render_paper.py`;移除 `scripts/md_to_pdf.py`(其能力并入 render_paper)。 diff --git a/SKILL.md b/SKILL.md index ed04f29..253d07f 100644 --- a/SKILL.md +++ b/SKILL.md @@ -29,6 +29,7 @@ description: 面向大学课程的本地优先持续学习 Agent。当用户说 | 新课程 | `python3 scripts/init_course.py <目录> --course-id .. --name .. --exam-date ..`,然后逐个 `event.py kc-add` 注册骨架(考纲优先),`event.py source-add` 登记来源 | references/provenance.md | | 讲解教学 | 当帧教学;讲完 `event.py kc-explained --kc-id ..` | references/evidence-graph.md | | 做题/刷题 | 出示题目 → 先问置信度(猜的/不太确定/比较确定/非常确定 → 0.25/0.5/0.75/1.0)→ 学生作答 → `event.py attempt --question-id .. --correct|--wrong --confidence .. [--hint-level ..] [--transfer] [--retest-of ..]` | references/hint-ladder.md | +| 刷题/出题/模拟卷 | 先问学生三件事:①模式(考纲直出 / 诊断先行)②题量(5/10/自定义)③形态(网页可点击 / PDF 试卷)→ `drill.py --mode .. --count .. --format ..`。网页版默认开启「点击即显示解析」,学生可在页面内随时关。 | references/(见 specs/2026-07-20-...) | | 学生答错 | 三步归因(错误假设/缺失前提/错因类型)→ `event.py misconception ...` → 按错因选修复策略 → `event.py repair-start/repair-done` → 双轨重测(原题二刷 + 迁移题) | references/misconception-memory.md | | 生成迁移题 | 按 agents/question-generator.md 出题 → agents/independent-solver.md 盲解 → agents/adversarial-reviewer.md 审查 → 组装 validation 块 → `validate_question.py cand.json --as-transfer-test` | references/transfer-ladder.md, references/question-validation.md | | 复习到期卡 | `fsrs.py due` → 逐卡提问 → `fsrs.py review --card-id .. --rating 1..4`(评分策略见 references/fsrs-policy.md) | references/fsrs-policy.md | diff --git a/scripts/drill.py b/scripts/drill.py new file mode 100644 index 0000000..d5df457 --- /dev/null +++ b/scripts/drill.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) + +import typer + +from studylib import derive as derive_mod +from studylib.cli_common import guard, resolve_root +from studylib.course import load_course +from studylib.display import kc_label +from studylib.drill import gather_questions, select_kcs +from studylib.ioutils import atomic_write_json, atomic_write_text, course_lock +from studylib.manifest import build_manifest +from studylib.render_html import render_quiz_html +from studylib.render_paper import manifest_to_markdown, markdown_to_pdf + +app = typer.Typer(add_completion=False) + +NEXT_STEP_HINT = { + "syllabus": "做完后对命中的同知识点做复盘重测(迁移题),验证不是背题。", + "diagnostic": "据作答结果,对命中的弱知识点针对性出题 / 修复错因。", +} + + +@app.command() +@guard +def main( + mode: str = typer.Option(..., "--mode"), + count: int = typer.Option(10, "--count"), + per_kc: int = typer.Option(2, "--per-kc"), + fmt: str = typer.Option("html", "--format"), + out: Path = typer.Option(None, "--out"), + reveal_default: str = typer.Option("on", "--reveal-default"), + seed: int = typer.Option(0, "--seed"), + fonts_dir: Path = typer.Option(None, "--fonts-dir"), + course: Path = typer.Option(None, "--course"), +): + if mode not in ("syllabus", "diagnostic"): + typer.echo("错误:--mode 必须是 syllabus 或 diagnostic", err=True) + raise typer.Exit(code=1) + if fmt not in ("html", "paper", "md"): + typer.echo("错误:--format 必须是 html / paper / md", err=True) + raise typer.Exit(code=1) + + root = resolve_root(course) + with course_lock(root): + result = derive_mod.derive(root) + kc_states = result["kc"] + questions = result["questions"] + course_doc = load_course(root) + + selected = select_kcs(kc_states, mode, count, seed) + picked, shortfall = gather_questions(questions, selected, per_kc=per_kc, total=count) + manifest = build_manifest(course_doc, mode, count, picked, kcs=kc_states) + + out = out or (root / "output" / f"drill-{mode}-{count}") + out.parent.mkdir(parents=True, exist_ok=True) + + if fmt == "html": + html_path = out.with_suffix(".html") + atomic_write_text(html_path, render_quiz_html(manifest, reveal_default == "on")) + typer.echo(f"已生成交互测验页:{html_path}") + elif fmt == "paper": + for v, tag in (("questions", "题目"), ("answers", "答案解析")): + pdf = out.parent / f"{out.name}-{tag}.pdf" + markdown_to_pdf(manifest_to_markdown(manifest, v), pdf, fonts_dir=fonts_dir) + typer.echo(f"已生成 PDF:{pdf}") + else: # md + md_path = out.with_suffix(".md") + atomic_write_text(md_path, manifest_to_markdown(manifest, "answers")) + typer.echo(f"已生成 Markdown:{md_path}") + atomic_write_json(out.with_suffix(".manifest.json"), manifest) + + typer.echo("\n选题(按权重):") + for kc_id in selected: + typer.echo(f" - {kc_label(kc_id, kc_states)}") + typer.echo(f"实际凑题:{len(picked)} 题(目标 {count})。") + if shortfall: + typer.echo("⚠️ 题量不足(注册表缺题,未自动走 AI 出题闸门):") + for k, miss in shortfall.items(): + typer.echo(f" - {kc_label(k, kc_states)}:缺 {miss} 题") + typer.echo(f"\n下一步建议:{NEXT_STEP_HINT[mode]}") + + +if __name__ == "__main__": + app() diff --git a/tests/test_cli_smoke.py b/tests/test_cli_smoke.py index a0abc16..3066431 100644 --- a/tests/test_cli_smoke.py +++ b/tests/test_cli_smoke.py @@ -118,3 +118,26 @@ def test_render_quiz_html_cli(tmp_path): assert r.returncode == 0, r.stderr text = out.read_text(encoding="utf-8") assert "id=\"revealToggle\"" in text and "模拟电子技术" in text + + +def test_drill_cli_produces_html(tmp_path): + home = tmp_path / "home" + course_dir = tmp_path / "模电" + assert run([SCRIPTS / "init_course.py", str(course_dir), "--course-id", "analog", + "--name", "模拟电子技术"], tmp_path, home).returncode == 0 + assert run([SCRIPTS / "event.py", "kc-add", "--kc-id", "feedback_topology", + "--name", "反馈组态判断", "--exam-weight", "0.9"], course_dir, home).returncode == 0 + # register two MCQs + for qid, ans in (("q1", "A"), ("q2", "B")): + (course_dir / f"{qid}.json").write_text(json.dumps({ + "question_id": qid, "kc_ids": ["feedback_topology"], "source_type": "past_exam", + "transfer_level": "T0", "stem": f"题 {qid}\nA.x\nB.y", "answer": ans, + }, ensure_ascii=False), encoding="utf-8") + assert run([SCRIPTS / "validate_question.py", str(course_dir / f"{qid}.json")], + course_dir, home).returncode == 0 + out_html = course_dir / "drill.html" + r = run([SCRIPTS / "drill.py", "--mode", "syllabus", "--count", "2", "--format", "html", + "--out", str(out_html), "--seed", "1"], course_dir, home) + assert r.returncode == 0, r.stderr + assert "feedback_topology(反馈组态判断)" in r.stdout # summary uses labels + assert out_html.exists() and "id=\"revealToggle\"" in out_html.read_text(encoding="utf-8") From ca4e35c19b102e87e56c177ae68afb98f6ad4ae4 Mon Sep 17 00:00:00 2001 From: TD_Xu Date: Mon, 20 Jul 2026 22:33:16 +0800 Subject: [PATCH 14/14] fix(output): HTML-escape quiz content + final-review cleanups MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C1: render_quiz_html now uses Environment(autoescape=True); user-controlled stem/option/solution text can no longer inject raw HTML. Verified by test_user_text_is_html_escaped. Minors: - test: 2-question distinct radio groups (name="q1"/name="q2") - render_paper: drop unused Spacer/Table/TableStyle/PageBreak imports - test: assert %PDF- magic bytes in PDF smoke test - drill: simplify w > 0 guard in _weighted_sample_no_replace - drill: clarify select_kcs diagnostic comment - drill CLI: mode-aware summary header (按考纲权重 / 按弱点(自适应)) Co-Authored-By: Claude --- scripts/drill.py | 4 +++- scripts/studylib/drill.py | 4 ++-- scripts/studylib/render_html.py | 4 ++-- scripts/studylib/render_paper.py | 3 +-- tests/test_render_html.py | 27 +++++++++++++++++++++++++++ tests/test_render_paper.py | 1 + 6 files changed, 36 insertions(+), 7 deletions(-) diff --git a/scripts/drill.py b/scripts/drill.py index d5df457..d5b3db4 100644 --- a/scripts/drill.py +++ b/scripts/drill.py @@ -23,6 +23,8 @@ "diagnostic": "据作答结果,对命中的弱知识点针对性出题 / 修复错因。", } +SELECT_LABEL = {"syllabus": "按考纲权重", "diagnostic": "按弱点(自适应)"} + @app.command() @guard @@ -73,7 +75,7 @@ def main( typer.echo(f"已生成 Markdown:{md_path}") atomic_write_json(out.with_suffix(".manifest.json"), manifest) - typer.echo("\n选题(按权重):") + typer.echo(f"\n选题({SELECT_LABEL[mode]}):") for kc_id in selected: typer.echo(f" - {kc_label(kc_id, kc_states)}") typer.echo(f"实际凑题:{len(picked)} 题(目标 {count})。") diff --git a/scripts/studylib/drill.py b/scripts/studylib/drill.py index b6168e8..1515fdc 100644 --- a/scripts/studylib/drill.py +++ b/scripts/studylib/drill.py @@ -13,7 +13,7 @@ def _weighted_sample_no_replace( # Efraimidis–Spirakis: key = u**(1/w); take the k largest keys. pairs = [] for it, w in zip(items, weights): - ww = w if w and w > 0 else 1e-9 + ww = w if w > 0 else 1e-9 pairs.append((rng.random() ** (1.0 / ww), it)) pairs.sort(key=lambda p: p[0], reverse=True) return [it for _, it in pairs[:k]] @@ -28,7 +28,7 @@ def select_kcs(kc_states: dict[str, dict], mode: str, count: int, seed: int = 0) rng = random.Random(seed) if mode == "syllabus": weights = [kc_states[i].get("exam_weight", 0.5) for i in ids] - else: # diagnostic, adaptive + else: # diagnostic (adaptive: prefers weak KCs when any learning record exists) has_record = any(kc_states[i].get("teaching_state") != "unseen" for i in ids) if has_record: weights = [WEAKNESS_SCORE.get(kc_states[i].get("teaching_state"), 0.0) for i in ids] diff --git a/scripts/studylib/render_html.py b/scripts/studylib/render_html.py index 8ca8b9a..ec6ed2a 100644 --- a/scripts/studylib/render_html.py +++ b/scripts/studylib/render_html.py @@ -4,7 +4,7 @@ import unicodedata from pathlib import Path -from jinja2 import Template +from jinja2 import Environment TEMPLATE_PATH = Path(__file__).resolve().parents[2] / "templates" / "quiz.html.j2" @@ -45,7 +45,7 @@ def _question_for_render(q: dict) -> dict: def render_quiz_html(manifest: dict, reveal_default: bool = True) -> str: - tpl = Template(TEMPLATE_PATH.read_text(encoding="utf-8")) + tpl = Environment(autoescape=True).from_string(TEMPLATE_PATH.read_text(encoding="utf-8")) view = { "meta": manifest["meta"], "questions": [_question_for_render(q) for q in manifest["questions"]], diff --git a/scripts/studylib/render_paper.py b/scripts/studylib/render_paper.py index 85e67a6..c9a8d19 100644 --- a/scripts/studylib/render_paper.py +++ b/scripts/studylib/render_paper.py @@ -12,8 +12,7 @@ from reportlab.pdfbase.cidfonts import UnicodeCIDFont from reportlab.pdfbase.pdfmetrics import registerFontFamily from reportlab.pdfbase.ttfonts import TTFont -from reportlab.platypus import (HRFlowable, PageBreak, Paragraph, SimpleDocTemplate, - Spacer, Table, TableStyle) +from reportlab.platypus import HRFlowable, Paragraph, SimpleDocTemplate BODY_FONT = "Body" BOLD_FONT = "HeitiM" diff --git a/tests/test_render_html.py b/tests/test_render_html.py index 05fc869..dac25bb 100644 --- a/tests/test_render_html.py +++ b/tests/test_render_html.py @@ -67,3 +67,30 @@ def test_lowercase_answer_normalized_for_grading(): # grading attribute uses uppercase; displayed answer is uppercase too assert 'data-answer="A,B,C"' in html assert "答案:ABC" in html + + +def test_user_text_is_html_escaped(): + m = { + "meta": {"course_name": "C", "mode": "syllabus", "count": 1, + "generated_at": "2026-07-20T00:00:00+08:00"}, + "questions": [{"kc_labels": ["k(名)"], "stem": "若 a < b 且 b > c\nA.x\nB.y", + "answer": "A", "solution": "解析:a & b 的关系"}], + } + html = render_quiz_html(m, reveal_default=True) + assert "<" in html and ">" in html and "&" in html + # literal user substring must NOT appear unescaped anywhere in the output + assert "a < b" not in html + assert "a < b" in html + + +def test_two_questions_have_distinct_radio_groups(): + m = { + "meta": {"course_name": "C", "mode": "syllabus", "count": 2, + "generated_at": "2026-07-20T00:00:00+08:00"}, + "questions": [ + {"kc_labels": [], "stem": "Q1\nA.x\nB.y", "answer": "A", "solution": ""}, + {"kc_labels": [], "stem": "Q2\nA.x\nB.y", "answer": "B", "solution": ""}, + ], + } + html = render_quiz_html(m, reveal_default=False) + assert 'name="q1"' in html and 'name="q2"' in html diff --git a/tests/test_render_paper.py b/tests/test_render_paper.py index f15a119..60cec0d 100644 --- a/tests/test_render_paper.py +++ b/tests/test_render_paper.py @@ -26,3 +26,4 @@ def test_markdown_to_pdf_produces_file(tmp_path): pdf = tmp_path / "out.pdf" markdown_to_pdf(md, pdf, fonts_dir=None) # CID fallback, no font files needed assert pdf.exists() and pdf.stat().st_size > 0 + assert pdf.read_bytes()[:5] == b"%PDF-"