把一批单词写成一篇有情节的短文,句子级中英对照,点一下就能把中文盖在英文上。
单词书失效的原因是「词 → 中文释义」这种成对记忆没有可挂靠的语境。这个项目的核心不是 「让 AI 造几个例句」,而是保证每个目标词出现时,上下文足以让人推出词义—— 这才是读文章记单词真正起作用的机制。
双击 run.bat。 就这一步。
首次启动会自动建虚拟环境、装依赖、下载 CEFR-J 词表(一两分钟),之后每次启动一两秒。 然后去设置页填 API Key、跑一次四层检验,就能开始生成了。
main.py 起一个只监听 127.0.0.1 的本地服务,再用 Edge 打开它。
弹出来的黑色控制台窗口就是服务本身,关掉它程序就退出。
界面是 web/ 那一套 HTML/CSS/JS。只有这一套。 早先桌面用 PySide6 控件另画了
一份 UI,和网页版实现的是同一个产品,代价立刻就出现了:每加一个功能要写两遍,
而漏掉的那一遍必然是后写的那端——词库页、每个词的命中/未命中标记、
文中残留的超纲词,最后都只有网页版有;生成中的「停止」又只有桌面版有。
找不到 Edge 时会退回系统默认浏览器;也可以用环境变量 WL_EDGE 指定浏览器
可执行文件的路径。8000 端口被占用时自动换一个,控制台里会印出实际地址。
打包会把某一时刻的代码定格。这个启动器相反——它每次都跑当前源码, 并且在启动前先把环境修好:
| 情况 | 它做什么 | 实测 |
|---|---|---|
.venv 不存在 |
找一个 Python 3.10+ 建环境并装依赖 | 从零 22s |
requirements.txt 变了 |
比对 SHA256,变了才重装 | 2s |
| 没有任何变化 | 直接启动 | 0.7s |
| 包被删了 / 装坏了 | 检测到就 --force-reinstall 补回来 |
13s |
| 项目被移动、拷到别的机器 | 检测到 venv 失效,自动重建 | 18s |
| CEFR 词表缺失 | 自动下载(失败也不影响运行,退回内置兜底表) | — |
所以之后不管代码怎么改、加了什么依赖,双击它都还能用。
run.bat 正常启动
run.bat --check 只检查并修好环境,不启动应用(顺带报告能不能找到 Edge)
run.bat --reset 删掉 .venv 完全重建,然后照常启动
run.bat --reset --check 重建完就停下,不启动
--reset 重建完会接着启动——它是「环境坏了,重置一下继续用」,不是「只重建」。
想重建完就停下,加上 --check。
--reset 只删 .venv。文章、词条、学习状态在 data/app.db,API Key 在
config/settings.local.json,都不受影响。
每次启动前会自动给 data/app.db 留一份快照,放在 data/backups/,只保留最近 5 份,
库没被写过就不重复留档。理由是这个应用里文章可以重新生成,学习状态不能——
掌握程度和累计语境是一次次阅读攒出来的,丢了补不回来。
快照走 SQLite 自己的 backup API 而不是复制文件:它会正确处理锁,源库损坏时直接报错, 不会产出一个「看着像备份」其实打不开的文件,也不会把坏数据覆盖到已有的好快照上。 备份失败绝不阻塞启动,但会在设置页的「数据」卡片里标红——默默失败的备份比没有备份更糟。
有两个坑是实测踩出来才加的防护:
- pip 只看
.dist-info元数据。 包目录被删了它照样报 "already satisfied" 然后跳过,文件永远补不回来。所以检测到损坏时必须--force-reinstall。 - MSYS2 / Cygwin 版的 Python 会建出
bin/布局的 venv,而且装不上带编译扩展的包。 启动器建完环境会检查Scripts\python.exe在不在,不在就直接报错并清理, 免得留下一个装不上东西的坏环境。
控制台窗口一直开着,报错就印在里面——启动失败时它会停住等你按键,不会一闪而过。
先看那里;还不行就 run.bat --reset(只删 .venv,不动你的数据)。
.\.venv\Scripts\python.exe main.py机械校验只能验「词出现了没有」,验不了「读者能不能猜出词义」。而后者才是 读文章记单词唯一起作用的机制——词出现了但没有线索,这一遍等于白读,跟单词书没区别。
所以管线里专门有一次调用,让模型扮演完全不认识这些词的读者,逐个判断:
| 判定 | 标准 |
|---|---|
| strong | 有同义改写 / 正反对比 / 具体到能看见画面的场景,不认识也能猜对 |
| weak | 有方向性提示,但猜出来可能偏差很大 |
| none | 把这个词换成任何生词句子照样通顺 |
判 weak 或 none 就重写补线索,重写后复审,最多两轮。补完还要跑一遍机械校验—— 补线索不能把难度或对齐搞坏,坏了就丢弃这次改写。
审计是真会判死刑的,不是橡皮图章。拿一段刻意无线索的文本考它:
accurate none 理由: 全文没有任何线索说明 accurate 是准确,
换成 rough 或 quick 句子也通顺
它用的正是替换测试。实际生成中也真的触发过:tedious 首轮被判 weak,
重写后补上 not hard, just dull and repetitive,复审 strong。
模型一次性输出句子级结构:
{"sentences": [
{"en": "...", "zh": "...", "targets": [{"lemma": "abandon", "surface": "abandoned"}]}
]}事后处理会在三个地方翻车,都是别人踩过的坑:
- 切句:英文句号切分必然在
Mr./U.S./ 引号内句号上出错,正则永远补不完。 - 对齐:整篇翻译时模型经常把两句英文并成一句中文,句级对应关系直接崩掉, 「每句盖住」这个功能就废了。
- 词形:你给
abandon,文中出现abandoned。让模型标出原形与实际形态, 比前端跑词干还原可靠得多。
顺带一个反直觉的决定:不要模型给字符偏移量。模型数字符位置经常错位,
高亮位置由前端按 surface 做词边界匹配自己算,稳得多。
Prompt 里写「不要用生僻词」基本没用,模型对「生僻」的判断很飘。所以:
- 用 CEFR-J 词表(8653 词,A1–C2)当尺子;
- 每段生成后程序化扫描,词形还原后凡是不在【目标词 ∪ 该等级以内】的就是超纲词;
- 超出配额就把这些词列出来,让模型定点重写这一段。
CEFR-J 只有 8653 条,缺一大批常用词——toward、color、program、realize
都不在里面,而它们每篇都会出现。所以查不到时回落到词频:拿背单词页那份
33217 词的 ECDICT 表(同一个仓库里,本来就为词典提交的),按词频排名给一个
等级。两条守住方向:只回落不覆盖(CEFR-J 是人工分级,词频只是代用品),
没有证据就不放行(词典里也查不到的照旧判超纲)。
阈值不是拍的:拿两张表都有的 7915 个词标定,取各档词频的 25 分位。 为什么是 25 分位而不是中位——CEFR-J 不认识的词先验上更难,所以要按它认识的 那批里最难的四分之一来卡。把 C1/C2 藏起来假装它不认识,验出来的泄漏率是 3.2%, 落在 CEFR-J 自己相邻档的噪声以内。实测两段 B2 文本超纲词 6→0、9→1, 而一段刻意堆 C1/C2 的文本 16→16,一个没放松。
专有名词只认一条独立于模型的实据:这个大写词在别处以大写出现在句子中间。 真名字在叙事里几乎一定会(人名大量出现在句中),而句首大写只是句子开头, 不构成任何证据——所以句首词照常检测,模型把生词放句首也绕不开标尺。
选题阶段模型确实会把人名地名报进 names,但那不是放行凭据:被检查的一方
不能控制检查器。prompt 里那句「漏报了会被当成超纲词退回来重写」是单边施压,
多报没有代价、少报要挨一次重写,模型自然会多报——构造一下能把超纲率从 26%
压到 7%,而界面上只会显示一个更好看的数字。这份申报换了个身份留下来:
对申报过的名字,修复指令改成「别换掉它们,让它们至少在某句话的中间出现一次」,
改完下一轮那条实据就成立了,是个能自己收敛的循环。
只在句首露过面的名字会被退回来重写一次,之后用户点一下「这个词不用管」,
从此不再犯。
配额同时看比例和绝对值:max(1, 2% × 本段词数)。只看比例的话,
几十个词的短段里一个稍难的词就能顶破 2%,白烧一次修复调用。
研究(SRS-Stories, arXiv 2512.18362)的经验区间是每篇 10–20 个目标词, 但那是在「其余全部是已知词」的前提下;生词密度更高时要往下压。 本项目按每段 3 个词、最多 6 段规划,超出就提示分批—— 硬塞会让文章退化成填空作业,反而破坏语境记忆。
模型说塞不进的词,程序不会替它硬塞。 选题阶段模型可以把容不下的词放进
unplaced,那些词就真的不写进文章,而是在结果里标成虚线 chip 如实报出来。
这条看着像少干了活,其实是刻意的:交一篇稀释过的文章,用户看到的是「生成完成」,
没有任何线索说明哪里不对;如实报出来,用户第一眼就知道该换个题材再生成一篇。
能看见的失败永远优于看不见的成功——这个产品里用户没法自己验收质量,
所以任何时候都要把沉默的失败换成可见的失败。
DeepSeek 和 Kimi 都兼容 OpenAI Chat Completions,所以共用一个
OpenAICompatProvider。再接一家模型只需要往 config/providers.yaml 加一段配置,
不写任何代码——智谱、通义、OpenRouter、本地 Ollama 都一样。
| DeepSeek | Kimi (Moonshot) | |
|---|---|---|
| base_url | https://api.deepseek.com/v1 |
https://api.moonshot.ai/v1 |
| 模型 | deepseek-v4-flash / deepseek-v4-pro |
kimi-k3 / kimi-k2.6 / kimi-k2.7-code |
| JSON | 只有 json_object |
json_object + Structured Output |
| 注意 | prompt 必须含 "json" 字样且给样例;官方承认偶尔返回空 content;思考模式下采样参数静默失效 | thinking 走 extra_body;partial 在 message 内 |
模型名一律走配置,不进代码。 kimi-k2.5 和整个 moonshot-v1 系列已于
2026-08-31 下线——任何把模型名写死在代码里的项目当天就全线报错。
设置页还能从 /v1/models 实时拉列表,以服务端为准。
deepseek-v4-flash 是推理模型,max_tokens 把思考 token 一起算进去。
实测同一个写作 prompt 的思考量:552 / 2316 / 2389 / 2518 / 2633 / 3110,
长尾能冲到 8001 和 9000。预算给不足,思考就把正文挤成空字符串——
DeepSeek 文档里那句「偶尔返回空 content」根因就在这,不是玄学。
第一次跑 8 个词的完整文章:369 秒、44663 tokens、6 次调用,其中 2 次是被思考
吃光预算后的废重试。把 reasoning.headroom 从 6000 提到 12000 之后:
| 修复前 | 修复后 | |
|---|---|---|
| 耗时 | 369s | 138s |
| tokens | 44663 | 19467 |
| LLM 调用 | 6(2 次废重试) | 4(零浪费) |
关键在于预算是上限不是预留:实测 max_tokens 开到 20000 时思考反而只用了
2389(比开 9000 时的 3110 还少),思考量与预算无关、不会「涨满」。
所以预算开大是免费的——正常情况一分钱不多花,抖动时省下整整一次 30 秒的重试。
思考要不要关?不关。 同一段落实测对比:
| 耗时 | 超纲率 | 句均词数 | 样句 | |
|---|---|---|---|---|
| 思考开 | 26s | 0–1.5% | 13–15 | like judging a mountain for rolling downhill |
| 思考关 | 4s | 5.5% | 12 | He never guessed his room could fail so fast and carelessly |
语境线索质量是这个产品的全部价值,26 秒买它值得。只有健康检查的 JSON 探针
(purpose="probe")关掉思考——要一个 {"ok":true} 而让模型思考几千 token
纯属烧钱。
另外两条实测结论,省得你重走:
reasoning_effort: "low"无效(思考 2316,与默认 2389 无差别),别在这档位上花时间。- 关思考的正确写法是
thinking: {"type": "disabled"};thinking: false会报 400 (invalid type: boolean, expected struct ThinkingOption)。reasoning_effort: "none"同样有效。 - 思考模式下
temperature被静默忽略。配置里的temperatures留着是为了关思考时 和换非推理模型时仍然生效,但对deepseek-v4-flash的默认路径不起作用。
只测连通性是不够的。很多模型 key 有效、模型名也对,但就是不肯稳定吐合法 JSON, 或者中文翻译整片漏掉——那种情况要等生成完一整篇废文才发现。
| 层 | 测什么 | 怎么测 |
|---|---|---|
| L1 连通性 | key、网络、base_url、模型是否还在 | GET /models |
| L2 JSON | 真的支持 json_object 吗?会返回空吗? |
要一个 {"ok": true} |
| L3 任务验收 | 它能不能干我们的活 | 给 3 个词生成 2 句话,验 schema 完整、中英一一对齐、目标词命中、中文非空 |
| L4 审计校准 | 它判不判得准 | 一段文本里埋一个强线索词、一个无线索词,验它给出不同结论 |
L4 是最要紧的一层,理由是这个产品有一处特殊的结构:它的核心失败模式,用户 无法自己察觉。能判断「这个语境够不够推出词义」的人,本来就已经认识这个词了。 所以审计判错时没有任何反馈回路——不像崩溃或乱码,你永远等不到用户来报。 没有回路的错误不会自我纠正,只能在入口一次性验掉。
L1–L3 验的全是「产出的形状对不对」,恰好把唯一要紧的能力漏在外面。L4 用同一段
文本同时考两个方向:meticulous 有破折号释义 + 具体画面,必须判 strong;
tedious 换成任何生词句子照样通顺,不能判 strong。见词就说 strong 的模型
(审计变橡皮图章)和见词就说 none 的模型(每段白烧两轮补线索)会同时挂掉。
四种模式,都在同一套句子结构上切换(快捷键 1–4,空格键中英互切):
| 模式 | 作用 |
|---|---|
| 英文 | 默认 |
| 中文 | 中文原地盖住英文 |
| 对照 | 译文跟在原文后,原文不消失 |
| 回忆 | 目标词挖空,先自己检索,点空格才揭示 |
回忆模式是这里唯一直接提升记忆效率的东西。 光看不算记住——主动检索 (retrieval practice)对留存率的提升远大于重复阅读。挖空的长度与单词等长, 点一下揭示并高亮一瞬;卡住时可以点句子看中文当提示。这个模式下中文不会 自动显示,否则等于把答案摆在旁边。
覆盖切换最大的体验问题是页面往上跳:中文比英文短,一换行数就变,读到一半位置就丢了。
两道保险:渲染后把每段 min-height 锁到中英两种排版里较高的一侧(从根上不产生高度变化),
外加切换时的滚动锚定兜底。
点任意一句可只翻那一句——真实的学习动作是读到卡住的那句才想看中文, 全文翻译会让人偷懒不去猜。
点任意生词,右侧滑出这个词的完整档案:
- 中文释义(生成时一次调用批量产出,含高频搭配)、CEFR 等级、掌握程度(Lute 式 1–5 / 已掌握 / 忽略)
- 跨所有文章的全部语境,每处都带当时的线索审计结论和线索原文
最后一条是这个应用真正的记忆杠杆。同一个词在不同故事里反复出现, 比在单词书上重复看十遍有效得多(varied context)。所以同一批词过几天换个题材 再生成一篇是推荐用法——文库页面就是为此存在的,而不是为了存档好看。
单词书失效的原因是「词 → 中文释义」没有可挂靠的语境——这是这个项目的出发点。 但反过来也成立:很多人手上就有一本纸质词汇书,而且是照着它的进度在背的。 让应用和那本书对齐,比再造一套词表有用。
所以这一页只做一件事:顺序完全由你定。书名、单元号、页号三样都是你填的, 词的先后就是你敲进去的先后。程序一处都不重排——按字母排、按词频排、 「智能」排,都会让你翻到第 43 页时对不上。
录一页
词书 新东方六级词汇词根+联想记忆法 乱序版
单元 List 07 页号 43
┌────────────────────────────────┐
│ abandon │ ← 只敲词形
│ abbreviation │
│ abdomen │
└────────────────────────────────┘
识别到 12 个词,配上释义 11 个。字典里没有 1 个:zzzznotaword
音标、中文释义、屈折形式、派生词由程序配上,来源是
ECDICT(MIT)。抽出来的
data/wordbook/dict.csv 有 33,217 词、3.7MB,直接在仓库里——
不下载、不联网,「双击 run.bat 就能用」在这一页上同样成立。
背的时候是翻卡片:正面只有词和音标(先自己回忆),翻面给释义、变形、派生词, 再判「认识 / 不认识」。空格翻面,左右手判,全程不碰鼠标。 掌握程度用的是全应用同一套分档,词库页看得到。
排期走 FSRS(MIT,纯 Python)。 自己拍一套间隔只会更差,而且差在哪儿看不出来——背单词的效果没有反馈回路, 用户不会来报「间隔排得不好」,他只会觉得「背了没记住」。 和「顺序 == 纸质书」的张力用只过滤、不重排解决:队列按到期筛,但顺序仍是书序, 词表视图完全不受影响;另外留一条明确的退路——「不管到期,按书再过一遍」, 没有它,间隔重复等于把「对着书从头背一遍」这个用法拿走了。 装不上 fsrs 也能跑,退回原来的行为。
字典是配菜,不是门卫。 词典里没有的词照样按原位录进去,释义留空等你补。 丢掉它的话,你书上那一页 12 个词、应用里只有 11 个,而且后面全体错位—— 这是唯一能把这个功能彻底废掉的错误,而且它不报错。
书上的词根拆解和联想不在里面。 那是各本书的编辑创作,不是事实数据, 所以既没有扒、也扒不来。每个词留了一个笔记位,你自己抄一句最有用的。
只有标成「已掌握」的词才进共用词库。 词库那份数据被难度标尺当豁免名单读 (文章生成时不再判它超纲)。一本六级书 5407 个词全灌进去,超纲检测当场失效, 而界面上只会显示一个更好看的数字——那正是这个项目最不能接受的那种失败。
顺序是不可再生资产。 它是你一页页敲进去的,所以和文章、语境走同一套保护: 删词书之前自动留一份快照,并且先把「会丢掉几个单元、几个词」摆出来。 还能按书上的顺序导出 CSV——「和纸质版一致」这条承诺此前只能在界面上一行行 翻着验,导成一张表才拿得到手上,对着书扫一眼就知道对不对。
词库也能导(/api/words/export.csv),而且是一行一处语境:同一个词在不同故事里
出现多次正是这个产品声称最有效的机制,压成一行只留一句例句,等于在导出这一步
把核心资产丢掉。两份都带 BOM——不带的话 Excel 会把中文列读成乱码。
五个页面:生成 / 文库 / 词库 / 阅读 / 设置。深浅色跟随系统,手动切过之后记住选择。
前端刻意不引框架——一共五个页面、交互也简单,引框架带来的构建步骤远大于它省下的代码。 换来的扩展性靠三个约定:
加一个页面。 在 web/pages.py 的 PAGES 里加一条 → 写 templates/<id>.html →
需要交互就写 static/js/pages/<id>.js 导出 init()。main.py 和 base.html 一行都不用改:
路由由注册表循环生成,导航栏遍历注册表渲染,页面脚本由 main.js 按 <body data-page>
动态 import。不需要 JS 的页面连文件都不用建。
词库页就是照这个流程加的:后端 /api/words 早就存在却一直没有界面用它,
补这个页面没有写一行后端代码,前端也只多了一个模板和一个 JS。
加一个接口。 在 static/js/api.js 对应的分组里加一行。
if (!res.ok) 的判断、FastAPI detail 的提取、网络失败的人话提示都由它统一负责——
请求散落在各页面里时,总有几处会忘记判 ok,表现出来就是「点了没反应」。
改视觉。 只动 static/css/tokens.css。其余 CSS 一律只引用变量,不写死色值。
参考了 cf 辅助工具的几处做法:深浅色令牌分「文字色 / 填充色」两套(暗底上链接要更亮、 按钮底要更暗,一个变量满足不了)、浅色不用纯白(大面积纯白是眩光的主要来源, 而这个应用的使用姿势就是盯着一屏英文读五分钟)。但导航没有沿用它硬编码在模板里的写法—— 那样加第八个入口要改三处,总有一处会忘。
「任务」= 一件能对着词表做的事:生成文章、出题、造例句、拆词根、画关联图谱…… 它们结构相同,都是「拼 prompt → 要 JSON → 校验 → 修复 → 落库」。
1. 在 tasks/ 下建目录,写一个 Task 子类
2. 在 tasks/__init__.py 里 import 一次
3. 结束——core/ 和 web/ 一行都不用改
所有任务共用 core/store 的同一份词汇学习状态:Word(父词条)、
WordForm(文中出现过的变形,借鉴 Lute v3 的 parent term)、
Encounter(这个词在哪句话里被读到过)。
文章模块只管写,将来的测验 / SRS / 关联图谱模块直接读。
这是「集成应用」和「几个工具装在一个壳里」的分界线。
core/
provider/ 模型接入层(OpenAI 兼容 + 各家 capability / quirks 声明)
llm/ 调用、重试、JSON 四层兜底解析
lexicon/ CEFR 分级、词形还原、超纲检测
wordbook/ 词书:参考词典(dictionary)+ 录入与进度(store)
store/ SQLite:词条 / 文章 / 学习状态,启动前自动留快照
health.py 四层正确性检验
tasks/
base.py Task 接口与注册表
article/ 文章生成(prompts / schema / 管线)
main.py 唯一入口:起本地服务 + 用 Edge 打开
web/
pages.py 页面注册表:加页面改这里一行,路由与导航自动生成
routes/ FastAPI 接口
templates/ 页面(都继承 base.html)
static/
css/ tokens.css 设计令牌 / app.css 布局与组件 / reader.css 阅读器
js/
core.js DOM、主题、toast 等共用工具
api.js 后端调用的唯一入口,加接口在这里加一行
main.js 引导:按 <body data-page> 自动载入对应页面模块
components/ 可复用部件(阅读器、结果面板)
pages/ 每个页面一个模块,导出 init()
config/
providers.yaml 模型注册表(新增一家模型改这里)
settings.local.json API Key,已 gitignore,绝不下发前端
data/wordbook/
dict.csv 背单词的参考词典(生成物,提交进仓库)
README.md 它的来源、许可、以及为什么是这个收词范围
pip install -r requirements.txt -r requirements-dev.txt
pytest # Python 561 项
cd tests/frontend
npm install
python make_fixtures.py
npm test # 前端 224 项测试全部离线:涉及模型的用 FakeLLM,返回什么由测试自己决定。真去调模型
既跑不稳又要花钱,而且验不了「模型返回畸形数据时会怎样」——
而那恰恰是这个项目最需要覆盖的部分。
前端测试在 jsdom 里跑服务端真实渲染的 HTML 加 web/static/js 里
真实的模块。但它测不了视觉:jsdom 没有布局引擎,CSS 完全不参与。
改了 CSS 就自己开浏览器看一眼,细节见 tests/frontend/README.md。
改这个仓库之前建议先读两份:
- CLAUDE.md —— 项目的判断依据、结构约定、跑测试的方法
- 需要注意.md —— 踩过、而且以后还会再踩的坑。收进去的都满足 两条:代码读起来是对的(隐蔽),而且是某类改动的固有陷阱(会重现)。 每条都标了现在有没有测试盯着
- CEFR-J Vocabulary Profile 1.5 —— Tono Laboratory, Tokyo University of Foreign Studies,研究与商用免费,需注明出处
- Octanove Vocabulary Profile C1/C2 1.0 —— CC BY-SA 4.0
- ECDICT —— MIT License,Copyright (c) 2025 Linwei。 背单词页的音标 / 释义 / 屈折形式来自它;派生词是构建时自己算的,见 data/wordbook/README.md