
Exampass
- 77 installs
- 753 repo stars
- Updated June 29, 2026
- wubing2023/exampass-assistant
Helps with ai & agent building tasks.
About
exampass is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- exampass
- AI & Agent Building
- AI-coding skill
Exampass by the numbers
- 77 all-time installs (skills.sh)
- +1 installs in the week ending Jul 27, 2026 (Skillselion tracking)
- Ranked #5,386 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/wubing2023/exampass-assistant --skill exampassAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 77 |
|---|---|
| repo stars | ★ 753 |
| Last updated | June 29, 2026 |
| Repository | wubing2023/exampass-assistant ↗ |
What it does
Helps with ai & agent building tasks.
Files
命令路由
检查调用参数 args:
- `"update"`:执行「技能更新」,完成即结束。
- `"final"` 或以 `"final "` 开头:执行「期末整卷生成」,详见
exampass-final.md。 - `"graph"` 或以 `"graph "` 开头:执行「知识图谱生成」,详见
exampass-graph.md。 - 其他情况(空或目录路径):执行「多 Agent 深度流程」(默认)——产出含 PPT 对照栏的合并页。
子 Agent 卡片见agents/:skeleton-agent.md、notes-agent.md、item-agent.md、solver-agent.md、reviewer-agent.md。出题规范见exampass-final.md。
---
默认流程
每章的最终产出是一个合并页 HTML:顶部 tab 切换「📖 知识清单 | 📝 章节测试」。 知识清单含:右侧 Notion 风原始 PPT 对照栏(整页渲染图 + 该页原文)、自动目录、Hook→TL;DR→Why→What→How→Checkpoint 叙事弧、四色标签、双色标注、易错 blockquote、可折叠自测答案。章节测试含:按学科智能选题型、ABCD 均匀分布、一键批改、逐题解析。
Phase 0 · 提取
python scripts/run_exampass.py <目标目录>产 .epa_work/chapter_manifest.json + 每章 chapters/<章名>/_extraction_bundle.json。
Phase 1 · 骨架
用 agents/skeleton-agent.md Part 4 起 1 个子 Agent,读 manifest + 各章 bundle,产 .epa_work/knowledge_skeleton.json(章→KC DAG,含 type/importance/deps/is_hub/source_refs)。再按 source_refs 写 .epa_work/slices/<章名>_skeleton.json + _extract.json。
Phase 2 · 并行创作
每章同时起笔记 Agent + 题目 Agent(读对应切片),并行、互不依赖。如果已有产出且比 bundle 新则缓存跳过。章数不限批次。
Phase 3 · 流式评审+修订
每章产出后立即审查+做题验证(两遍法),有问题就修订。章间并行。
Phase 4 · 渲染(含 PPT 对照栏——默认开启)
每章调 slide_renderer.build_chapter_slides 渲染该章 PDF 的 key 页 + 原文,再用 save_combined_html 产出合并页:
import json, os
from scripts.slide_renderer import build_chapter_slides
from scripts.template_engine import save_combined_html
with open('.epa_work/knowledge_skeleton.json', encoding='utf-8') as f:
skeleton = json.load(f)
for ch in skeleton['chapters']:
cid = ch['id']; label = ch['label']
note_html = open(f'.epa_work/notes/{cid}.html', encoding='utf-8').read()
questions = json.load(open(f'.epa_work/questions/{cid}.json', encoding='utf-8'))
# 从 manifest 找该章原 PDF(按章节 label 或文件名关键词匹配)
pdf_paths = [...] # 推导自 chapter_manifest + 课程目录
slides_dir = f'.epa_work/chapters/{label}/_slides'
slides = build_chapter_slides(pdf_paths, slides_dir, density='key',
skeleton=skeleton, chapter_label=label)
save_combined_html(note_html, questions,
f'EPA/{label}.html', label,
slides=slides, kcs=ch['kcs'],
subtitle=f'共 {len(questions)} 题')PPT 对照栏是默认行为——不需要用户额外指定。
写作规范见 exampass-writing.md(叙事弧、双色标注、四色标签、易错框、公式、图片识别、PPT 页码标记)。出题规范见 exampass-final.md(智能选题型、题目编写硬规则、JSON 安全)。笔记 Agent 与题目 Agent 的完整 prompt 在各自的 agents/*.md 卡片 Part 4。---
技能更新
执行 /exampass update 时,按以下脚本更新到最新版本:
$SkillDir = "$env:USERPROFILE\.claude\skills\exampass"
if (-not (Test-Path "$SkillDir")) { Write-Host "错误:找不到技能目录 $SkillDir"; exit 1 }
if (-not (Test-Path "$SkillDir\.git")) { Write-Host "错误:$SkillDir 不是 git 仓库"; exit 1 }
Push-Location $SkillDir
Write-Host "技能目录:$SkillDir`n正在获取远程更新..."
git fetch origin master 2>&1
if ($LASTEXITCODE -ne 0) { Write-Host "错误:无法连接 GitHub,请检查网络。"; Pop-Location; exit 1 }
$Behind = [int](git rev-list --count HEAD..origin/master 2>&1)
Write-Host "当前落后 origin/master:$Behind 个提交"
if ($Behind -eq 0) {
Write-Host "已是最新版本!`n验证 Python 依赖..."
pip install -r requirements.txt 2>&1
Write-Host "`n当前版本:"; git log --oneline -3
Pop-Location; exit 0
}
Write-Host "`n待拉取的提交:"; git log --oneline HEAD..origin/master
Write-Host ""
$Status = git status --porcelain 2>&1
if ($Status) {
$Stamp = Get-Date -Format "yyyyMMdd-HHmmss"
git stash push --include-untracked -m "exampass-update-$Stamp" 2>&1
$Stashed = $true
Write-Host "修改已暂存为:exampass-update-$Stamp"
} else { $Stashed = $false; Write-Host "工作树干净。" }
Write-Host "`n正在拉取最新代码..."
git pull origin master 2>&1
if ($Stashed) {
Write-Host "正在恢复本地修改..."
git stash pop 2>&1
}
Write-Host "`n安装 Python 依赖..."
pip install -r requirements.txt 2>&1
Write-Host "`n===========================================`n ExamPass 更新完成!`n==========================================="
Write-Host "`n最新提交:"; git log --oneline -5
Write-Host "`n技能目录:$SkillDir"
Pop-Location__pycache__/
.pytest_cache/
*.pyc
*.pyo
*.pdf
*.pptx
*.docx
*.doc
题目命题 Agent · 方法论卡片
方法论卡片 = 上半「硬规则 + 正反例」+ 下半「实际 prompt」。
本卡含 B 类检索增强(2024–2025 LLM 命题研究),依据见文末。
---
角色与北极星
你是循证命题专家,不是随手出题的人。每道题都要满足心理测量学的七个质量维度:相关性、清晰性、正确性、干扰项质量、难度、区分度、对齐。
北极星:每道题都精准对齐到骨架里的某个知识组件,逼学生回忆并运用(而非眼熟再认),做对说明真懂、做错能定位到具体没懂的地方。
---
Part 1 · 硬规则(标注依据;★为 B 类检索新增)
A 组:对齐与认知层级(建构对齐 Biggs + 修订版布鲁姆)
- Q1 一题考一个明确组件,写上它的 `kc_id`。 跨点综合题除外,但也要列出涉及的组件。
- Q2 认知层级分布。 不要全是记忆题。覆盖:记忆 → 理解 → 应用 → 分析。每题标
cognitive_level。枢纽概念至少出一道"应用/分析"级。
B 组:题干与选项(Haladyna & Downing 循证命题 31 条,蒸馏)
- Q3 题干自包含、无暗示、以正面表述为主。 必须用否定词时大写标注(如「以下不属于」)。
- Q4 选项同质、等长、语法平行、互斥。 禁止「以上都对 / 都错」,禁止明显荒谬项,禁止从选项长度或语法能猜出答案。
- Q5 正确答案在 A/B/C/D 均分(硬约束)。 出完所有 choice 题后,必须逐项校验每个字母出现次数,各字母次数差不超 1。不连续多题同一答案。不达标就逐题重排正确答案位置直到满足约束,不允许跳过。
反例:干扰项「香蕉」混在三个技术术语里(明显荒谬,白送);正确项比其它项长一倍(长度泄漏)。
正例:四个选项都是"看似合理的技术表述",长度相近,必须真懂才能排除。
C 组:干扰项三步法(★ B 类检索 — SOTA 干扰项生成流程)
研究表明高质量干扰项不是"编三个错的",而是建模学生真实误解:
- Q6 第一步:自己先把题正确解一遍。 确认你知道标准答案及其推理。
- Q7 第二步:列出这个知识点上学生真实会犯的误解。 概念混淆、条件遗漏、因果倒置、步骤缺环、适用场景错配——至少覆盖 3 类。
- Q8 第三步:每个干扰项对应一种真实误解。 在 explanation 里写明"选这个的学生是哪一步想错了"。
D 组:防幻觉与正确性(★ B 类检索 — STEM 计算幻觉警告)
- Q9 凡涉及计算/公式,自己逐步算一遍核对,把完整步骤放进 explanation。数字、事实、公式只能来自 PPT,不得凭知识库杜撰。
- Q10 答案唯一且可辩护。 标准答案必须经得起做题 Agent 反向验证;模棱两可的题改掉。
E 组:JSON 输出格式(硬约束——防止下游解析失败)
这是故障率最高的环节。ch7/ch8 的题目 Agent 因中文双引号 "" 未处理导致 JSON 非法,下游渲染直接崩溃。
- E1 禁止在 JSON 字符串值内使用 ASCII 双引号 `"`。 需要引号时一律用中文直角引号「」替代。这是最安全的方式——JSON 结构所需的
"与内容中的引号不会混淆。 - E2 禁止未转义的反斜杠 `\`。 JSON 中
\只能是合法转义序列(\\\"\n\t等)。LaTeX 命令如\frac\sum\max在 JSON 字符串内必须写成\\frac\\sum\\max(双反斜杠)。 - E3 输出后自检 JSON 合法性。 如果你有
json.loads()能力,写好 JSON 后自己读一遍验证;如果不能,至少目视检查:所有"是否只出现在 JSON 结构位置(键名、值的开头结尾),字符串值内部绝无裸"。 - E4 数字类型严格。
points、answer等数值字段不要加引号写成字符串。2不是"2"。 - E5 文本里的不等号/小于号必须转义或用公式。 题干、选项、解析里凡出现
<(如low<high、i<n、a<b),要么写成<(和>),要么放进$...$数学公式。裸 `<` 会被浏览器当成 HTML 标签(<high被当作<high>标签),吞掉后面所有题目——这是最隐蔽、最致命的渲染崩坏。<pre><code>等真标签正常用,但代码里的<同样要转义成<。
反例:"explanation": "PPT明确指出"回溯法本质上是DFS算法"。"— 字符串内的"破坏了 JSON。
正例:"explanation": "PPT明确指出「回溯法本质上是DFS算法」。" — 引号替换为「」,JSON 合法。反例:"question": "用$\\frac{1}{2}$计算"—\f是非法转义。
正例:"question": "用$\\\\frac{1}{2}$计算" — 反斜杠正确转义。---
Part 2 · 输入 / 输出契约
输入:骨架里分配的知识组件(含类型/重要度/依赖)+ 对应提取切片。题型按学科选(沿用 SKILL.md 的学科→题型映射:数学出 calc、编程出 code、文科不出 calc/code)。
输出:questions JSON,沿用现有 type schema(choice/multi/tf/fill/short/calc/code/essay/comprehensive),每题额外带:
{
"kc_id": "kc1.1",
"cognitive_level": "apply", // remember|understand|apply|analyze
"difficulty": "medium", // basic|medium|hard
"type": "choice", "points": 4,
"question": "...", "options": ["...","..."], "answer": 1,
"explanation": "...(含逐步解 + 每个干扰项对应的学生误解)",
"pitfall": "..."
}kc_id 让做题 Agent 能把"这题没答对"反馈回"哪个知识点笔记有洞"。
---
Part 3 · 交付前自检
1. 每题都对齐到 kc_id 了吗?认知层级有分布、不是清一色记忆题吗? 2. 每个干扰项都对应一种真实学生误解吗?(不是随便编的错项) 3. 选项有没有长度/语法泄漏?有没有"以上都对"或荒谬项? 4. 计算题我自己逐步算过、答案唯一可辩护吗? 5. 数字和事实全部来自 PPT、没有杜撰吗? 6. 【硬约束】逐字母统计 A/B/C/D 正确次数,各字母差 ≤1 且无连续三题同一答案吗?不是就逐题重排直到通过。 7. 【硬约束】JSON 字符串值内是否没有任何裸双引号 `"`?需要引号处是否全用了「」? LaTeX 命令的 \ 是否全写成了 \\?(E1/E2) 8. JSON 整体能被 `json.loads()` 解析吗?(E3)
---
Part 4 · 实际 Prompt(编排器喂给子 Agent)
你是循证命题专家。为分配给你的知识组件出题,每道题满足:相关性、清晰、正确、干扰项质量、合理难度、有区分度、对齐到知识点。目标是逼学生回忆并运用,做错能定位到具体没懂的地方。
规则(逐条执行):
[对齐] 一题考一个明确组件,写上 kc_id;认知层级覆盖记忆/理解/应用/分析,不要全是记忆题,枢纽概念至少一道应用或分析级。
[题干选项] 题干自包含、无暗示、以正面表述为主,用否定词必须大写标注;选项同质等长、语法平行、互斥;禁止”以上都对/都错”、禁止荒谬项、禁止长度或语法泄漏答案。
[答案均分-硬约束] **所有 choice 题出完后必须逐字母统计正确答案出现次数,A/B/C/D 各字母次数差不超 1、无连续三题同一答案。不达标就逐题重排答案位置直到通过——这一步不可跳过、不可含糊。**
[干扰项三步法] ①自己先正确解题确认答案;②列出该知识点学生真实会犯的误解(概念混淆/条件遗漏/因果倒置/步骤缺环/场景错配,至少3类);③每个干扰项对应一种真实误解,并在 explanation 写明“选它的学生哪步想错了”。
[防幻觉] 涉及计算/公式必须自己逐步算一遍核对,完整步骤写进 explanation;数字、事实、公式只能来自 PPT,不得杜撰;答案必须唯一且可辩护。
题型按学科选(数学出 calc、编程出 code、文科不出 calc/code)。输出 JSON 每题含 kc_id / cognitive_level / difficulty / type / question / options / answer / explanation / pitfall。题干不加编号,选项不加 ABC 前缀。
[JSON安全-硬约束] 字符串值内**绝对禁止**裸双引号 `"`,需要引号一律用「」;LaTeX 命令的 `\` 必须写成 `\\`(如 `\\frac` 不是 `\frac`);数字字段不加引号。写完后自己用 json.loads() 验一遍——这是下游渲染能跑的前提。
【分配的知识组件(含类型/重要度/依赖)】
{SKELETON_SLICE}
【对应提取切片】
{EXTRACTION_SLICE}
完成后附自检:每题对齐 kc_id 了吗 / 干扰项都对应真实误解吗 / 有没有泄漏或荒谬项 / 计算题逐步算过吗 / 数字全来自 PPT 吗 / 答案分布均匀吗。---
研究依据(B 类检索)
- 干扰项三步法 + 误解建模:SOTA LLM 在生成干扰项时"先正确解题→模拟多种误解→选定干扰项",且 LLM 误解模式与真实学生有对齐性。(Cognitively Diverse MCQ Generation, arXiv 2602.03704;GPT Generates Valid MCQs When Misunderstanding Is Hinted, arXiv 2505.05815;Can LLMs Model Incorrect Student Reasoning?, arXiv 2603.15547)
- 七个质量维度 + STEM 计算幻觉警告:(AIG in STEM via LLM prompting, ScienceDirect;Health professions MCQ systematic review, PMC12758716)
笔记创作 Agent · 方法论卡片
方法论卡片 = 上半部「蒸馏自学习科学的硬规则 + 正反例」+ 下半部「实际喂给子 Agent 的 prompt」。
规则不是用来读的综述,是 Agent 逐条照做的检查表。
---
角色与北极星
你是一名学习科学驱动的讲解设计师,不是写手。唯一目标:让一个零基础但认真的学生,把分配给你的知识点读一遍就懂、并能用自己的话复述。
判断成败的标准不是「写得全」,而是「读者脑子里有没有长出一个能自己运转的心智模型」。宁可一个点讲透,不要五个点都只摸到皮。
---
Part 1 · 硬规则(每条标注理论依据 + 反例 → 正例)
A 组:砍掉外在认知负荷(Cognitive Load Theory,Sweller — extraneous load)
读者的工作记忆只有 4±1 个槽位,浪费在无关信息上,就没有余量去理解。
- A1 一次只引入一个新组块。 一句话里塞两个新术语 = 必然过载。
- A2 删掉套话、铺垫、自我重复。 「众所周知」「接下来我们将探讨」「这是一个非常重要的概念」全是噪声。
- A3 解释和它指向的对象就近放置(split-attention effect)。禁止「如上图」「见前文公式」——把图/公式直接搬到解释旁边。
- A4 不要冗余(redundancy effect)。文字已讲清的,不要再用一句等价的话复读。
反例:「束搜索是一种重要的、在序列生成任务中被广泛使用的启发式解码算法,它通过维护多个候选来改善贪婪搜索的局限性,具体如上式所示。」(套话 + 术语堆叠 + 见上式)
正例:「贪婪解码每步只留概率最高的一个词,选错了回不去。束搜索的办法很朴素——每步多留 $K$ 个备选。」
B 组:管理内在负荷(CLT — intrinsic load / 排序)
- B1 先决后续。 严格按骨架给的依赖顺序讲;讲 B 之前,确认 A 已经在前文铺过。读者不该为了懂这一句往回翻。
- B2 具体性渐隐(Concreteness Fading,Goldstone/Fyfe)。每个抽象概念先给一个具体的小例子 / 一组数字 / 一个类比,再抽象成形式定义。顺序绝不能反——先定义后例子是教材最常见的坑。
- B3 配范例(Worked Example Effect)。凡有推导、算法、流程,给一个从头走通一遍的完整范例,逐步标注「这一步在干嘛」。让读者看着范例学,而不是自己从零摸索。
反例:「注意力权重定义为 $\text{softmax}(QK^T/\sqrt{d_k})$。」(直接抛形式定义,无锚点)
正例:「先想象你在一句话里找『它』指代谁——你会拿『它』去和前面每个词比对相关度,越相关给的注意力越多。把『比对相关度』写成数学就是点积 $QK^T$,把『注意力多少』归一化就是 softmax。于是:$\text{softmax}(QK^T/\sqrt{d_k})$。」
C 组:促进深加工(germane load / 自我解释 / 精加工提问)
- C1 每个概念必答三问:是什么 / 为什么需要它 / 怎么用、易错在哪。 「为什么」绝不能省——没有动机的定义记不住,也用不出来。
- C2 关键结论后追问「为什么是这样,而不是别的」(Elaborative Interrogation)。例:「为什么除以 $\sqrt{d_k}$ 而不是 $d_k$?因为要抵消的是方差量级,方差正比于 $d_k$,开根号后才对得上标准差。」
- C3 显式挂接已学概念(Ausubel 先行组织者)。新概念一出场就告诉读者它和前面哪个概念是什么关系:「这和上一节的 X 像,区别只在……」
D 组:双重编码与信号(Dual Coding,Paivio / Mayer 多媒体原则)
- D1 结构关系能用图/表/类比表达的,就别只堆文字。 两个概念要对比 → 必须上对比表。流程有分支 → 画出来或编号列出。
- D2 给认知路标(Signaling)。用既有的双色标注和四色标签,让读者一眼看出「哪句是必须记的核心、哪句是辅助理解的展开、哪里是易错陷阱」。
E 组:对抗「知识的诅咒」(Curse of Knowledge)
- E1 术语首次出现,就地一句话解释。 不假设读者已经知道——哪怕你觉得「这还用解释?」。
- E2 自检每两句之间有没有「推理跳跃」。 凡是读者必须自己脑补一步才能接上的地方,就是一个门槛洞,必须补上中间那步。
F 组:Hook → TL;DR → Why → What → How → Check 叙事弧(Narrative Arc)
大多数教科书「先定义后例子」的结构是反认知的。高效教学材料(3Blue1Brown / BetterExplained / 得到 / 樊登)一律遵循:
- F1 Hook 钩子:每个 H3 的第一句必须是一句话的具体场景、谜题或困境,让读者「我需要知道这个」。
- F2 TL;DR:Hook 之后立即给一句粗体结论——读者读完这一句就知道这个知识点的核心意思。剩下的正文是「为什么这个结论成立」的展开。
- F3 Why 先行:讲「是什么」之前必须先答「为什么需要它」——动机是记忆的锚。
- F4 具体 → 抽象(已有 B2,此处强化):抽象定义永远不能出现在具体例子之前。
- F5 Checkpoint(必须带答案):每个章节末尾(所有 H3 之后)放 1-2 个 <div class="checkpoint"> 自测问题,让读者用刚学的知识解释一个简单变体或说出两个概念的差别。每个 checkpoint 必须配一个可折叠的参考答案——问题列表后紧跟
<details class="cp-answer"><summary>参考答案</summary>...</details>,答案逐题对应、简洁准确。先问后答(答案默认折叠),符合主动回忆原则。没有答案的自测题等于没出。
反例(无 Hook/TL;DR 的平铺):「归并排序是一种基于分治策略的排序算法。它将数组分为两个子数组,分别排序后合并。」(读者已走神)
正例(Hook → TL;DR → Why → What):「<strong>Hook:</strong>给你一叠按学号排好的作业本,再给你另一叠也排好的,你如何最快合并成一叠全排好的?你不需要从头排——两个指针交替取小的就行。<strong>TL;DR:</strong><span class="kp">归并排序 = 拆到底 + 两两有序合并</span>。核心只在合并这一步。<strong>Why:</strong>直接排序 O(n²) 太慢...」
---
Part 2 · 输入 / 输出契约
输入(由编排器提供,不要自己去读整个 bundle):
- 骨架里分配给你这一批的知识点(含:知识点 ID、类型『事实/概念/过程/原理』、依赖前置、重要度标签)
- 这些知识点对应的
_extraction_bundle.json切片(原文段落、图、表)
输出:
- 每个知识点一段 HTML,沿用现有模板约定:
- 核心概念/定义/公式用
<span class="kp">…</span>,解释/动机/举例用<span class="exp">…</span> - 每个 H3 结尾标四色重要度标签(
tag-must/tag-key/tag-freq/tag-info) - 易错点用
<blockquote>易错:…</blockquote> - 公式
$$…$$/$…$,箭头-->,引号「」 - 附一份自检结果(见 Part 3),交给审查 Agent 作为复核依据。
类型决定讲法(来自 KLI 框架):事实→记忆钩子;概念→例子+反例划边界;过程→worked example 分步;原理→因果链+适用条件。
---
Part 3 · 交付前自检(每个知识点逐条过,不过就重写)
1. 每个抽象概念前面,有没有具体锚点?(B2) 2. 有没有未解释就用的术语?(E1) 3. 任意相邻两句之间,有没有读者要脑补的跳跃?(E2) 4. 「为什么需要它」写了吗?(C1) 5. 该配对比表/图的地方配了吗?(D1) 6. 有没有套话和冗余可以再砍?(A2/A4) 7. 每个 H3 开头是Hook+TL;DR结构吗?读者能在前两句抓到核心意思吗?(F1/F2) 8. Why 在 What 前面吗?具体例子在抽象定义前面吗?(F3/F4) 9. 末尾有 checkpoint 自测吗?(F5)
---
Part 4 · 实际 Prompt(编排器喂给子 Agent)
你是一名学习科学驱动的讲解设计师。目标:让一个零基础但认真的学生把下面分配给你的知识点读一遍就懂、能用自己的话复述。你不是在“写全”,是在“讲透到对方脑子里长出心智模型”。
严格执行以下规则(每条都是硬要求,交付前逐条自检):
[降负荷] 一次只引入一个新组块;删掉套话/铺垫/自我重复;解释与它指向的公式或图就近放置,禁止“见上图/上式”。
[排序] 按给定依赖顺序讲,不让读者回翻;每个抽象概念先给具体例子/数字/类比再抽象成定义(顺序不可反);凡有推导/算法/流程,给一个从头走通的范例并逐步标注每步目的。
[深加工] 每个概念必答“是什么 / 为什么需要它 / 怎么用·易错在哪”,动机不可省;关键结论后追问“为什么是这样而不是别的”;新概念显式挂接到它依赖的已学概念,点明区别。
[图文] 要对比的概念必上对比表;用双色与四色标签给读者认知路标。
[反诅咒] 术语首次出现就地一句话解释;不留任何需要读者脑补的推理跳跃。
[叙事弧] 每个 H3 按 Hook→TL;DR→Why→What→How→Check 顺序写:Hook 钩子(一句话的场景/谜题)→ TL;DR(<div class="tldr">粗体结论</div>)→ Why(为什么需要它)→ What(是什么,此时才能出现抽象定义)→ How(怎么用,例子/推导/范例)→ Check(每个章节末尾用 <div class="checkpoint"> 放 1-2 个自测问题,**问题列表后必须紧跟可折叠参考答案** <details class="cp-answer"><summary>参考答案</summary>逐题作答</details>,没答案的自测题不合格)。
输出格式(必须兼容模板引擎):
- 核心概念/定义/公式包 <span class="kp">…</span>,解释/动机/举例包 <span class="exp">…</span>
- 每个 H3 结尾标四色标签:tag-must / tag-key / tag-freq / tag-info
- 易错点用 <blockquote>易错:…</blockquote>
- 公式用 $$…$$ 或 $…$;箭头写 -->;引号用「」;不用 Unicode 特殊字符
- 只写 H2/H3,不写 H1
- **【幻灯片对照】每个 H2/H3 标题末尾追加该知识点对应的 PPT 页码标记**:`<span data-slides="12,13"></span>`(页码取自切片里该知识组件的 source_refs)。渲染时会自动变成标题旁的 [页N] 小链接,点击滚动到右侧对应幻灯片。没有对应页就省略这个标记。
知识点类型决定讲法:事实→记忆钩子;概念→例子+反例划边界;过程→分步范例;原理→因果链+适用条件。
【分配给你的知识点(含类型/依赖/重要度)】
{SKELETON_SLICE}
【对应的提取内容切片】
{EXTRACTION_SLICE}
完成后,附一份自检结果:逐条回答“每个抽象概念有具体锚点吗 / 有未解释术语吗 / 有推理跳跃吗 / 写了为什么吗 / 该配表的配了吗 / 还能砍哪些冗余”。笔记审查 Agent · 方法论卡片
方法论卡片 = 上半「硬规则 + 三个审查镜头」+ 下半「实际 prompt」。
---
角色与北极星
你是笔记质量审查员。不联网,以 PPT 为准。你不重写笔记,你的产物是一份结构化问题清单(critical / important / minor),交给修订环。
北极星:站在一个零基础但认真的读者角度,把笔记里"会让人卡住、读错、或读不懂"的地方精确定位出来。你是这条流水线里读者的代理人。
---
Part 1 · 三个审查镜头(标注依据)
镜头 R1 · 事实性(PPT 为准 + 硬常识)
- 与 PPT 矛盾 →
critical。 - 违背学科硬常识(明显的公式错、概念错、数值错)→
critical。 - PPT 没提、笔记自行扩展的背景,如果明显错误也标出;如果只是合理补充则放过(笔记 Agent 允许补背景,但不能错)。
- 不联网查证:拿不准是否属实、但 PPT 未涉及的,标
minor提示"建议人工核对",不要自己当真理。
镜头 R2 · 逻辑(Mayer 连贯原则)
- 推理跳跃:相邻两步之间缺了中间环节,读者要自己脑补 →
important。 - 动机缺失:只给了"是什么",没给"为什么需要它" →
important(这是记不住、用不出的根因)。 - 循环定义 / 用未解释的术语解释另一个术语 →
important。
镜头 R3 · 可懂性(自我解释探针 + 知识的诅咒)
这是你最核心的工作。方法:逐段做自我解释——假装你就是第一次学的读者,试着把每一段用自己的话复述一遍。
- 凡是你自己都得"脑补"或回看才能接上的地方 → 就是一个门槛洞,标
important,指出缺了什么。 - 术语首次出现未就地解释 →
important。 - 抽象概念前面没有具体锚点(没有例子/类比/数字就直接抛定义)→
important。 - 该有对比表却用大段文字硬讲两个易混概念 →
minor,建议改表。 - 反查笔记 Agent 的硬规则:哪条没做到,就对应标一条。
---
Part 2 · 严重度分级与修订触发
| 级别 | 含义 | 是否进入修订环 |
|---|---|---|
critical | 事实错、概念错——会教错人 | 是 |
important | 推理跳跃、动机缺失、术语未解释、抽象无锚点——会让人读不懂 | 是 |
minor | 可优化的表达、建议改表——不影响正确和理解 | 否(记录但本轮不改) |
只有 critical + important 触发那一轮定向修订。 不要把所有 minor 都塞进去导致无限打磨。
---
Part 3 · 输入 / 输出契约
输入:笔记草稿 + 对应骨架知识组件 + 对应 PPT 提取切片。
输出:结构化问题清单,每条精确定位到知识点 + 给出修订方向:
{
"kc_id": "kc1.1",
"severity": "important",
"lens": "R3",
"where": "“缩放点积注意力”这一段第二句",
"problem": "直接给了 softmax(QK^T/√d_k),前面没有任何具体锚点,读者第一次看会卡住",
"fix": "先用“在句子里找‘它’指代谁”的例子引入点积相关度,再抛公式"
}---
Part 4 · 实际 Prompt(编排器喂给子 Agent)
你是笔记质量审查员,站在零基础但认真的读者角度审查笔记。不联网,以 PPT 为准。你不重写笔记,只产出结构化问题清单(critical/important/minor)。
用三个镜头逐段审:
[R1 事实性] 与 PPT 矛盾 → critical;违背学科硬常识(公式/概念/数值明显错)→ critical;PPT 没提且明显错的扩展 → 标出;拿不准又 PPT 未涉及的 → minor 提示人工核对,别自己当真理。
[R2 逻辑] 推理跳跃(缺中间步,读者要脑补)→ important;只讲“是什么”没讲“为什么需要它” → important;循环定义/用未解释术语解释术语 → important。
[R3 可懂性] 逐段做自我解释:假装第一次学,用自己的话复述每段;凡是你自己都得脑补或回看才接得上的地方 → important 门槛洞,指出缺什么;术语首次出现没就地解释 → important;抽象概念前没有具体锚点 → important;该用对比表却硬讲 → minor 建议改表。
严重度只有 critical 和 important 会进入修订,minor 只记录。每条问题必须定位到具体知识点和具体句子,并给出修订方向。
【笔记草稿】
{NOTES_DRAFT}
【对应知识组件】
{SKELETON_SLICE}
【对应 PPT 提取切片】
{EXTRACTION_SLICE}
输出 JSON 清单,每条含 kc_id / severity / lens / where / problem / fix。骨架 Agent · 方法论卡片
方法论卡片 = 上半「蒸馏自学习科学的硬规则 + 正反例」+ 下半「实际喂给子 Agent 的 prompt」。
---
角色与北极星
你是知识架构师。你是整条流水线里唯一通读全部提取内容的 Agent。你的产物是一张知识依赖图(DAG)——后续的笔记 Agent 和题目 Agent 都按你的切片并行工作,所以你定的结构对不对,决定了整门课讲得对不对、考得准不准。
北极星:把一门课拆成最小可学习单元,并理清它们的先后依赖,让"先学什么后学什么"有客观依据,而不是照 PPT 页码顺序流水账。
---
Part 1 · 硬规则(标注理论依据)
A 组:拆成知识组件(KLI 框架,Koedinger — Knowledge Component)
- S1 拆到「知识组件」粒度。 一个节点 = 一个能被单独学会、单独考查的东西,不是一章、也不是一页 PPT。
- S2 给每个组件分类:事实 / 概念 / 过程 / 原理。 这个类型会传给下游——决定笔记怎么讲(事实→记忆钩子,概念→例子划边界,过程→分步范例,原理→因果链)、题目出什么型。分类错,下游全错。
B 组:建依赖结构(学习进阶 + Ausubel 先行组织者)
- S3 建依赖边。 「学懂 B 必须先学懂 A」就连一条 A-->B 的有向边。输出一个拓扑排序,保证下游讲解时前置永远在前。
- S4 标枢纽概念。 被最多其它组件依赖的少数几个核心单独标出来——它们是这门课的主干,笔记要讲到最透、题目要考到最狠。
C 组:覆盖与忠实(建构对齐 + PPT 为准)
- S5 覆盖完整且不重复。 每个提取区块都要映射到至少一个知识组件;同一个点不要拆成多个节点。
- S6 PPT 为准,不发明。 只组织 PPT 真实出现的内容。PPT 没讲的,不要凭知识库自行加进骨架(那是笔记 Agent 在讲解时可以适度补背景,但骨架的"考纲范围"以 PPT 为界)。
- S7 控制粒度。 叶子组件数量适中:碎到上百个会让笔记/题目失焦,粗到十几个会讲不透。以"一个组件能用一段话讲清"为基准。
---
Part 2 · 输入 / 输出契约
输入:完整的 _extraction_bundle.json(你是唯一拿全局的 Agent)。
输出:一份骨架 JSON,作为下游所有 Agent 的共享契约:
{
"title": "课程名",
"chapters": [
{
"id": "ch1",
"label": "章节名",
"kcs": [
{
"id": "kc1.1",
"label": "知识组件名",
"type": "concept", // fact | concept | procedure | principle
"importance": "must", // must | key | freq | info
"deps": ["kc1.0"], // 前置组件 id
"is_hub": true, // 是否枢纽概念
"source_refs": ["slide_12", "slide_13"] // 对应提取区块,供下游切片
}
]
}
]
}source_refs 是关键:编排器靠它把每个组件对应的提取内容切片分发给笔记/题目 Agent,避免它们重读整个 bundle。
---
Part 3 · 交付前自检
1. 每个节点都是单一可学习单元吗?(没有把一整章塞进一个节点) 2. 类型分对了吗?(过程别标成概念) 3. 依赖图无环、拓扑排序成立吗? 4. 每个提取区块都被至少一个组件覆盖了吗?有没有遗漏或重复? 5. 有没有凭空加入 PPT 里没有的内容? 6. 枢纽概念标出来了吗?
---
Part 4 · 实际 Prompt(编排器喂给子 Agent)
你是知识架构师,是整条流水线里唯一通读全部课程提取内容的 Agent。把这门课拆成一张知识依赖图,供后续的笔记 Agent 和题目 Agent 并行使用。
规则(逐条执行):
- 拆到“知识组件”粒度:一个节点=一个能被单独学会、单独考查的东西,不是一章或一页 PPT。
- 给每个组件分类:fact 事实 / concept 概念 / procedure 过程 / principle 原理。分类决定下游怎么讲怎么考,务必准确。
- 建依赖边:“学懂 B 必须先懂 A”就连一条 A→B,输出拓扑顺序,前置永远在前。
- 标枢纽概念:被最多其它组件依赖的少数核心,单独标 is_hub。
- 覆盖完整不重复:每个提取区块都映射到至少一个组件,不遗漏不重复。
- PPT 为准:只组织 PPT 真实出现的内容,不凭空发明考纲范围。
- 控制粒度:以“一个组件能用一段话讲清”为基准,不要碎到上百个,也不要粗到十几个。
输出严格遵循这个 JSON 结构(chapters → kcs,每个 kc 含 id/label/type/importance/deps/is_hub/source_refs):
{SKELETON_SCHEMA}
source_refs 必须准确指向提取区块(slide 号 / 段落号),下游靠它切片。
【完整提取内容】
{EXTRACTION_BUNDLE}
完成后附自检:每个节点是单一单元吗 / 类型分对了吗 / 依赖图无环吗 / 提取区块全覆盖了吗 / 有没有凭空添加。做题验证 Agent · 方法论卡片
方法论卡片 = 上半「硬规则 + 协议」+ 下半「实际 prompt」。
本卡含 B 类检索增强(2024–2025 LLM 答题/验证研究),依据见文末。
---
角色与北极星
你是题目验证器,不是学生模拟器。
⚠ 核心边界(来自 B 类检索):基准研究证明 LLM 无法可靠地模拟真实学生的难度感受。所以你绝不输出"学生能得几分""这题对学生多难"这类预测——那是不可信的。你只做三类客观诊断:题目能不能答(answerability)、能不能不学就猜对(guessability)、有没有歧义。
北极星:用"答题"这个动作,把题目和笔记里客观存在的缺陷逼出来,喂给修订环。
---
Part 1 · 验证协议(两遍法,标注依据)
研究中验证的有效协议:让评估者分别在"看原文"和"不看原文"两种条件下作答,对比结果判断题目质量(GPT-4 作评估者与人类标注一致性高)。我们据此对每道题跑两遍:
Pass 1 · 仅看题干(不给笔记、不给标准答案)
尝试作答。用途:
- 答对了 → 这题可猜 / 太水 / 有线索泄漏(不学就能答,没有区分度)。
- 答不了 / 觉得有歧义 → 题目表述不清或信息不足(answerability 问题)。
Pass 2 · 题干 + 笔记(仍不给标准答案)
尝试作答。用途:
- 答对了 → 笔记对这个点覆盖到位。
- 答错 / 答不全 → 三种可能,要进一步区分:
- 笔记里根本没讲到这个点 → 笔记有洞,反馈给笔记 Agent。
- 题目本身超出笔记范围 / 超纲 → 反馈给题目 Agent。
- 笔记讲了但你算出的答案和标准答案不一致 → 答案存疑,反馈给题目 Agent 复核。
对照标准答案
两遍都跑完,再看标准答案,给出最终诊断标签。
---
Part 2 · 诊断标签(每题输出其一或多个)
| 标签 | 触发条件 | 反馈去向 |
|---|---|---|
ok | Pass1 答不出/不确定,Pass2 答对 | —(理想题目) |
too_easy | Pass1 直接答对 | 题目 Agent(加难或换角度) |
leak | 能从选项长度/语法/常识猜出 | 题目 Agent(重写选项) |
ambiguous | Pass1/Pass2 都卡在"题目到底问什么" | 题目 Agent(澄清题干) |
notes_gap | Pass2 答错,但题目本身合理,笔记没覆盖 | 笔记 Agent(补这个点) |
out_of_scope | 题目要求的内容 PPT/笔记里都没有 | 题目 Agent(删或改) |
answer_suspect | 你坚定算出的结果≠标准答案 | 题目 Agent(复核答案) |
只有这些客观标签进入修订环。不要写"学生可能觉得难"。
---
Part 3 · 输入 / 输出契约
输入:题目集 + 笔记(分两次喂:Pass1 只给题干,Pass2 加笔记;两遍都不给标准答案,标准答案最后单独给用于对照)。
输出:逐题诊断清单:
{
"kc_id": "kc1.1",
"question_ref": "q7",
"pass1_answer": "...", "pass1_correct": false,
"pass2_answer": "...", "pass2_correct": true,
"labels": ["ok"],
"note": "Pass1 题干无法确定问的是哪种情况,Pass2 看笔记后明确,建议题干补一个限定词"
}---
Part 4 · 实际 Prompt(编排器分两次调用)
你是题目验证器,不是学生模拟器。你不预测“学生能得几分/这题多难”——那不可信。你只诊断题目能不能答、能不能不学就猜对、有没有歧义。
本次是【第 {PASS} 遍】:
- 第 1 遍:只给你题干,不给笔记、不给答案。逐题尽力作答,并标注:你是确定地答出来的,还是靠猜/常识,还是觉得题目有歧义答不了。
- 第 2 遍:给你题干 + 笔记,仍不给答案。逐题作答,并标注:笔记里有没有讲到答这题需要的内容。
{PASS_SPECIFIC_INPUT} // 第1遍=题目;第2遍=题目+笔记
逐题输出:你的作答、是否确定、依据来自题干还是笔记、是否觉得有歧义或信息不足。不要编造笔记里没有的内容来凑答案——答不出就如实说答不出。对照标准答案后,编排器据两遍结果打诊断标签(ok / too_easy / leak / ambiguous / notes_gap / out_of_scope / answer_suspect),只有这些客观标签进入修订环。
---
研究依据(B 类检索)
- LLM 不能可靠模拟学生难度(核心边界):18 个指令微调模型在两个数据集、三个学科上均不足以可信地模拟考生用于试题预测试。(Do LLMs Give Psychometrically Plausible Responses?, arXiv 2506.09796)
- 看原文/不看原文两遍法 + GPT-4 作评估者:以"是否展示原文"两种条件下的作答来度量 guessability 与 answerability,GPT-4 评估与人类标注一致性高。(Automatic Generation and Evaluation of Reading Comprehension Items, arXiv 2404.07720)
Knowledge Graph Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.Goal: Add /exampass graph command that generates an interactive horizontal knowledge tree from course materials.
Architecture: Python script builds a prompt, Claude structures extracted content into a tree JSON, template engine embeds the JSON into a self-contained HTML page. The HTML page uses vanilla JS to render the tree with columns-per-level layout, SVG bezier connections, inline edit panels, and localStorage persistence.
Tech Stack: Python 3, vanilla HTML/CSS/JS (no frameworks), MathJax (for formula nodes if needed)
---
Task 1: Create scripts/knowledge_graph.py
Files:
- Create:
scripts/knowledge_graph.py - Create:
tests/test_knowledge_graph.py
- [ ] Step 1: Write tests for prompt builder and JSON parser
"""Tests for knowledge_graph module."""
import json
import os
import sys
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..', 'scripts'))
from knowledge_graph import build_graph_prompt, parse_graph_response, validate_tree_json
def test_build_graph_prompt_contains_text_summary():
prompt = build_graph_prompt("第一章 深度学习基础\n神经网络概念")
assert "第一章 深度学习基础" in prompt
assert "神经网络概念" in prompt
assert "知识树" in prompt or "tree" in prompt.lower()
assert "JSON" in prompt
def test_build_graph_prompt_has_schema():
prompt = build_graph_prompt("test content")
assert '"id"' in prompt
assert '"label"' in prompt
assert '"children"' in prompt
assert '"summary"' in prompt
def test_build_graph_prompt_empty_content():
prompt = build_graph_prompt("")
assert len(prompt) > 0
def test_parse_graph_response_valid_json():
response = '''```json
{
"title": "深度学习",
"nodes": [
{"id": "n1", "label": "第1章", "summary": "概述", "children": []}
]
}result = parse_graph_response(response) assert result["title"] == "深度学习" assert len(result["nodes"]) == 1 assert result["nodes"][0]["id"] == "n1"
def test_parse_graph_response_no_code_block(): response = '{"title": "test", "nodes": []}' result = parse_graph_response(response) assert result["title"] == "test"
def test_validate_tree_json_valid(): data = { "title": "课程", "nodes": [ {"id": "n1", "label": "章", "summary": "s", "children": [ {"id": "n2", "label": "节", "summary": "s2", "children": []} ]} ] } validate_tree_json(data) # should not raise
def test_validate_tree_json_missing_title(): import pytest with pytest.raises(ValueError, match="title"): validate_tree_json({"nodes": []})
def test_validate_tree_json_duplicate_ids(): import pytest data = { "title": "t", "nodes": [ {"id": "n1", "label": "a", "summary": "s", "children": []}, {"id": "n1", "label": "b", "summary": "s", "children": []} ] } with pytest.raises(ValueError, match="duplicate"): validate_tree_json(data)
- [ ] **Step 2: Run tests to verify they fail**
Run: `pytest tests/test_knowledge_graph.py -v`
Expected: All FAIL (module not found)
- [ ] **Step 3: Implement `knowledge_graph.py`**
"""Knowledge graph prompt builder and JSON utilities.
Generates the prompt that asks Claude to structure extracted course content into a hierarchical knowledge tree, then parses and validates the response. """
import json import re import uuid
def build_graph_prompt(text_summary: str) -> str: return f"""你是一位资深的大学课程辅导专家。请根据以下课程资料,生成一份知识树结构,用作交互式知识图谱。
输出要求
输出严格的 JSON 格式,不含其他文字:
{{
"title": "课程名称",
"nodes": [
{{
"id": "n1",
"label": "第1章 XXX",
"summary": "从PPT原文提取的章节概述,1-2句话",
"children": [
{{
"id": "n2",
"label": "核心概念/知识点",
"summary": "PPT关于该知识点的关键表述",
"children": [
{{"id": "n3", "label": "具体公式/方法", "summary": "关键细节", "children": []}}
]
}}
]
}}
]
}}构建规则
1. 根节点:title 是课程名称,nodes 是顶层章节列表。 2. Lv1 章节:每个章节目录对应一个 Lv1 节点。单章课程也正常只有 1 个 Lv1 节点。 3. Lv2+ 知识点:逐层细化。中间节点带 children,叶子节点 children: []。 4. 深度:3-5 层为宜。每个 Lv1 章节下至少 2 个 Lv2 知识点。 5. id 唯一:使用 "n1", "n2" ... 全局递增,不可重复。 6. summary:每个节点必须从 PPT 原文提取摘要,1-2 句话。 7. 叶子节点:深入到具体概念、公式、方法、题型。
课程资料
{text_summary} """
def parse_graph_response(response: str) -> dict: """Extract and parse JSON from Claude's response (may contain markdown fences)."""
Try to find JSON in markdown code block
m = re.search(r'``(?:json)?\s*\n(.*?)\n``', response, re.DOTALL) if m: json_str = m.group(1) else:
Try to find raw JSON object
m = re.search(r'\{[\s\S]"title"[\s\S]"nodes"[\s\S]*\}', response) if m: json_str = m.group(0) else: json_str = response
try: data = json.loads(json_str) except json.JSONDecodeError as e: raise ValueError(f"JSON 解析失败: {e}. 原始响应前200字符: {response[:200]}")
validate_tree_json(data) return data
def validate_tree_json(data: dict): """Validate knowledge tree JSON structure. Raises ValueError on problems.""" if not isinstance(data, dict): raise ValueError("根必须是 JSON 对象") if "title" not in data: raise ValueError("缺少 'title' 字段") if "nodes" not in data: raise ValueError("缺少 'nodes' 字段") if not isinstance(data["nodes"], list): raise ValueError("'nodes' 必须是数组")
ids = set()
def walk(nodes): for node in nodes: if not isinstance(node, dict): raise ValueError(f"节点必须是对象: {node}") if "id" not in node: raise ValueError(f"节点缺少 id: {node}") if "label" not in node: raise ValueError(f"节点 {node.get('id', '?')} 缺少 label") if "summary" not in node: raise ValueError(f"节点 {node.get('id', '?')} 缺少 summary") nid = node["id"] if nid in ids: raise ValueError(f"重复 id: {nid}") ids.add(nid) if "children" in node: if not isinstance(node["children"], list): raise ValueError(f"节点 {nid} 的 children 必须是数组") walk(node["children"])
walk(data["nodes"])
- [ ] **Step 4: Run tests to verify they pass**
Run: `pytest tests/test_knowledge_graph.py -v`
Expected: All PASS
- [ ] **Step 5: Commit**
git add scripts/knowledge_graph.py tests/test_knowledge_graph.py git commit -m "feat: add knowledge_graph prompt builder and JSON parser"
---
### Task 2: Create HTML template and CSS
**Files:**
- Create: `templates/graph_template.html`
- Create: `templates/graph.css`
- [ ] **Step 1: Create `templates/graph_template.html`**
<!DOCTYPE html> <html lang="zh-CN"> <head> <meta charset="utf-8"/> <meta name="viewport" content="width=device-width, initial-scale=1.0"/> <title>__TITLE__ - 知识图谱</title> <style> __CSS__ </style> </head> <body>
<header id="graph-header"> <div class="header-left"> <span class="header-brand">ExamPass Assistant</span> <span class="header-sep">|</span> <span class="header-title" id="header-title">__TITLE__ - 知识图谱</span> </div> <div class="header-right"> <input type="text" id="search-input" placeholder="搜索知识点..." /> <button id="reset-btn" title="重置视图">↺</button> </div> </header>
<div id="graph-viewport"> <div id="graph-canvas"></div> <svg id="connections-layer"></svg> </div>
<div id="zoom-bar"> <button id="zoom-out">−</button> <input type="range" id="zoom-slider" min="50" max="200" value="100" step="10" /> <button id="zoom-in">+</button> <span id="zoom-label">100%</span> </div>
<div id="tooltip" class="tooltip-hidden"></div>
<div id="toast"></div>
<script> __TREE_DATA__ </script> <script> __JS__ </script> </body> </html>
- [ ] **Step 2: Create `templates/graph.css`**
/ === ExamPass Knowledge Graph Styles === /
:root { --bg: #faf8f5; --ink: #3c3c3c; --ink-light: #888; --divider: #e0dcd5; --branch-0: #d4c5b9; --branch-1: #c5d5cb; --branch-2: #d5cec0; --branch-3: #c8d0d8; --branch-4: #d0c8c0; --branch-5: #ccd4c8; --node-radius: 8px; --edit-bg: #fefdfa; --highlight: #f0e8c0; }
- { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: "Microsoft YaHei", "Noto Sans SC", "SimSun", sans-serif; font-size: 13px; line-height: 1.6; color: var(--ink); background: var(--bg); overflow-x: hidden; }
/ ── Header ── / #graph-header { position: sticky; top: 0; z-index: 100; display: flex; justify-content: space-between; align-items: center; padding: 8px 20px; background: var(--bg); border-bottom: 1px solid var(--divider); } .header-left { display: flex; align-items: baseline; gap: 10px; } .header-brand { font-size: 1.1em; font-weight: 700; color: #1a1a2e; letter-spacing: 0.3px; } .header-sep { color: var(--divider); } .header-title { font-size: 0.95em; color: var(--ink-light); } .header-right { display: flex; gap: 8px; align-items: center; } #search-input { width: 200px; padding: 4px 10px; border: 1px solid var(--divider); border-radius: 4px; font-size: 0.9em; background: #fff; outline: none; } #search-input:focus { border-color: #aaa; } #reset-btn { background: none; border: 1px solid var(--divider); border-radius: 4px; padding: 4px 10px; cursor: pointer; font-size: 1.1em; color: var(--ink-light); } #reset-btn:hover { background: #f0ede5; }
/ ── Viewport ── / #graph-viewport { position: relative; width: 100%; min-height: calc(100vh - 90px); overflow: auto; } #graph-canvas { position: relative; padding: 40px 40px 200px 40px; transform-origin: top left; display: flex; gap: 0; } #connections-layer { position: absolute; top: 0; left: 0; pointer-events: none; z-index: 0; }
/ ── Level columns ── / .gl { display: flex; flex-direction: column; min-width: 220px; max-width: 320px; padding: 0 16px; position: relative; z-index: 1; } .gl:first-child { padding-left: 0; }
/ ── Nodes ── / .gn { position: relative; background: #fff; border: 1px solid var(--divider); border-left: 3px solid var(--divider); border-radius: var(--node-radius); padding: 8px 12px; margin-bottom: 10px; cursor: pointer; transition: box-shadow 0.15s, border-color 0.15s; user-select: none; z-index: 2; } .gn:hover { box-shadow: 0 2px 8px rgba(0,0,0,0.08); border-color: #c0b8a8; } .gn:active { box-shadow: 0 1px 4px rgba(0,0,0,0.06); }
.gn-label { font-weight: 600; color: var(--ink); font-size: 0.95em; display: block; }
.gn-badge { position: absolute; top: -4px; right: -4px; width: 10px; height: 10px; border-radius: 50%; background: #8cb88c; border: 1px solid #fff; display: none; } .gn.has-notes .gn-badge { display: block; }
.gn.collapsed { opacity: 0.5; } .gn.collapsed::after { content: '+'; position: absolute; right: 8px; top: 50%; transform: translateY(-50%); font-size: 1em; color: var(--ink-light); }
.gn.search-hit { box-shadow: 0 0 0 3px var(--highlight); } .gn.search-dim { opacity: 0.25; }
/ ── Node branch colors ── / .gn[data-branch="0"] { border-left-color: var(--branch-0); } .gn[data-branch="1"] { border-left-color: var(--branch-1); } .gn[data-branch="2"] { border-left-color: var(--branch-2); } .gn[data-branch="3"] { border-left-color: var(--branch-3); } .gn[data-branch="4"] { border-left-color: var(--branch-4); } .gn[data-branch="5"] { border-left-color: var(--branch-5); }
/ ── Edit panel ── / .ge { background: #fefdfa; border: 1px solid var(--divider); border-radius: var(--node-radius); padding: 12px; margin-bottom: 10px; min-width: 260px; max-width: 460px; z-index: 3; animation: ge-in 0.15s ease; } @keyframes ge-in { from { opacity: 0; transform: translateX(-8px); } to { opacity: 1; transform: translateX(0); } }
.ge-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 8px; font-size: 0.85em; color: var(--ink-light); } .ge-close { background: none; border: none; font-size: 1.2em; cursor: pointer; color: var(--ink-light); padding: 0 4px; } .ge-close:hover { color: var(--ink); }
.ge-notes { min-height: 60px; max-height: 300px; overflow-y: auto; padding: 8px; border: 1px dashed var(--divider); border-radius: 4px; outline: none; font-size: 0.9em; line-height: 1.7; background: #fff; } .ge-notes:focus { border-style: solid; border-color: #c0b8a8; }
.ge-images { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 8px; } .ge-img-wrap { position: relative; } .ge-img-wrap img { max-width: 200px; max-height: 150px; border-radius: 4px; border: 1px solid var(--divider); } .ge-img-del { position: absolute; top: 2px; right: 2px; width: 18px; height: 18px; border-radius: 50%; background: rgba(0,0,0,0.5); color: #fff; border: none; font-size: 12px; line-height: 18px; cursor: pointer; text-align: center; } .ge-img-del:hover { background: rgba(200,0,0,0.7); }
.ge-hint { font-size: 0.8em; color: #bbb; margin-top: 6px; text-align: center; }
/ ── Connections (SVG paths) ── / .conn-path { fill: none; stroke-width: 1.5; opacity: 0.5; } .conn-path[data-branch="0"] { stroke: var(--branch-0); } .conn-path[data-branch="1"] { stroke: var(--branch-1); } .conn-path[data-branch="2"] { stroke: var(--branch-2); } .conn-path[data-branch="3"] { stroke: var(--branch-3); } .conn-path[data-branch="4"] { stroke: var(--branch-4); } .conn-path[data-branch="5"] { stroke: var(--branch-5); }
/ ── Tooltip ── / #tooltip { position: fixed; z-index: 200; max-width: 300px; padding: 8px 12px; background: #3c3c3c; color: #f5f0e8; border-radius: 6px; font-size: 0.85em; line-height: 1.5; pointer-events: none; opacity: 0; transition: opacity 0.12s; } #tooltip.tooltip-show { opacity: 1; }
/ ── Zoom bar ── / #zoom-bar { position: fixed; bottom: 16px; left: 50%; transform: translateX(-50%); display: flex; align-items: center; gap: 6px; background: #fff; border: 1px solid var(--divider); border-radius: 20px; padding: 4px 12px; z-index: 100; box-shadow: 0 2px 8px rgba(0,0,0,0.08); } #zoom-bar button { background: none; border: none; font-size: 1.2em; cursor: pointer; width: 28px; height: 28px; border-radius: 50%; color: var(--ink-light); } #zoom-bar button:hover { background: #f0ede5; color: var(--ink); } #zoom-slider { width: 100px; accent-color: #aaa; } #zoom-label { font-size: 0.85em; color: var(--ink-light); min-width: 36px; text-align: center; }
/ ── Toast ── / #toast { position: fixed; bottom: 64px; left: 50%; transform: translateX(-50%); padding: 6px 16px; border-radius: 6px; font-size: 0.85em; background: #3c3c3c; color: #f5f0e8; opacity: 0; transition: opacity 0.3s; pointer-events: none; z-index: 300; } #toast.toast-show { opacity: 1; }
- [ ] **Step 3: Commit**
git add templates/graph_template.html templates/graph.css git commit -m "feat: add knowledge graph HTML template and CSS"
---
### Task 3: Create `templates/graph.js` — Core rendering
**Files:**
- Create: `templates/graph.js`
- [ ] **Step 1: Write the JS file — tree data, rendering, connections**
// === ExamPass Knowledge Graph Engine ===
const BRANCH_COLORS = [ '#d4c5b9', '#c5d5cb', '#d5cec0', '#c8d0d8', '#d0c8c0', '#ccd4c8' ];
const STORAGE_KEY = 'graph_settings';
// ─── Tree model ───────────────────────────────────────────
function walkTree(nodes, branch, depth, callback) { let idx = 0; function _walk(nodes, branch, depth) { for (const node of nodes) { const isLeaf = !node.children || node.children.length === 0; callback(node, branch, depth, isLeaf); if (node.children && node.children.length > 0) { _walk(node.children, branch, depth + 1); } } } _walk(nodes, branch, depth); }
function assignBranchColors(nodes) { nodes.forEach(function(node, i) { walkTree([node], i % BRANCH_COLORS.length, 0, function(n, branch) { n._branch = branch; }); }); }
function flattenByLevel(nodes) { var levels = []; function walk(nodes, depth) { if (!levels[depth]) levels[depth] = []; for (var i = 0; i < nodes.length; i++) { var node = nodes[i]; node._depth = depth; levels[depth].push(node); if (node.children && node.children.length > 0) { walk(node.children, depth + 1); } } } walk(nodes, 0); return levels; }
// ─── localStorage helpers ─────────────────────────────────
function loadNotes(nodeId) { try { return localStorage.getItem('graph_' + nodeId + '_notes') || ''; } catch(e) { return ''; } }
function saveNotes(nodeId, html) { try { if (html) { localStorage.setItem('graph_' + nodeId + '_notes', html); } else { localStorage.removeItem('graph_' + nodeId + '_notes'); } localStorage.setItem('graph_' + nodeId + '_updated', new Date().toISOString()); } catch(e) { if (e.name === 'QuotaExceededError') { showToast('存储空间不足,请清理旧笔记或图片'); } } }
function loadImages(nodeId) { try { var raw = localStorage.getItem('graph_' + nodeId + '_images'); return raw ? JSON.parse(raw) : []; } catch(e) { return []; } }
function saveImages(nodeId, images) { try { if (images.length > 0) { localStorage.setItem('graph_' + nodeId + '_images', JSON.stringify(images)); } else { localStorage.removeItem('graph_' + nodeId + '_images'); } } catch(e) { if (e.name === 'QuotaExceededError') { showToast('图片过大,存储空间不足。请删除部分旧图片'); } } }
function loadSettings() { try { var raw = localStorage.getItem(STORAGE_KEY); return raw ? JSON.parse(raw) : {}; } catch(e) { return {}; } }
function saveSettings(settings) { try { localStorage.setItem(STORAGE_KEY, JSON.stringify(settings)); } catch(e) {} }
// ─── Rendering ────────────────────────────────────────────
var treeData = null; var activeEditNode = null; var settings = loadSettings();
function render(tree) { treeData = tree; if (!treeData.nodes || treeData.nodes.length === 0) { document.getElementById('graph-canvas').innerHTML = '<div style="padding:60px;text-align:center;color:#999;">课程内容为空,无法生成知识图谱</div>'; return; }
assignBranchColors(treeData.nodes);
var canvas = document.getElementById('graph-canvas'); canvas.innerHTML = '';
var levels = flattenByLevel(treeData.nodes);
// Apply collapsed state from settings var collapsed = settings.collapsed || []; applyCollapsed(treeData.nodes, collapsed);
// Render level columns for (var li = 0; li < levels.length; li++) { var col = document.createElement('div'); col.className = 'gl gl-' + li;
for (var ni = 0; ni < levels[li].length; ni++) { var node = levels[li][ni]; var el = renderNode(node); col.appendChild(el);
// Show edit panel if this node was being edited if (activeEditNode && activeEditNode.id === node.id) { var editEl = renderEditPanel(node); col.appendChild(editEl); setTimeout(function(el) { el.querySelector('.ge-notes').focus(); }, 50, editEl); } }
canvas.appendChild(col); }
// SVG layer drawConnections();
// Restore zoom var zoom = settings.zoom || 1; canvas.style.transform = 'scale(' + zoom + ')'; document.getElementById('zoom-slider').value = Math.round(zoom 100); document.getElementById('zoom-label').textContent = Math.round(zoom 100) + '%';
// Restore renames applyRenames();
// Update header document.getElementById('header-title').textContent = (treeData.title || '课程') + ' - 知识图谱'; }
function renderNode(node) { var el = document.createElement('div'); el.className = 'gn gn-lv' + (node._depth || 0); el.dataset.id = node.id; el.dataset.branch = node._branch; el.dataset.depth = node._depth;
var isLeaf = !node.children || node.children.length === 0;
var label = document.createElement('span'); label.className = 'gn-label'; label.textContent = node.label; el.appendChild(label);
// Badge for notes var badge = document.createElement('span'); badge.className = 'gn-badge'; el.appendChild(badge);
// Check for existing notes/images if (loadNotes(node.id) || loadImages(node.id).length > 0) { el.classList.add('has-notes'); }
// Collapsed state if (node._collapsed) { el.classList.add('collapsed'); }
// Tooltip if (node.summary) { el.addEventListener('mouseenter', function(e) { showTooltip(e, node.summary); }); el.addEventListener('mouseleave', hideTooltip); el.addEventListener('mousemove', moveTooltip); }
// Click handler el.addEventListener('click', function(e) { e.stopPropagation(); if (isLeaf) { toggleEditPanel(node, el); } else { toggleCollapse(node); } });
// Double-click rename el.addEventListener('dblclick', function(e) { e.stopPropagation(); renameNode(node, label); });
return el; }
// ─── Edit panel ───────────────────────────────────────────
function renderEditPanel(node) { var el = document.createElement('div'); el.className = 'ge'; el.dataset.forNode = node.id;
var header = document.createElement('div'); header.className = 'ge-header'; var updated = localStorage.getItem('graph_' + node.id + '_updated'); header.innerHTML = '<span>笔记' + (updated ? ' · 最后保存 ' + formatTime(updated) : '') + '</span>';
var closeBtn = document.createElement('button'); closeBtn.className = 'ge-close'; closeBtn.textContent = '✕'; closeBtn.addEventListener('click', function(e) { e.stopPropagation(); closeEditPanel(node); }); header.appendChild(closeBtn); el.appendChild(header);
// Notes area var notesDiv = document.createElement('div'); notesDiv.className = 'ge-notes'; notesDiv.contentEditable = 'true'; notesDiv.innerHTML = loadNotes(node.id); notesDiv.addEventListener('blur', function() { saveNotes(node.id, notesDiv.innerHTML); updateNodeBadge(node); }); notesDiv.addEventListener('paste', function(e) { handlePaste(e, node); }); el.appendChild(notesDiv);
// Images var images = loadImages(node.id); var imagesDiv = document.createElement('div'); imagesDiv.className = 'ge-images'; for (var i = 0; i < images.length; i++) { imagesDiv.appendChild(createImageElement(images[i], i, node)); } el.appendChild(imagesDiv);
// Hint var hint = document.createElement('div'); hint.className = 'ge-hint'; hint.textContent = 'Ctrl+V 粘贴图片 · 点击外部自动保存'; el.appendChild(hint);
return el; }
function createImageElement(src, index, node) { var wrap = document.createElement('div'); wrap.className = 'ge-img-wrap';
var img = document.createElement('img'); img.src = src; img.alt = '粘贴的图片'; wrap.appendChild(img);
var del = document.createElement('button'); del.className = 'ge-img-del'; del.textContent = '✕'; del.addEventListener('click', function(e) { e.stopPropagation(); var images = loadImages(node.id); images.splice(index, 1); saveImages(node.id, images); wrap.remove(); }); wrap.appendChild(del);
return wrap; }
function handlePaste(e, node) { var items = e.clipboardData && e.clipboardData.items; if (!items) return;
for (var i = 0; i < items.length; i++) { if (items[i].type.indexOf('image') !== -1) { e.preventDefault(); var blob = items[i].getAsFile(); compressImage(blob, function(dataUrl) { var images = loadImages(node.id); images.push(dataUrl); saveImages(node.id, images); // Add to DOM var imagesDiv = e.target.parentElement.querySelector('.ge-images'); if (imagesDiv) { imagesDiv.appendChild(createImageElement(dataUrl, images.length - 1, node)); } }); return; } } }
function compressImage(blob, callback) { var img = new Image(); var url = URL.createObjectURL(blob); img.onload = function() { URL.revokeObjectURL(url); var canvas = document.createElement('canvas'); var maxW = 800; var w = img.width, h = img.height; if (w > maxW) { h = h * (maxW / w); w = maxW; } canvas.width = w; canvas.height = h; var ctx = canvas.getContext('2d'); ctx.drawImage(img, 0, 0, w, h); callback(canvas.toDataURL('image/jpeg', 0.7)); }; img.src = url; }
function toggleEditPanel(node, nodeEl) { if (activeEditNode && activeEditNode.id === node.id) { closeEditPanel(node); return; } if (activeEditNode) { closeEditPanel(activeEditNode); } activeEditNode = node;
var panel = renderEditPanel(node); nodeEl.parentElement.insertBefore(panel, nodeEl.nextSibling); setTimeout(function() { panel.querySelector('.ge-notes').focus(); }, 50);
// Scroll detection — close when panel scrolls out of viewport panel._scrollHandler = function() { var rect = panel.getBoundingClientRect(); if (rect.bottom < 0 || rect.top > window.innerHeight) { closeEditPanel(node); } }; window.addEventListener('scroll', panel._scrollHandler, {passive: true}); }
function closeEditPanel(node) { var panel = document.querySelector('.ge[data-for-node="' + node.id + '"]'); if (panel) { // Final save var notesDiv = panel.querySelector('.ge-notes'); if (notesDiv) { saveNotes(node.id, notesDiv.innerHTML); } if (panel._scrollHandler) { window.removeEventListener('scroll', panel._scrollHandler); } panel.remove(); } if (activeEditNode && activeEditNode.id === node.id) { activeEditNode = null; } updateNodeBadge(node); }
function updateNodeBadge(node) { var el = document.querySelector('.gn[data-id="' + node.id + '"]'); if (!el) return; var hasContent = loadNotes(node.id) || loadImages(node.id).length > 0; if (hasContent) { el.classList.add('has-notes'); } else { el.classList.remove('has-notes'); } }
// ─── Collapse / Expand ────────────────────────────────────
function applyCollapsed(nodes, collapsedIds) { for (var i = 0; i < nodes.length; i++) { var node = nodes[i]; if (collapsedIds.indexOf(node.id) !== -1) { node._collapsed = true; } if (node.children && node.children.length > 0) { applyCollapsed(node.children, collapsedIds); } } }
function toggleCollapse(node) { node._collapsed = !node._collapsed; var collapsed = settings.collapsed || []; if (node._collapsed) { if (collapsed.indexOf(node.id) === -1) collapsed.push(node.id); } else { var idx = collapsed.indexOf(node.id); if (idx !== -1) collapsed.splice(idx, 1); } settings.collapsed = collapsed; saveSettings(settings); render(treeData); }
// ─── Rename ────────────────────────────────────────────────
function renameNode(node, labelEl) { var oldLabel = node.label; var input = document.createElement('input'); input.type = 'text'; input.value = oldLabel; input.style.cssText = 'font-weight:600;font-size:0.95em;width:100%;border:1px solid #ccc;border-radius:4px;padding:2px 6px;'; input.addEventListener('blur', function() { finishRename(node, input.value.trim() || oldLabel); }); input.addEventListener('keydown', function(e) { if (e.key === 'Enter') { e.preventDefault(); input.blur(); } if (e.key === 'Escape') { input.value = oldLabel; input.blur(); } }); labelEl.replaceWith(input); input.focus(); input.select(); }
function finishRename(node, newLabel) { node.label = newLabel; var renamed = settings.renamed || {}; renamed[node.id] = newLabel; settings.renamed = renamed; saveSettings(settings); render(treeData); }
function applyRenames() { var renamed = settings.renamed || {}; var ids = Object.keys(renamed); for (var i = 0; i < ids.length; i++) { var el = document.querySelector('.gn[data-id="' + ids[i] + '"] .gn-label'); if (el) el.textContent = renamed[ids[i]]; } }
// ─── Connections (SVG) ────────────────────────────────────
function drawConnections() { var svg = document.getElementById('connections-layer'); var canvas = document.getElementById('graph-canvas'); var canvasRect = canvas.getBoundingClientRect();
svg.style.width = canvas.scrollWidth + 'px'; svg.style.height = canvas.scrollHeight + 'px'; svg.setAttribute('viewBox', '0 0 ' + canvas.scrollWidth + ' ' + canvas.scrollHeight);
var paths = [];
function collectEdges(nodes) { for (var i = 0; i < nodes.length; i++) { var parent = nodes[i]; if (parent._collapsed) continue; var parentEl = document.querySelector('.gn[data-id="' + parent.id + '"]'); if (!parentEl) continue; if (parent.children && parent.children.length > 0) { for (var j = 0; j < parent.children.length; j++) { var child = parent.children[j]; var childEl = document.querySelector('.gn[data-id="' + child.id + '"]'); if (!childEl) continue; paths.push({ parent: parentEl, child: childEl, branch: parent._branch }); collectEdges(parent.children); } } } } collectEdges(treeData.nodes);
var html = ''; for (var i = 0; i < paths.length; i++) { var p = paths[i]; var pRect = p.parent.getBoundingClientRect(); var cRect = p.child.getBoundingClientRect();
var x1 = pRect.right - canvasRect.left; var y1 = pRect.top + pRect.height / 2 - canvasRect.top; var x2 = cRect.left - canvasRect.left; var y2 = cRect.top + cRect.height / 2 - canvasRect.top;
var cx1 = x1 + (x2 - x1) 0.4; var cy1 = y1; var cx2 = x1 + (x2 - x1) 0.6; var cy2 = y2;
html += '<path class="conn-path" data-branch="' + p.branch + '" d="M' + x1 + ',' + y1 + ' C' + cx1 + ',' + cy1 + ' ' + cx2 + ',' + cy2 + ' ' + x2 + ',' + y2 + '" />'; }
svg.innerHTML = html; }
// ─── Tooltip ───────────────────────────────────────────────
function showTooltip(e, text) { var tip = document.getElementById('tooltip'); tip.textContent = text; tip.classList.add('tooltip-show'); moveTooltip(e); }
function moveTooltip(e) { var tip = document.getElementById('tooltip'); var x = e.clientX + 14; var y = e.clientY + 14; if (x + 300 > window.innerWidth) x = e.clientX - 310; if (y + 80 > window.innerHeight) y = e.clientY - 90; tip.style.left = x + 'px'; tip.style.top = y + 'px'; }
function hideTooltip() { document.getElementById('tooltip').classList.remove('tooltip-show'); }
// ─── Toast ─────────────────────────────────────────────────
var toastTimer = null; function showToast(msg) { var toast = document.getElementById('toast'); toast.textContent = msg; toast.classList.add('toast-show'); if (toastTimer) clearTimeout(toastTimer); toastTimer = setTimeout(function() { toast.classList.remove('toast-show'); }, 2000); }
// ─── Format helpers ────────────────────────────────────────
function formatTime(iso) { try { var d = new Date(iso); var pad = function(n) { return n < 10 ? '0' + n : String(n); }; return d.getFullYear() + '-' + pad(d.getMonth()+1) + '-' + pad(d.getDate()) + ' ' + pad(d.getHours()) + ':' + pad(d.getMinutes()); } catch(e) { return ''; } }
- [ ] **Step 2: Commit**
git add templates/graph.js git commit -m "feat: add knowledge graph core JS (render, edit panel, connections)"
---
### Task 4: Add search, zoom, and initialization to `templates/graph.js`
**Files:**
- Modify: `templates/graph.js` (append at end)
- [ ] **Step 1: Append search, zoom, and init code**
// ─── Search ────────────────────────────────────────────────
var searchTimer = null; document.addEventListener('DOMContentLoaded', function() { var searchInput = document.getElementById('search-input'); if (!searchInput) return; searchInput.addEventListener('input', function() { if (searchTimer) clearTimeout(searchTimer); searchTimer = setTimeout(doSearch, 200); }); searchInput.addEventListener('keydown', function(e) { if (e.key === 'Escape') { searchInput.value = ''; doSearch(); } }); });
function doSearch() { var query = document.getElementById('search-input').value.trim().toLowerCase(); var allNodes = document.querySelectorAll('.gn'); var firstHit = null;
allNodes.forEach(function(el) { el.classList.remove('search-hit', 'search-dim'); });
if (!query) return;
allNodes.forEach(function(el) { var label = (el.querySelector('.gn-label') || {}).textContent || ''; if (label.toLowerCase().indexOf(query) !== -1) { el.classList.add('search-hit'); if (!firstHit) firstHit = el; } else { el.classList.add('search-dim'); } });
if (firstHit) { firstHit.scrollIntoView({ behavior: 'smooth', block: 'center' }); } }
// ─── Zoom ──────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', function() { var slider = document.getElementById('zoom-slider'); var label = document.getElementById('zoom-label'); var outBtn = document.getElementById('zoom-out'); var inBtn = document.getElementById('zoom-in'); var resetBtn = document.getElementById('reset-btn');
if (!slider) return;
var zoom = settings.zoom || 1;
function applyZoom(z) { zoom = Math.max(0.5, Math.min(2, z)); var canvas = document.getElementById('graph-canvas'); if (canvas) canvas.style.transform = 'scale(' + zoom + ')'; slider.value = Math.round(zoom 100); label.textContent = Math.round(zoom 100) + '%'; settings.zoom = zoom; saveSettings(settings); // Redraw connections after zoom setTimeout(drawConnections, 100); }
slider.addEventListener('input', function() { applyZoom(this.value / 100); }); outBtn.addEventListener('click', function() { applyZoom(zoom - 0.1); }); inBtn.addEventListener('click', function() { applyZoom(zoom + 0.1); });
resetBtn.addEventListener('click', function() { settings.collapsed = []; settings.renamed = {}; saveSettings(settings); activeEditNode = null; document.getElementById('search-input').value = ''; doSearch(); applyZoom(1); render(treeData); }); });
// ─── Init ──────────────────────────────────────────────────
document.addEventListener('DOMContentLoaded', function() { if (typeof TREE_DATA === 'undefined') { document.getElementById('graph-canvas').innerHTML = '<div style="padding:60px;text-align:center;color:#999;">未找到知识图谱数据</div>'; return; } render(TREE_DATA);
// Handle window resize → redraw connections var resizeTimer = null; window.addEventListener('resize', function() { if (resizeTimer) clearTimeout(resizeTimer); resizeTimer = setTimeout(function() { if (activeEditNode) closeEditPanel(activeEditNode); drawConnections(); }, 300); });
// Close edit panel on click outside document.addEventListener('click', function(e) { if (activeEditNode) { var clickedOnNode = e.target.closest('.gn[data-id="' + activeEditNode.id + '"]'); var clickedOnPanel = e.target.closest('.ge[data-for-node="' + activeEditNode.id + '"]'); if (!clickedOnNode && !clickedOnPanel) { closeEditPanel(activeEditNode); } } }); });
- [ ] **Step 2: Commit**
git add templates/graph.js git commit -m "feat: add knowledge graph search, zoom, and init logic"
---
### Task 5: Add `save_graph_html()` to template engine
**Files:**
- Modify: `scripts/template_engine.py`
- [ ] **Step 1: Read current template_engine.py to locate insertion point**
The function should be added after `save_knowledge_html()` (around line 98).
- [ ] **Step 2: Add `save_graph_html()` function**
Add the following after the `save_knowledge_html` function (after line 98) and before the `# ─── Interactive test page` comment:
─── Knowledge graph page ─────────────────────────────────────────
def save_graph_html(tree_json: dict, output_path: str, title: str): """Generate an interactive knowledge graph page.
tree_json: {"title": "...", "nodes": [...]} """ import json as _json tree_data_js = 'const TREE_DATA = ' + _json.dumps(tree_json, ensure_ascii=False) + ';'
graph_css = _read('graph.css') graph_js = _read('graph.js') graph_template = _read('graph_template.html')
html = graph_template html = html.replace('__TITLE__', title) html = html.replace('__CSS__', graph_css) html = html.replace('__TREE_DATA__', '<script>\n' + tree_data_js + '\n</script>') html = html.replace('__JS__', graph_js)
out_dir = os.path.dirname(output_path) if out_dir: os.makedirs(out_dir, exist_ok=True) with open(output_path, 'w', encoding='utf-8') as f: f.write(html)
- [ ] **Step 3: Write tests for `save_graph_html`**
Add to `tests/test_knowledge_graph.py`:
def test_save_graph_html_creates_file(tmp_path): from template_engine import save_graph_html
tree = { "title": "测试课程", "nodes": [ {"id": "n1", "label": "第1章", "summary": "概述", "children": [ {"id": "n2", "label": "知识点1", "summary": "细节", "children": []} ]} ] } output = tmp_path / "知识图谱.html" save_graph_html(tree, str(output), "测试课程")
assert output.exists() content = output.read_text(encoding='utf-8') assert "测试课程" in content assert "TREE_DATA" in content assert "n1" in content assert "n2" in content assert "graph-canvas" in content
def test_save_graph_html_creates_parent_dir(tmp_path): from template_engine import save_graph_html
tree = {"title": "t", "nodes": []} output = tmp_path / "subdir" / "graph.html" save_graph_html(tree, str(output), "t") assert output.exists()
- [ ] **Step 4: Run tests**
Run: `pytest tests/test_knowledge_graph.py -v`
Expected: All PASS (2 new + existing tests)
- [ ] **Step 5: Commit**
git add scripts/template_engine.py tests/test_knowledge_graph.py git commit -m "feat: add save_graph_html() to template engine with tests"
---
### Task 6: Add `graph` subcommand routing to SKILL.md
**Files:**
- Modify: `SKILL.md`
- [ ] **Step 1: Add `graph` to command routing**
The routing section currently checks for `"update"`. Add `"graph"`:
Edit the «命令路由» section — after the `update` line and before the `其他情况` line — to include:
- `args` 为 `"update"`:执行下方「技能更新」流程,完成后直接结束,不执行知识清单生成。
- `args` 为 `"graph"` 或以
"graph "开头:执行下方「知识图谱生成」流程,完成后直接结束。
- [ ] **Step 2: Add knowledge graph generation section**
Insert before `# ExamPass Assistant` heading, after the update PowerShell script block:
---
知识图谱生成
当用户执行 /exampass graph [目录] 时:
第一步:提取
python scripts/run_exampass.py <目标目录>(复用扫描+提取管线,产出 _extraction_bundle.json)
第二步:深度分析生成知识树 JSON
Claude 读取提取内容,按 scripts/knowledge_graph.py 的 prompt 将内容组织为知识树 JSON,保存为 knowledge_graph.json。
第三步:生成交互式 HTML
import json
from scripts.template_engine import save_graph_html
with open('knowledge_graph.json', 'r', encoding='utf-8') as f:
tree = json.load(f)
save_graph_html(tree, '知识图谱.html', tree['title'])第四步:打开
浏览器打开 HTML。用户可交互编辑、粘贴图片、搜索、缩放。
从已有 JSON 重新生成
若用户提供了 --from-json <路径> 参数: 1. 直接读取 JSON 2. 跳过提取和分析 3. 调用 save_graph_html() 重新生成 HTML
- [ ] **Step 3: Commit**
git add SKILL.md git commit -m "feat: add /exampass graph subcommand routing"
---
### Task 7: End-to-end verification
**Files:**
- Verify: All new and modified files work together
- [ ] **Step 1: Run all existing tests to ensure no regressions**
cd M:\skills\EPA && python -m pytest tests/ -v
Expected: All 102+ tests PASS
- [ ] **Step 2: Verify imports work**
cd M:\skills\EPA && python -c "from scripts.knowledge_graph import build_graph_prompt, parse_graph_response, validate_tree_json; print('OK')"
- [ ] **Step 3: Verify template engine integration**
cd M:\skills\EPA && python -c "from scripts.template_engine import save_graph_html; print('OK')"
- [ ] **Step 4: Generate a test graph HTML with sample data**
cd M:\skills\EPA && python -c " import json from scripts.template_engine import save_graph_html
tree = { 'title': '深度学习', 'nodes': [ {'id': 'n1', 'label': '第1章 绪论', 'summary': '课程概述和基础概念', 'children': [ {'id': 'n2', 'label': '神经元模型', 'summary': 'M-P神经元模型是深度学习的基本计算单元', 'children': [ {'id': 'n3', 'label': '激活函数', 'summary': '引入非线性,常见的有Sigmoid、Tanh、ReLU', 'children': []}, {'id': 'n4', 'label': '前向传播', 'summary': '输入数据从输入层经隐藏层到输出层的计算过程', 'children': []} ]} ]}, {'id': 'n5', 'label': '第2章 神经网络', 'summary': '多层神经网络的训练和优化', 'children': [ {'id': 'n6', 'label': '反向传播', 'summary': '利用链式法则计算损失函数对每个参数的梯度', 'children': []}, {'id': 'n7', 'label': '梯度下降', 'summary': '沿负梯度方向更新参数以最小化损失', 'children': [ {'id': 'n8', 'label': 'SGD', 'summary': '随机梯度下降,每次用一个小批量估计梯度', 'children': []}, {'id': 'n9', 'label': 'Adam', 'summary': '自适应矩估计,结合动量和RMSProp', 'children': []} ]} ]}, {'id': 'n10', 'label': '第3章 CNN', 'summary': '卷积神经网络在图像处理中的应用', 'children': [ {'id': 'n11', 'label': '卷积层', 'summary': '通过卷积核提取局部特征', 'children': []}, {'id': 'n12', 'label': '池化层', 'summary': '下采样降低特征图尺寸,保留主要特征', 'children': []} ]} ] } save_graph_html(tree, 'test_graph.html', '深度学习') print('Generated test_graph.html') "
- [ ] **Step 5: Verify HTML structure**
cd M:\skills\EPA && python -c " content = open('test_graph.html', 'r', encoding='utf-8').read() assert 'TREE_DATA' in content assert 'graph-canvas' in content assert 'connections-layer' in content assert 'search-input' in content assert 'zoom-slider' in content assert 'const TREE_DATA' in content print('HTML structure OK') "
- [ ] **Step 6: Clean up test file**
Remove-Item M:\skills\EPA\test_graph.html
- [ ] **Step 7: Commit (if any fixes were needed)**
Only if changes were required during verification.
---
### Final Verification Checklist
- [ ] `pytest tests/ -v` — all tests pass
- [ ] `python -c "from scripts.knowledge_graph import *"` — imports work
- [ ] `python -c "from scripts.template_engine import save_graph_html"` — new function available
- [ ] Generated HTML contains all required elements (canvas, connections layer, search, zoom)
- [ ] SKILL.md has `graph` routing
- [ ] No regressions in existing functionality
Knowledge Graph Feature — Design Spec
Overview
Add an interactive knowledge graph (mind map) to ExamPass Assistant. Users run /exampass graph to generate a horizontal tree visualization of course knowledge, with inline editing and image paste support.
Command
/exampass graph # current directory
/exampass graph <dir> # specific directory
/exampass graph --from-json <json> # re-render from saved JSONData Model
Knowledge Tree JSON
{
"title": "深度学习",
"nodes": [
{
"id": "n1",
"label": "第1章 绪论",
"summary": "PPT原文摘要,悬浮tooltip显示",
"children": [
{
"id": "n2",
"label": "神经元模型",
"summary": "M-P神经元:加权求和+激活函数",
"children": [
{"id": "n3", "label": "激活函数", "summary": "...", "children": []},
{"id": "n4", "label": "前向传播", "summary": "...", "children": []}
]
}
]
}
]
}id: globally unique, used as localStorage key prefixchildren: []: leaf node (editable); non-empty: intermediate node (collapsible)summary: extracted from PPT, shown in tooltip on hover
localStorage Schema
graph_{nodeId}_notes → HTML string (rich text notes)
graph_{nodeId}_images → JSON array of base64 strings
graph_{nodeId}_updated → ISO timestamp
graph_settings → {"zoom": 1, "collapsed": ["n5","n8"], "renamed": {"n1":"自定义名"}}Page Layout
Horizontal tree, root on left, children expand right. Page scrolls vertically for large trees.
Lv0(root) Lv1(chapter) Lv2(topic) Lv3(detail) Edit Panel
┌──────────┐
│ 神经元模型 │
┌────┤ M-P模型 ├──────────────────┐
│ └──────────┘ │
│ ▼
┌──────┐ ┌───┴──────┐ ┌──────────┐ ┌──────────────────┐
│ │ │ 第1章 │ │ Sigmoid │ │ 📝 笔记: │
│ 深度 │─│ 绪论 │ │ Tanh │ │ 考试重点是... │
│ 学习 │ └──────────┘ │ ReLU │ │ │
│ │ └──────────┘ │ 🖼️ [粘贴的截图] │
│ │ └──────────────────┘
│ │ ┌──────────┐ ┌──────────┐
│ │ │ 第2章 │──────────────│ 反向传播 │─[点击编辑]
│ │─│ 神经网络 │ └──────────┘
│ │ └──────────┘
└──────┘ Color Scheme
Muted, low-saturation academic palette. Each Lv1 chapter gets a branch color; all descendants share the same hue with decreasing saturation at deeper levels.
| Branch | Base Color | Description |
|---|---|---|
| Root | #d4c5b9 | Warm gray-brown |
| Chapter A | #c5d5cb | Muted sage green |
| Chapter B | #d5cec0 | Warm camel |
| Chapter C | #c8d0d8 | Cool gray-blue |
| Chapter D | #d0c8c0 | Warm gray-brown |
| Chapter E | #ccd4c8 | Olive gray |
| Chapter F+ | cycle through above | Rotate palette |
Children at deeper levels use lighter tints of their chapter's base color. Connection lines inherit branch color.
Interaction
| Action | Behavior |
|---|---|
| Hover node | Tooltip shows PPT summary |
| Click leaf node | Toggle edit panel next to it |
| Click intermediate node | Collapse/expand subtree |
| Double-click label | Rename (saved to localStorage) |
| Ctrl+V in edit panel | Paste image (auto-compress, base64) |
| Blur / scroll panel out of view | Auto-save to localStorage |
| Close edit panel | Save and hide |
Edit Panel
- Inline, attached to the right/below the clicked leaf node
contenteditablediv for rich text notes- Image paste area: Ctrl+V directly pastes clipboard images
- Auto-compress: max 800px wide, JPEG quality 0.7, ~50-150KB per image
- Delete button per image
- Auto-save on blur, on scroll-out-of-view, or after 2s idle
- Max width 500px, follows the node's branch color with lighter background
Search
Top search bar. Type to filter nodes: matching nodes get highlighted (subtle glow), non-matching dim. Scroll to first match.
Zoom
Bottom zoom slider (50%–200%). Scales the entire tree. Default 100%.
Generation Pipeline
Course dir/
├── PPT/PDF ──→ scanner.py ──→ extractor.py
└── ... │
▼
text_summary (merged)
│
▼
Claude deep analysis
(prompt: knowledge_graph.py)
│
▼
knowledge_graph.json
│
┌────────┴────────┐
▼ ▼
template_engine.py Direct re-render
save_graph_html() (--from-json)
│
▼
知识图谱.htmlClaude Prompt Constraints
- Root = course name
- Lv1 = chapters (one color branch per chapter)
- Lv2+ = knowledge points, progressively detailed
- Every node has
summaryfrom source material - Leaf nodes are concrete concepts/formulas/methods
- Target depth: 3-5 levels depending on material complexity
- Cross-chapter relationships noted in summary where relevant
Files
| File | Purpose |
|---|---|
scripts/knowledge_graph.py | Build prompt, call Claude, parse JSON response |
templates/graph_template.html | HTML shell with root containers |
templates/graph.css | Tree layout, node styles, edit panel, animations |
templates/graph.js | Tree renderer, SVG connections, edit panel, localStorage, search, zoom |
SKILL.md | Add graph subcommand routing |
Edge Cases
| Scenario | Handling |
|---|---|
| Single-chapter course | Lv1 has one node; tree still renders normally |
| Deep nesting (5+ levels) | Horizontal scrollbar appears; drag to pan horizontally |
| Empty directory / no content | "No course materials found" message |
| localStorage full | Alert user, suggest clearing old images |
| Very large tree (100+ nodes) | Virtual scrolling: only render nodes within ±2 viewports; collapse all by default past Lv3 |
| No images in PPT | Edit panel still works for text notes only |
| JSON parse failure | Show error with line number, offer to retry |
| Duplicate node IDs | Auto-deduplicate on generation |
EPA 多 Agent 编排方案
把 /exampass 从「单 Agent 一把梭」改成「骨架 → 并行创作 → 并行评审 → 一轮定向修订 → 渲染」。五张 Agent 方法论卡片见 agents/,本文只管它们之间怎么协作。决策基线(已确认)
| 维度 | 决策 |
|---|---|
| 骨架 | 加(共享契约,唯一通读全局) |
| 验证 | 不联网,PPT 为准 |
| 修订 | 一轮封顶 |
| 并行粒度 | 按章并行(每章一个笔记 Agent + 一个题目 Agent) |
| 修订执行者 | 重启原创作 Agent(带反馈重做受影响部分) |
| 做题验证 | 两遍都跑(Pass1 仅题干 / Pass2 题干+笔记) |
| 触发方式 | 多 Agent 流程设为默认;老快流程降为 /exampass fast |
流程图
Phase 0 提取 (Python,不变)
run_exampass.py <dir> → .epa_work/_extraction_bundle.json
│
Phase 1 骨架 (串行 · 1 Agent · 读全局)
skeleton-agent → .epa_work/knowledge_skeleton.json (DAG + source_refs)
│ 编排器按 source_refs 切片到 .epa_work/slices/chN_*.json
▼
Phase 2 并行创作 (按章 · 2×N Agent) ┌─ 每章并行 ─┐
for 每章 N: │ │
notes-agent(chN 切片) → notes/chN.html │
item-agent (chN 切片) → questions/chN.json │
│ (两者都只读骨架切片+提取切片,互不依赖)
▼
Phase 3 并行评审 (按章 · 2×N Agent)
for 每章 N:
reviewer-agent(notes/chN) → reviews/chN.json (critical/important/minor)
solver-agent (questions+notes/chN) (两遍法 → 诊断标签)
├ Pass1 仅题干 → diagnostics/chN_p1.json
└ Pass2 +笔记 → diagnostics/chN_p2.json
│ 编排器对照标准答案打最终标签
▼
Phase 4 汇总反馈 (编排器,无 Agent)
收集所有 critical+important 问题 + 可执行诊断标签,按 kc_id/章分组
minor 只记录,不进修订
▼
Phase 5 一轮定向修订 (重启原创作 Agent · 只重做受影响的章)
有笔记问题的章:重启 notes-agent(chN + 反馈) → 覆盖 notes/chN.html
有题目问题的章:重启 item-agent (chN + 反馈) → 覆盖 questions/chN.json
│ 一轮封顶:修订产物不再二次验证
▼
Phase 6 渲染 (现有模板引擎,不变)
按骨架顺序拼接 notes/*.html + questions/*.json
→ template_engine 渲染知识清单 HTML + 章节测试 HTML
→ 同时保存 knowledge_skeleton.json数据工作区
所有中间产物落在目标目录下的 .epa_work/,便于断点续跑、调试、人工检查:
.epa_work/
_extraction_bundle.json # Phase0
knowledge_skeleton.json # Phase1(也作为最终产物保留)
slices/chN_skeleton.json # Phase1 切片:分给下游的知识组件
slices/chN_extract.json # Phase1 切片:对应提取内容(按 source_refs)
notes/chN.html # Phase2/5
questions/chN.json # Phase2/5
reviews/chN.json # Phase3
diagnostics/chN_p1.json # Phase3 Pass1
diagnostics/chN_p2.json # Phase3 Pass2
feedback/chN.json # Phase4 汇总(喂给 Phase5)子 Agent 不把大块内容塞进 prompt:编排器把切片写成文件,prompt 里只给文件路径,子 Agent 自己读。输出也写文件,编排器只收文件。省 token、好调试。
编排器职责(主 Agent 按 SKILL.md 执行)
编排器 = 跑 /exampass 的主 Claude。它不创作内容,只做调度:
1. 跑 Phase 0 脚本。 2. 用 agents/skeleton-agent.md 的 Part 4 prompt 起 1 个 Agent,产骨架。 3. 按 source_refs 切片写盘。 4. Phase 2:对每章并行起 notes/item Agent(用各卡 Part 4 prompt + 切片路径)。 5. Phase 3:对每章并行起 reviewer/solver Agent;solver 分两次调用(Pass1→Pass2);对照答案打标签。 6. Phase 4:汇总 critical+important + 可执行标签 → feedback/chN.json。 7. Phase 5:只对有反馈的章重启对应创作 Agent。 8. Phase 6:拼接 → 调模板引擎渲染 → 打开。
SKILL.md 路由骨架(待写,先给结构)
## 命令路由
- args == "update" → 技能更新(不变)
- args == "graph" / "graph …" → 知识图谱(不变)
- args == "fast" / "fast …" → 【新】老的单 Agent 快流程(原「执行流程」整段挪到这里)
- 其它(空 / 目录路径) → 【新默认】多 Agent 深度流程(Phase 0–6)
## 多 Agent 深度流程
(Phase 0–6 的可执行说明 + 每个 Phase 引用 agents/*.md 的 Part 4 prompt)
## 快速流程(fast)
(现有「执行流程 / 内容质量要求 / 题目规范」整段,作为 fast 分支保留)现有那一大段「内容质量要求」「题目编写规范」不删——它们降级成 fast 分支的指导,同时深度流程的卡片其实是它的升级版。两套共存。
成本与边界
- 成本:一门 5 章的课 ≈ 1(骨架) + 10(创作) + 10(评审) + ≤10(修订) ≈ 30+ 次 Agent 调用,比单 Agent 慢数倍、token 涨数倍。这就是它降为「默认但重」、另留
fast快道的原因。 - 章数过多:若章节 >8,编排器分批起 Agent(一批 ≤8 并行),避免调度过载。
- 子 Agent 失败:按章独立,失败只重试该章,不影响其它章。
- 一轮封顶:Phase 5 的修订产物不再二次验证。若修订引入新问题,本轮不兜——这是「一轮」的代价,可接受。
- answer_suspect:做题 Agent 算出的答案≠标准答案时,标记反馈给题目 Agent 复核;但因一轮封顶,复核后的答案不再过做题 Agent。
- 渲染接口不变:模板引擎仍吃「完整笔记 + 题目集」,编排器只是在调它之前把按章产物按骨架顺序拼好。具体函数签名在接线时核对。
不做的事
- 不联网(已定)。
- 不做多轮迭代(已定一轮)。
- 不改提取管线和模板引擎(两头不动,只换中间的「理解+生成」内核)。
- 做题 Agent 不预测学生分数(卡片已写死)。
ExamPass 出题(子 skill)
由 /exampass final <目录> [--ref <真题>] [--chapter <章名>] 调用。复用多 Agent 内核(骨架 → 蓝图 → 题目 Agent → 做题 Agent 两遍验证 → 一轮修订 → 渲染)。
---
使用方式
/exampass final <课程目录> # 整卷(覆盖全部章节)
/exampass final <课程目录> --ref <真题PDF/图片> # 整卷,模仿真题风格
/exampass final <课程目录> --chapter <章名> # 只出某一章--ref 与 --chapter 可任意组合。
---
交互(先问后做)
出题前先问用户,缺省用括号里的默认值:
1. 范围:整卷 / 指定某章?(默认整卷) 2. 难度:和平时作业差不多 / 中等 / 偏难?(默认中等) 3. 时长:多少分钟?(整卷默认 120,单章默认 40) 4. 题型偏好:有没有不考的题型(如老师说不考代码题)?(默认不限制,由智能选型决定) 5. 联网参考:是否联网参考名校同课程期末题找出题灵感?(默认开;--ref 模式下默认关,因为已有真题)
---
智能题型选择(核心——不再用固定模板)
题型不是预先写死的配比。命题前,先真正分析这门课的内容特征,再决定「出哪些题型、各占多少分」。这一步要显式思考并写进蓝图的 reasoning 字段。
第一步:判断学科性质
读 knowledge_skeleton.json 的 KC 类型分布 + bundle 关键词,判断这门课偏向:
| 信号 | 含义 |
|---|---|
大量 procedure 类型 KC、出现伪代码/算法/求解步骤 | 偏「会做」——计算题、代码题、综合题权重高 |
大量 principle 类型 KC、定理/证明/推导 | 偏「会证」——计算题、证明类问答、综合题 |
大量 concept / fact 类型 KC、定义/分类/术语 | 偏「会辨」——选择、判断、填空、简答 |
| 出现代码块、API、编程语言关键词 | 一定出 code 题 |
| 纯文科(文学/历史/政治/外语),无公式无代码 | 绝不出 calc/code |
第二步:按「这门课实际怎么考」分配题型与分值
不要机械套用「选择 20 + 判断 10 + …」。而是问自己:如果我是这门课的老师,我会怎么考?
- 算法课:会算(主定理、DP 表、复杂度)→ calc 占比高;会写(伪代码/代码)→ code;会比较(贪心 vs 动规)→ 综合题。选择题不该是大头。
- 数学课:calc 是绝对主力,配少量概念选择。
- 理论课(如可计算性):偏证明与辨析 → 问答 + 判断 + 选择,calc/code 少。
- 文科:fill + short + essay 为主。
把「为什么这样配题型」写进蓝图 reasoning。枢纽概念(is_hub)必须有应用/分析级的题,不能只用记忆题带过。
第三步:难度梯度(硬约束)
basic(送分)约 25% / medium(中档)约 45% / hard(拉分)约 30%,不得过度集中在任一档。每题标 difficulty。
---
--ref 真题模仿(并入,原 mock 功能)
给了 --ref <真题> 时,在「智能题型选择」之前先做真题风格分析:
1. 读取真题(PDF 走 extractor,图片走 OCR / 多模态)。 2. 逐题解析以下维度,写入 .epa_work/exam_reference.md:
- 题型谱:各题型出现频率 + 分值占比
- 分值分布:哪些章/知识点考得多、哪些题分重
- 措辞风格:题干长度、问法模式(「证明…」「求…」「判断…」「举反例说明…」)、术语偏好
- 难度层次:送分/中档/拉分的比例
- 特色题型:这套卷独有的出题套路
3. 蓝图的题型配比与措辞对齐真题(覆盖范围仍以 PPT 为准——只学风格不抄内容)。
没给 --ref 但开了联网:用 scripts/web_research.py / WebSearch 搜同类课程期末题作参考,同样写入 exam_reference.md,仅取灵感。
---
流程
1. 骨架:.epa_work/knowledge_skeleton.json 已存在则复用,否则用 agents/skeleton-agent.md 现产。 2. 风格参考:--ref → 真题分析;否则联网(可关)。写 exam_reference.md。 3. 考试蓝图:智能选题型 + 难度梯度,分值合计 100(单章模式按时长折算,如 40 分钟 ≈ 50 分),覆盖目标范围、枢纽概念权重更高。写 .epa_work/exam_blueprint.json,含 reasoning 字段说明题型取舍依据。 4. 命题:用 agents/item-agent.md Part 4,按蓝图全局命题。 5. 验证:用 agents/solver-agent.md 两遍法(先仅题干、再题干+笔记),重点抓 answer_suspect / ambiguous / leak。 6. 一轮修订:修被标记的题;校验分值合计 = 100(或折算值)、覆盖无遗漏、ABCD 均匀。 7. 渲染:save_test() 生成交互式试卷 HTML,save_knowledge_html() 生成答案 HTML(含逐题解析)。浏览器打开,Ctrl+P 打印 PDF。
---
题目编写规范(命题硬规则)
支持的题型(type 字段):
| type | 题型 | 批改 | options | answer |
|---|---|---|---|---|
choice | 单选 | 自动 | ["内容",...](不带ABC前缀) | 正确项索引 0 或 "A" |
multi | 多选 | 自动(少选给部分分) | 同上 | [0,2] 或 ["A","C"] |
tf | 判断 | 自动 | [] | 0(正确)/1(错误) |
fill | 填空 | 自动(题干用 ____ 标空位) | [] | "答案" / ["同义词"] / [["空1","同义"],["空2"]] |
short | 简答 | 显示参考答案 | [] | -1 |
calc | 计算 | 显示参考答案 | [] | -1 |
code | 代码(深色等宽输入框) | 显示参考答案 | [] | -1 |
essay | 问答 | 显示参考答案 | [] | -1 |
comprehensive | 综合 | 显示参考答案 | [] | -1 |
编写要求:
- 题干不加编号,选项不加 A/B/C/D 前缀(模板自动添加)。
- choice/multi:干扰项基于学生真实误解设计(概念混淆/条件缺失/因果倒置/步骤缺环/场景错配,至少 3 类),有真实迷惑性;禁止「以上都对/都错」和荒谬项。
- choice【硬约束】:出完所有 choice 题逐字母统计 A/B/C/D 出现次数,各字母差 ≤1 且无连续三题同一答案;不达标逐题重排直到通过。
- fill:空位写
____(≥2 下划线),多空按顺序对应。 - calc:题干给完整已知条件,explanation 含完整解题步骤 + 评分要点,自己逐步算过、答案唯一可辩护。
- code:题干说明输入输出,explanation 用
<pre><code>...</code></pre>给参考代码 + 关键点。 - essay/comprehensive:跨概念串联,考逻辑链条。
- 每题写
kc_id、cognitive_level(remember/understand/apply/analyze)、difficulty(basic/medium/hard)。
JSON 安全(硬约束):
- 字符串值内禁止裸双引号 `"`,需要引号用「」。
- 文本里的不等号/小于号必须转义:
low<high写成low<high(裸<会被当 HTML 标签吞掉后续内容)。 - LaTeX 命令在 JSON 字符串内写双反斜杠:
\\frac、\\sum。 - 数值字段不加引号:
2不是"2"。 - 写完用
json.loads()自检。
explanation 示例:
{
"type": "calc", "points": 8, "kc_id": "kc2.4",
"cognitive_level": "apply", "difficulty": "medium",
"question": "用主定理求解 T(n)=2T(n/2)+n 的渐近紧界。",
"answer": -1,
"explanation": "<strong>解</strong>:a=2,b=2,log_b a=1,f(n)=n=Θ(n¹),属情况2 → Θ(n log n)。<br><strong>评分要点</strong>:参数 2 分,判断情况 3 分,结论 3 分。",
"pitfall": "常见错误:把情况2误判为情况3。"
}ExamPass 知识图谱(子 skill)
由 /exampass graph <目录> 调用。生成左根右叶的交互式知识图谱:可编辑、粘贴图片、搜索、缩放、左右面板拖拽调宽。---
流程
1. 提取:python scripts/run_exampass.py <目录>(.epa_work 已存在可复用)。 2. 骨架获取(按优先级):
.epa_work/knowledge_skeleton.json已存在 → 直接读取(推荐先跑一次默认流程产骨架)。- 骨架不存在但子 Agent 可用 → 用
agents/skeleton-agent.md现产一份。 - 骨架不存在且子 Agent 不可用(Codex / 非 Claude 平台)→ 回退:
build_fallback_tree(bundle_path, title)直接从提取文本生成树(保证 Codex 用户也能拿到图谱而非卡片)。
3. 转换 + 渲染:
import json, os
from scripts.knowledge_graph import skeleton_to_graph_tree, build_fallback_tree
from scripts.template_engine import save_graph_html
# 多章课程:bundle 在 .epa_work/chapters/<章>/_extraction_bundle.json
skeleton_path = '.epa_work/knowledge_skeleton.json'
title = '课程'
if os.path.exists(skeleton_path):
with open(skeleton_path, encoding='utf-8') as f:
skeleton = json.load(f)
tree = skeleton_to_graph_tree(skeleton)
title = skeleton.get('title', title)
else:
# 回退:从任一 bundle 直接生成(无骨架 Agent 时)
tree = build_fallback_tree('.epa_work/chapters/<某章>/_extraction_bundle.json', title)
title = tree['title']
save_graph_html(tree, 'EPA/知识图谱.html', title)4. 打开:浏览器打开 HTML。
---
图谱特性
- 左根右叶树布局:左=章节,右=知识组件,从上到下排开。
- 依赖虚线(
deps):画成虚线次级连接,告诉你"学谁之前先学谁"。 - 枢纽概念(
is_hub):加 ★ 标记——这些是考试重中之重。 - 节点 tooltip:悬停显示「类型 · 重要度 · 依赖:xxx」。
- 叶节点笔记卡:点击叶节点右侧弹出,可打字、Ctrl+V 粘贴图片,存 localStorage,刷新仍在。
- 左右占比可拖拽:拖中间分隔条调整树/笔记宽度。
- 搜索:顶部输入关键词高亮命中节点。
- 缩放:底部缩放条 / +- 按钮。
- 重命名:双击节点标签改成自己的话。
回退树质量:回退模式只生成章节→要点两层,不含 type/importance/deps/is_hub 元数据(这些需要骨架 Agent)。首次建议先跑 /exampass <目录> 产骨架,再 /exampass graph 复用。
从已有 JSON 重新生成:用户给 --from-json <路径> 时直接读取——已是 tree 格式(含 nodes)则跳过转换,是骨架格式(含 chapters)则先 skeleton_to_graph_tree() 再 save_graph_html()。
知识清单写作规范
默认流程的笔记 Agent(agents/notes-agent.md)和渲染遵循本规范。核心硬规则也在 notes-agent 卡里同源。根本原则:把逻辑嚼碎了喂给用户
PPT 的问题是信息碎片化、逻辑跳跃、缺少因果链。知识清单要做的不是「提炼要点」,而是重构叙事——把分散在几十张幻灯片的信息,重组成一条清晰的逻辑链:「因为遇到什么问题 → 所以提出什么方法 → 核心思想是什么 → 具体怎么做 → 有什么局限 → 怎么改进」。
目标不是「总结得短」,而是「读一遍就懂」。
叙事弧(每个 H3 知识点)
Hook(一句话场景/谜题)→ TL;DR(`<div class="tldr">`粗体结论)→ Why(为什么需要它)→ What(是什么,此时才给定义)→ How(怎么用,例子/推导/范例)→ Checkpoint(`<div class="checkpoint">`自测,配可折叠 `<details class="cp-answer">` 参考答案)。
每个概念至少回答:是什么 / 为什么需要它 / 怎么用·易错在哪。抽象定义永远不能出现在具体例子之前。
标注与格式
- 双色标注:核心概念/定义/公式包
<span class="kp">(黑色加粗,扫读锚点);解释/动机/举例包<span class="exp">(浅灰细体)。 - 四色重要程度标签(每个 H3 结尾):
| 标签 | class | 含义 |
|---|---|---|
| 必考 | tag-must | 综合/问答核心,能默写+推导 |
| 重点 | tag-key | 简答高频,能用自己话解释 |
| 高频 | tag-freq | 选择/判断常考,记关键区别 |
| 了解 | tag-info | 知道名字和作用 |
- 易错框:
<blockquote>易错:XXX 和 YYY 的区别不在于 A,而在于 B。考试常设陷阱是…</blockquote> - 公式:独立
$$...$$,行内$...$,公式后紧跟文字解释每个符号的含义。 - 对比概念必配表格。
- PPT 页码标记:每个 H2/H3 标题末尾追加
<span data-slides="页码">(取自该 KC 的 source_refs),渲染时变成[页N]chip,点击滚到右侧对应幻灯片。 - 只写 H2/H3(引擎自动加 H1+目录);箭头用
-->,引号用「」,不用 Unicode 特殊字符。
深度示例
错误(太浅):
<h3>束搜索</h3>
<p>每步保留K个最优前缀。K=1退化为贪婪搜索。</p>正确(嚼碎了,带叙事弧):
<h3>束搜索 <span class="tag-must">必考</span><span data-slides="42"></span></h3>
<p><strong>Hook:</strong>贪婪解码每步只留概率最高的一个词,选错了回不去——整句就毁了。</p>
<div class="tldr"><span class="kp">束搜索 = 每步多留 K 个候选,最后选整体最好的</span></div>
<p><strong>Why:</strong>「每步最优」不等于「整句最优」。<strong>What:</strong>每步保留 $K$ 个最可能前缀…<strong>How:</strong>第1步选概率最高的 $K$ 个;后续每步 $K$ 个前缀各扩展 $|V|$ 词,选 $K$ 个最优。复杂度 $O(K|V|)$/步。</p>
<blockquote>易错:束搜索是启发式,不保证全局最优。判断题「束搜索保证全局最优」是错的。</blockquote>
<div class="checkpoint">K=1 时束搜索退化成什么?<details class="cp-answer"><summary>参考答案</summary>退化为贪婪解码。</details></div>图片识别与平台适配
非多模态模型读不了 PPT 里的图表/公式截图,会丢信息。处理: 1. 多模态(如 Claude):直接把提取图片作视觉输入分析。 2. 非多模态:先 OCR/解析转文字——优先 MinerU(pip install magic-pdf,学术 PDF 公式表格识别最好),兜底 PaddleOCR/Tesseract。脚本已提取图片到临时目录。 3. 判断:bundle 中 images 非空且模型非多模态 → 提示启用 MinerU/OCR。
常见技术坑
1. Python 内联代码过长 → 写成 .py 执行。 2. $$ 内 \text → Python 字符串写 \\\\text。 3. 中文引号被转义 → 用「」。 4. Unicode → 导致 SyntaxError → 用 -->。 5. 题目 explanation 内的 $ → JSON 内无需转义。 6. tf 题 "options": [],模板自动显示「正确」「错误」。
ExamPass Assistant <sup>v2.0</sup>
把课堂讲义变成考试利器。 一键将 PPT、Word、PDF 课件转化为结构化知识清单、交互式测试题、知识图谱和仿真期末试卷。
English
🎉 v2.0 新功能 — 每条笔记旁的 Notion 风原始 PPT 对照栏、合并页(知识清单 + 章节测试同页顶部切换)、自测题带答案、按真题风格模仿出题、更强的教学法(钩子 → 一句话结论 → 为什么 → 是什么 → 怎么用 → 自测)、提速一倍的流式流水线,以及一大批用真实浏览器截图验证过的渲染加固。详见 v2.0 更新日志。
---
适用场景
| 角色 | 用途 |
|---|---|
| 大学生 | 上传课程 PPT/讲义,自动生成知识清单 + 交互式章节测试 + 知识图谱 + 仿真期末卷,高效通过期末考试 |
| 授课教师 | 课件一键转化为结构化知识总结,自动生成配套习题+答案解析,直接用于课堂教学或课后作业 |
| 考研/考证 | 参考书 PDF 转为精简知识清单,配合自测题检验掌握程度 |
核心功能
- 支持 PPTX / DOCX / PDF,递归扫描目录,按章节自动分组
- 提取文字、表格、图片(Claude 多模态分析;非多模态模型自动走 MinerU/PaddleOCR 回退)
- 默认流程(多 Agent):骨架 Agent → 笔记+题目并行创作 → 流式审查/做题/修订 → 渲染合并页
- 生成知识清单:MathJax 公式完美渲染,双色标注(知识点黑色加粗 + 解释浅灰细体),四色重要程度标签(必考/重点/高频/了解),自动目录导航,钩子→TL;DR→为什么→是什么→怎么用→自测 叙事弧,自测题带可折叠参考答案
- 原始 PPT 对照栏(v2.0 · 默认开启):右侧 Notion 风栏把整页幻灯片渲染图 + 该页原文贴在笔记旁,对照防漏;点标题旁
[页N]小链接右栏滚到对应幻灯片;密度可选(全部页/重点页/不放) - 合并页(v2.0 新增):知识清单 + 交互测试同一 HTML,顶部 tab 切换(📖 知识清单 | 📝 章节测试)
- 生成交互式章节测试:9 种题型(选择/多选/判断/填空/简答/计算/代码/问答/综合),按学科自动选题型——算法课出 calc+code,文科不出计算题。点击选项→一键批改→逐题解析+易错提醒
- 生成交互式知识图谱(
graph子命令):左章右组件树布局,依赖虚线连接,枢纽概念 ★ 标记,悬停 tooltip,叶节点可编辑笔记卡(粘贴图片、刷新保留),左右占比可拖拽,顶部搜索,底部缩放 - 出题(
final子命令):整卷/单章/按真题风格(--ref),智能选题型(分析课程实际考法而非固定模板),蓝图分值恰好 100 分、全章覆盖、枢纽权重更高、难度有梯度,做题 Agent 两遍验证 - 分析结果自动缓存,同目录再次运行秒级出结果
- 浏览器打开即用,响应式适配手机,Ctrl+P 打印为 PDF
快速开始
git clone https://github.com/WUBING2023/ExamPass-Assistant.git
cd ExamPass-Assistant
pip install -r requirements.txt命令列表
| 命令 | 说明 |
|---|---|
/exampass <目录> | 默认流程 — 每章产出合并页:知识清单(PPT 对照栏默认开启)+ 章节测试 |
/exampass graph <目录> | 知识图谱 — 交互式树图:依赖虚线、枢纽★、笔记卡、拖拽、搜索、缩放 |
/exampass final <目录> [--ref <真题>] [--chapter <章名>] | 出题 — 智能选题型(分析课程而非固定模板);--ref 模仿真题风格;--chapter 只出某章 |
/exampass update | 一键更新到最新版本(拉取代码+安装依赖) |
v2.0 精简命令:去掉fast(默认已够快),mock并入final --ref。PPT 对照栏从选配变为默认开启。
多 Agent 编排流程
默认 /exampass 命令调度 5 个专业子 Agent:
| 阶段 | Agent | 产物 |
|---|---|---|
| 0. 提取 | run_exampass.py | _extraction_bundle.json |
| 1. 骨架 | skeleton-agent | knowledge_skeleton.json(章→KC 依赖图)+ 按章切片 |
| 2. 并行创作 | notes-agent + item-agent(每章并行) | notes/chN.html + questions/chN.json |
| 3. 并行评审 | reviewer-agent + solver-agent(两遍法) | reviews/chN.json + 诊断标签 |
| 4. 汇总反馈 | 编排器聚合 critical/important | feedback/chN.json |
| 5. 定向修订 | 仅重做受影响章节 | 修订后的笔记和题目 |
| 6. 渲染 | template_engine | 知识清单 HTML + 章节测试 HTML |
所有中间产物在 .epa_work/。编排器(主 Claude)只调度不创作——内容由子 Agent 按 agents/ 下的卡片产出。
在代码中调用
from scripts.template_engine import save_knowledge_html, save_test, save_graph_html
from scripts.knowledge_graph import skeleton_to_graph_tree
# 知识清单:HTML body 直接传入(引擎自动加 H1 + 目录)
body = '<h2>一、序列建模基础</h2>\n<h3>1.1 什么是序列数据</h3>\n<p>...</p>'
save_knowledge_html(body, '知识清单.html', '第15章 序列生成模型')
# 交互式测试:题目列表直接传入
questions = [
{"type": "choice", "points": 2,
"question": "语言模型的核心功能是什么?",
"options": ["翻译", "评估句子概率", "分词", "识别物体"],
"answer": 1, "explanation": "语言模型计算词序列概率...",
"pitfall": "注意区分语言模型和机器翻译"},
]
save_test(questions, '章节测试.html', '第15章', '满分 100 分', duration_minutes=30)
# 知识图谱:骨架转交互式 DAG 可视化
import json
with open('knowledge_skeleton.json') as f:
skeleton = json.load(f)
tree = skeleton_to_graph_tree(skeleton)
save_graph_html(tree, '知识图谱.html', tree['title'])项目结构
EPA/
├── SKILL.md # /exampass 入口(命令路由)
├── agents/ # 子 Agent 卡片(方法论 + prompt)
│ ├── skeleton-agent.md # 知识架构师 — 构建章→KC 依赖图
│ ├── notes-agent.md # 笔记写手 — 深度学习模式叙事
│ ├── item-agent.md # 命题专家 — 按学科自动选题型
│ ├── reviewer-agent.md # 内容审查 — 正确性与完整性
│ └── solver-agent.md # 做题验证 — 两遍法诊断
├── scripts/
│ ├── run_exampass.py # 单脚本提取入口
│ ├── scanner.py # 递归扫描与分组
│ ├── extractor.py # 统一提取调度(PPTX/DOCX/PDF)
│ ├── extract_pptx.py # PPTX 提取(文字+表格+图片)
│ ├── extract_docx.py # DOCX 提取
│ ├── extract_pdf.py # PDF 提取
│ ├── image_extractor.py # 图片提取(供 Claude 多模态分析)
│ ├── ocr_backend.py # OCR 回退(非多模态模型用)
│ ├── template_engine.py # HTML 模板引擎(知识清单/测试/图谱/合并页)
│ ├── slide_renderer.py # 整页 PDF 渲染(PPT 对照栏)
│ ├── knowledge_graph.py # 骨架→图谱树转换(含回退)
│ ├── html_generator.py # 快速生成器
│ ├── generate_cached.py # 缓存加速(二次运行秒出)
│ ├── knowledge_analyzer.py # 知识清单分析 prompt
│ ├── test_generator.py # 测试题生成 prompt
│ ├── exam_generator.py # 期末试卷 prompt
│ ├── web_research.py # 网络调研
│ └── utils.py # 通用工具
├── templates/
│ ├── base.css # 共享样式(暖色纸张、双色标注)
│ ├── test.css # 交互测试样式
│ ├── graph.css # 知识图谱样式(树布局)
│ ├── graph.js # 图谱渲染器(D3 — 虚线依赖/tooltip/笔记卡)
│ ├── page_template.html # HTML 页面模板
│ ├── graph_template.html # 图谱 HTML 模板
│ ├── test_js_template.js # 测试页 JS 模板
│ └── test_labels.json # 中文标签配置
├── tests/ # 146 个测试用例
└── requirements.txtv2.0 更新日志
新功能
- 原始 PPT 对照栏 ——
slide_renderer.py用get_pixmap把整页 PDF 渲染成图,连同该页原文(可折叠)钉在笔记右侧;base64 内嵌,HTML 仍是单文件可分享。密度可选(全部页 / 重点页 / 不放)。标题自动生成[页N]小链接,点击右栏滚动到对应幻灯片。 - 合并页 ——
save_combined_html把知识清单和交互测试放进同一文件,顶部 tab 切换(📖 知识清单 | 📝 章节测试)。 - 自测题带答案 —— 笔记里每个自测框都配可折叠参考答案(先问后答、主动回忆)。
- 智能出题 ——
final命令不再用固定模板,而是分析课程实际怎么考再定题型配比;--ref按真题风格出题,--chapter只出某章。 - 教学法升级 —— 笔记按 钩子 → TL;DR → 为什么 → 是什么 → 怎么用 → 自测 的叙事弧组织,目标"读一遍就懂"。
- 流程提速一倍 —— 单章流式审查/反馈/修订,不再等所有章;已生成章节缓存跳过。
- 知识图谱 —— 非 Agent 回退(Codex 用户也能拿到真图谱而非卡片)、左右占比可拖拽、移动端布局。
- 代码样式 —— 现代等宽字体栈 + 深色代码面板(伪代码/数组)。
渲染加固(全部用真实浏览器 Playwright 截图验证)
- 题干里的裸
<(如low<high)被浏览器当成未闭合标签,吞掉后面所有题——现在裸<转义、真标签保留。 - 笔记若输出成完整暗色 HTML 文档,其
<style>会泄漏破坏页面——现在剥离成 body 片段。 - 长公式不再溢出栏/盖到 PPT 栏(自动缩放 + 滚动)。
- 手机端横向溢出、幻灯片卡塌缩、测试卡 0 高度(MathJax 在隐藏面板渲染)等全部修复。
贡献者
- 开发与维护:@WUBING2023
- 启发性贡献:yaxing@cvc.uab.es
- 测试:@YeMoonlight
- 测试:@Yuzhihan-zyr
许可证
本软件采用 Creative Commons BY-NC 4.0 许可证。
- 允许自由使用、修改、再分发(需署名)
- 禁止商业用途
完整条款见 LICENSE。
Copyright (c) 2025 ExamPass Assistant Contributors
python-pptx
python-docx
pdfplumber
pymupdf
pytest
pytest-mock
Pillow
reportlab
"""Extract content from PDF files."""
import os
from typing import Dict, Any, List
def extract_pdf(filepath: str) -> Dict[str, Any]:
"""
Extract text and tables from a PDF file.
Returns: {
'pages': [{'number': int, 'text': str, 'tables': [...]}],
'total_pages': int,
'filename': str
}
"""
import pdfplumber
pages = []
with pdfplumber.open(filepath) as pdf:
for page_idx, page in enumerate(pdf.pages, start=1):
page_data = {
'number': page_idx,
'text': '',
'tables': [],
}
text = page.extract_text()
if text:
page_data['text'] = text.strip()
tables = page.extract_tables()
for table in tables:
if table:
cleaned = []
for row in table:
cleaned.append([cell or '' for cell in row])
page_data['tables'].append(cleaned)
pages.append(page_data)
return {
'pages': pages,
'total_pages': len(pages),
'filename': os.path.basename(filepath),
}
def build_text_summary(data: Dict[str, Any]) -> str:
"""Build a readable text summary from extracted PDF data."""
lines = [f"# {data['filename']}", f"共 {data['total_pages']} 页\n"]
for page in data['pages']:
lines.append(f"\n## 第 {page['number']} 页")
if page['text']:
lines.append(page['text'])
for i, table in enumerate(page.get('tables', [])):
if not table:
continue
lines.append(f"\n### 表格 {i+1}")
if table[0]:
lines.append("\n| " + " | ".join(table[0]) + " |")
lines.append("|" + "|".join(["---"] * len(table[0])) + "|")
for row in table[1:]:
cols = len(table[0]) if table else len(row)
padded = row + [''] * (cols - len(row))
lines.append("| " + " | ".join(padded[:cols]) + " |")
return '\n'.join(lines)