Skip to content
Closed
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
16 changes: 16 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,22 @@

## [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)。
- `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`。

### 2026-07-20 — `chore` — 建立版本控制与变更纪律基线

- 将本地 git 纳入「每次修改可追踪」的工作流:新增本文件 `CHANGELOG.md` 与 `CLAUDE.md`。
Expand Down
2 changes: 2 additions & 0 deletions SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ description: 面向大学课程的本地优先持续学习 Agent。当用户说
3. 听懂≠掌握:升级 checked/confirmed 的规则由脚本执行,你不得口头宣布掌握。
4. AI 生成题必须走 agents/ 三卡流程 + `validate_question.py` 闸门,通过才存在。
5. 原题(真题/课后题)优先于 AI 生成题,且必须进 FSRS。
6. 面向用户提到知识点时,一律用 `kc_id(中文名)` 形式(脚本已自动生成,你照着念)。

## 会话开场(/study 默认行为)

Expand All @@ -28,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 |
Expand Down
1 change: 1 addition & 0 deletions requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ jinja2>=3.1
filelock>=3.12
pyyaml>=6.0
pytest>=8.0
reportlab>=4.0
90 changes: 90 additions & 0 deletions scripts/drill.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
#!/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": "据作答结果,对命中的弱知识点针对性出题 / 修复错因。",
}

SELECT_LABEL = {"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(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})。")
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()
7 changes: 5 additions & 2 deletions scripts/evidence.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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")
Expand Down
108 changes: 0 additions & 108 deletions scripts/md_to_pdf.py

This file was deleted.

7 changes: 5 additions & 2 deletions scripts/misconception.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand All @@ -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', '')}")


Expand Down
44 changes: 44 additions & 0 deletions scripts/render_paper.py
Original file line number Diff line number Diff line change
@@ -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()
34 changes: 34 additions & 0 deletions scripts/render_quiz_html.py
Original file line number Diff line number Diff line change
@@ -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()
2 changes: 1 addition & 1 deletion scripts/studylib/cli_common.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]:
Expand Down
Loading