词汇循环(点击 → 查询 → 保存 → 划线 → 复习)
父节点:key-designs
这是产品的核心循环。每一步都能在代码里找到对应的实现。
1. 点击一个单词 → 三层查询(usecases/lookupWord.ts、useLookup.ts)。
- L1 IndexedDB 中的
DictCache(即时、离线可用); - L2 mainbackend 的全局词典(
DictionaryApi→/api/v1/dictionary/entries)——所有用户共享的缓存; - L3 用户自己配置的 LLM(
api/llmLookup.ts、llmLookupBasic,温度 0.1)——给出 word/lemma/词性/释义/名词信息。命中 L3 后会以 fire-and-forget 的方式回写到 L1 和 L2,于是下一个点同一个词的读者就不用再付这个代价。
2. 动词补全(惰性、异步)——如果词性是动词,llmLookupVerbTable 会填出完整的变位表,services/verbForms.ts 收集每一个变位形式加分词,一并追加进 L2 词典。这样以后查询任何一个变位形式都会命中缓存,而不用再问 LLM。
3. 保存(usecases/markWordFromLookup.ts)——构建一条 VocabularyEntry:名词的词元带上性别前缀(如 der Kopf),点击位置被定位到一个全书绝对坐标 rawStart(通过双坐标映射——raw-display-cursor),并且抓取段落级的 context(段落 → 句子 → 页面子串,依次降级)供制作抽认卡使用。
4. 到处划线——打开书籍时,loadBookVocabulary 先用缓存绘制再做校验,并运行全形式迁移(vocabFormsMigration):按 bookId::lemma 把条目分组,保留最早的那条(以保住 SRS 历史),解析出完整的形式集合(先问 LLM,词典兜底),把 word 重写成 lemma,删除重复项——这个过程是幂等的,并有并发保护。之后由 all-forms-underline 把每一个形式都画上下划线。
5. 复习——SRS 由服务端掌管。 客户端只把一个评级('dont_know' | 'fuzzy' | 'know')发送到 /reviews;真正的 SM-2 算法跑在 Go 里(mainbackend/internal/domain/review/srs.go——难度系数增减 −0.2/−0.15/+0.1,下限钳位在 1.3,dont_know → 1 天后复习,know 则 1→6→×难度系数,评级在服务端校验)。把算法放在服务端,意味着 web 端和桌面端的复习计划始终一致,也不会被过期的客户端篡改。
这个循环正是"精读"能成立的原因:阅读产生分级的词汇,而保存下来的词汇又反馈回阅读界面,变成随处可见的下划线。