# 未完成事项与审计记录(BACKLOG) > 本文件记录**尚未修复**的已知问题、待验证项与验证方法。 > 已发布的变更写 [`CHANGELOG.md`](../CHANGELOG.md);发布流程与隐私约定写 [`AGENTS.md`](../AGENTS.md)。 > 最近更新:1.6.7。 --- ## 0. 1.6.7 处置记录(两条第三方 issue + 本轮审计) ### 0.1 两个 issue 的处置 | issue | 现象 | 根因 | 处置 | |---|---|---|---| | #1 | 启动报 `subprocess service unavailable`,`kb_*` 工具全不注册 | 插件 `inject` 缺 `subprocess`;cordis 只等待 inject 声明的服务,而 loader 并发激活各 entry(`Promise.allSettled`),本包先跑完 `apply()`,此时 `ctx.get("subprocess")` 是 `undefined`(不抛错)→ 提前 return | 已修:静态侧与动态侧(`plugin/host.js`)都补上 `subprocess`。**注意**:`host.js` 那侧是静默丢工具(沙箱里 `ctx.get` 是可选查询、不要求声明),issue 只覆盖了静态侧 | | #2 | 模型不可用时静默降级(0 向量/纯关键词),且失败状态被守护进程永久缓存 | ① `_embed_new_chunks()` 在 `get_embedder() is None` 时 `return 0`,返回里只有 `embedding: null` ② `_EMBED_ERR` / `_RERANK_ERR` 模块级永久短路 ③ **报告人未发现的第三半**:增量入库的跳过判据只看 sha256,而"建向量"在 `return` 之后 → 环境修好后重跑 `kb_ingest` 也补不回来 | 已修:新增 `embedding_error` / `vectors_missing` / `retry_secs` 字段与渲染、`KB_MODEL_RETRY_SECS`(默认 120 秒,`0`=每次重试,负数=旧行为)、`reload` 命令、`skipped` 分支补齐缺失向量 | | #2 细节更正 | 报告称"宿主把 stderr 截断到 300 字符" | 300 是**守护进程退出日志**的截断;模型错误是引擎侧 `[:300]`,命令级错误在插件侧 `[:800]` | 已在回复稿中说明(`issue-1-2-回复.md`,见交接包) | ### 0.2 本轮审计发现并已修复的其他问题 - References 判定误判吞正文(**2 篇文档整篇不可检索**)→ 按"人名在首位 + 编号递增 + 文末"的版式特征重做判据,并加整篇兜底;`PARSER_REV 4 → 5`(详见 CHANGELOG 的 1.6.7 段)。 - 向量无相关性下限(库外问题返回 Top-3 垃圾)→ 实测标定后加 `verdict` / `no_hit` / `closest`;**余弦不做地板**(库内 min 0.691 vs 库外 max 0.688,基本重叠)。 - 缓存 key 用运行时 `_RERANK_NAME` → 某进程第一次 deep 检索的缓存行永远命中不了;改用配置名。 - filters 大小写/连字符/作者标点敏感 → 两侧归一 + 作者分词 AND。 - GPU 不可用(含加载期失败)会让整条向量链路死掉 → 硬兜底退 CPU,并标记本进程不再尝试 GPU。 - 客户端半边(动态插件形态)的来源卡片正则与宿主输出格式不匹配 → 卡片恒显示"无命中"。 --- ## 1. 1.6.5 修复清单(已发布,供对照) 来源:对 1.6.1 时期一次代码审计结论的逐条复核(复核基线为 1.6.4,逐行读码给出证据)。 | # | 问题 | 触发与后果 | 位置 | 修复 | |---|---|---|---|---| | 1 | 检索末尾写缓存没有锁容错 | 异步入库子进程持写锁时,**已算完**的检索以 `database is locked` 整次报错(DSH/MCP 表现为工具调用失败) | `_search_core` 尾部 `INSERT OR REPLACE INTO cache` | 只吞 `locked`/`busy`,其他 `OperationalError` 照旧上抛 | | 2 | `kb_zotero` 整批单事务、从不写 progress | 异步整库迁移看不到进度;任务被杀把已入库文件**全部回滚**(0 篇落盘);写锁窗口覆盖整批,放大问题 1 | `cmd_zotero` 循环 | 与 `cmd_ingest` 对齐:每篇 `_ingest_file` 后 `db.commit()` + `_prog()` | | 3 | 异步启动校验形同虚设 | `Popen` 后**立刻** `poll()`;python 仅打开不存在的脚本也要数十毫秒才退出 → 最可能的启动失败抓不到,留下长期 `running` | `cmd_ingest_async` spawn 段 | `wait(timeout=0.5)` + Popen 前校验引擎脚本存在,失败即清 job 文件 | | 4 | 引文链结束后章节被重置 | 一律重置为 `Front matter`(权重 1.0)。Nature 式论文正文 refs 与 Methods refs 是两段离散链,链后正文被标错章节 → `filters.section` 漏召回、排序权重掉档、§标签错误 | `chunk_document` 引文链分支 | 进入 References 时暂存 `(section, weight)`,链结束还原 | | 5 | 图注块之后章节被重置(同类,复核时新发现) | Results 中插图之后的所有段落丢章节与权重 | 同上,`CAPTION_RE` 分支 | 图注仍独立成 `Figure/Table` 块,其后正文还原图注前章节 | | 6 | `kb_clear` 清不掉原子写残留 | glob `*.json` 不匹配 `*.json.tmp`;进程在 rename 前被杀即留下永远清不掉的残留 | `cmd_clear` 的 `.kb-jobs` 清理 | glob 改 `*.json*` | | 7 | 无页信息时返回 `[]` 而非 `None` | 污染"无页 = None"语义(当前调用方有 `len()` 守卫,无实际后果) | `_apply_ref_spans` | 保持 `None` | 同时订正了 `CHANGELOG` 1.6.2 节两处过宽断言("并发读不再锁死"只覆盖 `cmd_ingest` 的 connect 路径;"启动即失败可感知"实际抓不到脚本路径错误)。 --- ## 2. 已确认但**未**修复 ### 2.1 后台任务没有自动失败终态(中) - **现状**:`cmd_status` 只要 job/progress 文件存在就返回 `running`;`job.json` 的 mtime 超过 1 小时只**追加一句提示**,状态仍是 `running`(`kb_engine.py` 的 `cmd_status`,1.6.5 时约 L2741-2750)。 - **后果**:子进程被强杀或卡死时,调用方只能靠这句提示人工判断,没有可编程的终态。 - **待决策**:是否引入"心跳超时 → `stale`/`error`"的自动终态。若做,心跳依据已具备:progress 文件含 `processed`/`errors`/`chunks`,且是原子写,可用其 mtime 判定。 - **注意**:真正的长任务(整库 Zotero 迁移、大目录入库)可能长时间无产出,阈值不能取太小。 ### 2.2 页码锚点是近似(低,已文档化,非缺陷) - 无标题回退分块(`fallback_chunks`)各块页码取首段页,`page_end` 偏低;超长块被 `split_long` 切开后各片沿用整段范围。 - 已在 `docs/DESIGN.md`(分块锚点条目)与 `docs/OUTPUT-FORMAT.md`(页码降级表)声明为近似。 ### 2.3 引擎进程退出行为未验证(低,待验证) - 1.6.1 时期观测到"加载过嵌入模型的一次性引擎进程退出挂起(2 例,>10min)";当前代码内**没有**任何退出处理(无 `atexit`、无 `os._exit`、无显式线程收尾)。 - **需要**:在允许起进程的环境跑 `python kb_engine.py run_job `,观察退出码、耗时与是否有残留进程;确认后再决定是否加显式清理或强制退出。 ### 2.4 `run_job` 与宿主命令白名单(待确认) - `plugin/kbrag.plugin.json` 的 `engine.commands` 只列插件**实际调用**的命令(不含 `ingest_async`/`status`/`run_job`),这与"异步只走 MCP 侧"一致,非缺陷。 - **待确认**:若宿主按该白名单校验引擎子命令,引擎内部 `Popen(... run_job ...)` 是否会被策略拦截。本仓库无法判定,需要宿主侧确认。 ### 2.5 尚未审计的区域(低) - `npm-package/install.mjs` 与 `npm-package/scripts/install.ps1|sh`:只验证过 UTF-8 管道修复后不再复现 `WinError 123`,其余分支未审。 - `plugin/client.js` 的卡片视图。 - 上标角标识别(`_superscript_cites` / `_bracket_superscripts`)与 `_match_cite_lib` 的**误报率**:需要真实 PDF 语料评估;代码逻辑已读通,未发现确定性错误。 ### 2.6 DOI 覆盖率:陈旧元数据不会自愈(中,已定性未修复) 实测:库里约 **46%** 的文档带 DOI(渲染成可点击链接),其余为空;空的里面**约 49 篇属于"陈旧元数据"**——用当前引擎重新解析可以拿到 DOI,但库内仍为空。 - **根因**:这些行写入于首轮批量入库(早于 1.2.0 的 identifier ladder),当时 DOI 搜索只覆盖正文前 3000 字符,页脚 DOI 被漏;而**增量入库按 sha256 跳过未变文件**,所以抽取逻辑改好后**老库不会自愈**。这是本条的关键:引擎改进 ≠ 已有库自动受益。 - 另有若干来源未覆盖:DOI 只存在于 PDF 的 XMP 元数据、仅带 arXiv 号、以及 DOI 只出现在首页之外的正文里;少数文档确实没有 DOI(教材/专著/扫描件)。 - **注意**:把 DOI 搜索扩大到全文并不可取——实测会命中**参考文献里别人的 DOI**,必须配合理性校验(与标题/期刊/XMP 交叉验证,或只取页眉/页脚带),否则比没有 DOI 更糟。 - **建议修法(未实施)**:① 新增 `metadata_only` 刷新通道(只重跑元数据抽取 + `UPDATE docs`,不重切块/不重嵌入;这也是让引擎改进能作用于老库的通用机制);② 扩展 DOI 来源(XMP、页眉页脚带、arXiv 分支放宽);③ 可选按标题向 Crossref 反查。 - 完整实测数据、样例与复现脚本见**本地审计记录**(工作区,不随仓库发布)。 **2026-09-11 更新:已对全库做过一次 `force` 重灌**(312 篇,322 s,`updated=312 / errors=0 / duplicates=0`)。结果:有 DOI **143 → 191 篇(45.8% → 61.2%)**;页码锚点恢复 99.7%;References 分块 3271 块,**引文链恢复正常**(重灌前部分文献没有 `↳ 引文补充`)。重灌后仍无 DOI 的 121 篇构成: | 类别 | 篇数 | 能否救 | |---|---|---| | DOI 只在 XMP 元数据里 | 16 | 能,读 XMP 即可,**零风险** | | DOI 在首页之外的正文里 | 34 | 需页眉/页脚带 + 交叉校验;直接全篇搜会挂上别人的 DOI | | 全文与 XMP 都搜不到 | 68 | 补充材料/学位论文/教材/中文期刊/老文献,基本到顶 | | 无文本层或非 PDF | 3 | 取不到(扫描件、补充材料 docx) | 所以建议把上面的 ② 拆成两步:先只加 **XMP**(覆盖率约 66%),页眉/页脚带单独一轮、带校验再做。 > 重灌注意:**不能按目录传参**。`force=True` 会绕过去重检测(`kb_engine.py` 中的 `if dup is not None and not force`),传目录会把内容重复的文件当新文献重复入库(实测某目录会多带 31 个重复文件 + 1 个 Office 临时锁文件)。要按库内现有文件路径精确重灌。 ### 2.8 元数据字段 `journal` 全库为空(中;④ 文档已写明,③ 字段补全未做) 实测:`journal` **非空 0 / 312(0%)**,而 `title` 100%、`authors` 85%、`year` 99%、`doi` 66%。 - **原因**:`extract_meta()` 把 `journal` 初始化为 `None` 后**从未赋值**(见 `kb_engine.py` 中 `title = authors = journal = doi = None` 之后的全部逻辑);只有 **Zotero 迁移**路径会从 `publicationTitle` / `journalAbbreviation` 填(`cmd_zotero` 的 `meta["journal"]`)。因此用 `kb_ingest` 建起来的库,这一列永远是 NULL。 - **影响**:① `filters.journal` 必然零命中(实测:`filters={authors:"Author A", journal:"Carbon"}` 返回 0 条,去掉 journal 立刻命中);② 来源行与援引格式里的"期刊"缺失(`[作者, 年份, 期刊](doi)` 退化成没有期刊);③ 关联文献的"同期刊"打分信号恒为空转。 - **可选修法**:① 首页启发式抽取(噪声大,需严格白名单/位置约束);② 由 DOI 前缀映射**出版商**(10.1038→Nature 系、10.1103→APS…,但那是出版商不是期刊名,容易误导);③ Crossref 按 DOI 反查权威期刊名(需联网,200+ 次请求,一次可缓存);④ 维持现状并**在文档中写明"期刊字段只由 Zotero 迁移填充"**。 - **倾向**:短期做 ④(先把文档说准),中期与 §2.6 的 Crossref 方案合并做 ③。 - **④ 已完成(本次改动,尚未发版)**:把"期刊字段只由 Zotero 迁移填充"写进所有面向模型与用户的文本—— - 工具 schema(两个插件副本 `plugin/host.js` / `npm-package/lib/index.js` 的 `filterSchema.journal`、`mcp-server/server.py` 的 `kb_search` / `kb_rag` docstring):字段说明里写明"仅 Zotero 迁移填充,`kb_ingest` 入库的文档为 `NULL`,用它过滤通常零命中,请改用 authors/year/title";`kb_search` 的 filters 说明也加了同样一句 - README(`README.md` 英文、`README_CN.md`、`npm-package/README.md` 的 Query guidance,`mcp-server/README.md` 的已知限制):同一句提醒(顺手修掉 `README_CN.md` "由此有三条实用规则"与 4 条列表不符的笔误) - `docs/DESIGN.md`:§4 元数据抽取列一条"`journal` 不由本通道填充",§5 加"预过滤字段"条目 - 仍未做:③ Crossref 按 DOI 反查真正的期刊名(与 §2.6 合并,需联网 + 缓存),以及 `related` 的"同期刊"打分信号对非 Zotero 库仍恒为空转 ### 2.9 大规模写入的性能与内存(低,运维提示) **1.6.7 追加(检索侧,已出数据)**:`_search_core` 每次查询要把参与检索的分块(文本 + 向量)读进内存, 本身是 O(库规模)。**实测拆解**(本机 316 篇 / 20345 个可检索分块,`_profile_search.py`): | 阶段 | 实测 | |---|---| | 语料读取(冷) | 164 ms(含向量)/ 96 ms(纯关键词) | | 语料读取(同会话第 2 次起) | **0–1 ms**(语料缓存命中) | | BM25 关键词排序 | 首次 208–302 ms → 缓存命中 **54–125 ms**(2.2–3.1×,结果逐位一致) | | 查询嵌入 + 向量排序 | 7–9 ms + 98–114 ms | | RRF 融合 | 4 ms | | 精排 20 条 × 1800 字 | ≈1.9–2.8 s(**deep 的绝对大头**;`KB_RERANK_CHARS` 可截短) | | 端到端 hybrid(热,语料缓存命中) | **275 ms**(优化前 479 ms);冷启动因模型加载约 9.9 s | | 端到端 deep | 635 ms–2.7 s,取决于精排是否已加载/命中缓存 | 结论:**当前规模下扫描不是瓶颈**(占 deep 的 ~12%、占纯排序计算 ~23%,且同会话重复查询已为 0)。 因此**架构级改造(FTS5 / 倒排 + 向量分级)本轮不做** —— 它会改变关键词路的召回语义 (本引擎的 BM25 用**子串**匹配支持词形近似,FTS5 的 token 匹配会掉召回),风险大于收益。 已做的三件零风险事项:① 纯关键词路不 JOIN `vecs`;② 语料缓存 + BM25 小写文本/平均长度缓存 (`KB_CORPUS_CACHE=0` 可关,`KB_CORPUS_CACHE_MAX` 默认 6 万块为上限); ③ `scan { chunks, load_ms, need_vec, corpus_cached, warn }` 观测 + 10 万块阈值提示。 **重新评估的触发条件**:可检索分块 > 10 万,或 `scan.load_ms` 在热查询里占比 > 30%, 或守护进程常驻内存超出预期(语料缓存是文本 + 向量常驻)。届时候选方案:FTS5(trigram) 只用于**候选集**粗筛(保留现有 BM25 在候选集上精算,需接受 IDF 变化)、或把向量搬到 外置 faiss 索引文件、或对超大库启用"分片 + 每片独立缓存"。任何一条都要把 `scan` 指标 与一组固定查询的 top-k 重合率纳入回归。 同一次全量 `rebuild` 的两次实测差异很大,原因是机器状态而非代码: | 场景 | 观察 | |---|---| | 单进程 CLI、守护进程空闲 | 312 篇 **322 s(约 1.0 s/篇)** | | 通过工具转后台、守护进程同时持有模型 + 并发检索 | 冷启动阶段 **约 17 s/篇**(job 进程 CPU 占用仅 16%),随后回到 **1.4 s/篇**(44→60→86 篇/分钟) | - 冷启动慢的主因:两个 Python 进程各持一套模型(各约 **930 MB–1 GB**),而当时系统**提交内存 32.3 / 36.6 GB**,分页与磁盘 I/O 成为瓶颈。 - SQLite 默认 `journal_mode=delete`(刻意选择:同步 `.kb` 目录更安全),写者独占;需要更平滑的并发可设 **`KB_SQLITE_WAL=1`**(代码里已有该开关)。 - 实测**并发检索在重灌期间全部成功**(643–783 ms,无 `database is locked`),说明 1.6.2/1.6.5 的锁容错修复在该场景下有效。 - 结论:全量重灌建议在守护进程空闲时用单进程 CLI(322 s),或先设 `KB_SQLITE_WAL=1`;不构成缺陷,记以备查。 ### 2.10 去重只按内容,不按论文(低) 实测(真实文档):Zotero 迁移时**同一论文的两个 PDF 版本**(sha256 不同,1.83 MB 与 1.56 MB)都被入库——内容级去重按设计不会合并它们,`kb_dedup` 同样只按 sha256 判重。可选改进:在内容判重之外加一层"论文级"判重(DOI 相同,或"归一化标题 + 首作者 + 年份"相同),命中时**保留一份并标注另一份为同一论文的其它版本**,而不是直接删除(用户可能想留不同版次)。 > 另注:`force=true` 会**绕过内容去重**(`if dup is not None and not force`)。实测用 `kb_zotero(force=true)` 恢复元数据时,一篇内容重复的文献被当成新文献插入(多出 1 篇),随后用 `kb_dedup` 清掉(`removed: 1`)。这是设计行为,但调用方需知道。 ### 2.11 已验证的 1.6.6 行为(真实文档实测,供回归参考) 以下均在**真实文档**上跑过,属"已验证、无需改动": | 场景 | 实测结果 | |---|---| | 陈旧检测闭环 | 用旧引擎(rev3)force 重灌 4 篇 → `stale_docs=4`(`stale_sample` 精确列出这 4 篇)→ `metadata_only` 刷新 → `stale_docs=0` | | Zotero 迁移(`limit=5`) | 新增 4 / 内容重复跳过 1;`journal`(Zotero 的 `publicationTitle`,如 `Carbon`)、`doi`、`zotero_key` 全部写入——**这是 `journal` 唯一的来源**(见 §2.8) | | 扫描件/无文本层 PDF | 逐文件失败 `✗ 1.pdf · ValueError: no text extracted`,不影响整批 | | 全量 rebuild 转后台 | 312 篇 `updated`,分块/向量与重灌前**完全一致**(23447 / 20176)→ 解析与嵌入确定性 | | 重灌期间并发检索 | 9 次调用全部成功,无 `database is locked` | | 失败原因渲染 | 已修:失败条目现在显示 `· ValueError: no text extracted`(此前只显示 `✗ 文件名`) | | 作者 junk 过滤 | 真实库命中 `user` / `Administrator` / `aipuser`;名单已扩充(`user`、账号类 `*user`、`administrator`、`owner`、`guest`、软件名等),`PARSER_REV` → 4 | | `metadata_only` 覆盖问题 | 已修:曾用首页抽取结果把 Zotero 写入的 `doi`/`journal` 抹掉(DOI 211→209);现改为**绝不用空值覆盖非空值**,仍允许好值替换脏值(如 `.indd` 标题) | ### 2.12 相关性判定:库外问题仍会被当成库内证据(已定位、未修复,**发布后第一优先**) 1.6.7 的相关性地板(`KB_MIN_RERANK=0.10` / `KB_MIN_RERANK_WEAK=0.35`)只拦得住"完全无关"。在 316 篇真实库上 用 58 条查询标定(库内 20 / 库外 30 / 泛词 8): | 组 | 裸精排分范围 | |---|---| | 库内·标题式(10) | 0.890 – 0.999 | | 库内·自然问句(10) | **0.139** – 0.998 | | 库外·自然问句(30) | 0.000 – **0.988** | **两类在 0.35–0.99 完全重叠 → 任何常数阈值都分不开**(库外 max 0.988 > 库内 min 0.139)。实测成绩:库外 30 条里 **19 条**正确判 `no_hit`、**7 条**被判"相关"并作为证据返回、**4 条**判"弱相关"但结果照旧返回(合计 11/30 泄漏)。 后果不是"多绕几圈"而是**错答**:判"相关"会让 agent 不再停手,直接把块当库内证据引用。 **根因(三层)** 1. 判定量是 `max_score = max(池内 ≤25 块的裸精排分)`(`kb_engine.py:2988`)——**单个离群块就能决定整条查询的结论**。 2. 候选池里混着"不承诺具体内容"的块(front matter / 致谢 / 作者串 / 页眉),cross-encoder 给它们中等偏正分。 已复现的三条高分假阳性(0.988 / 0.988 / 0.806)命中块都不是正文;同一条库外查询对抽样正文块只有 0.000–0.001。 3. `BAAI/bge-reranker-base` 的 logits 在域外没有校准(`rerank()` 直接用 `CrossEncoder.predict` 原始输出,`kb_engine.py:1891`), 所以 0.10 / 0.35 这两条线只能当"完全无关"的门,不能当"是否相关"的界线。 **已量出的替代判据(行为信号,无需重新训练或校准)** | 信号 | 库内 20 条 | 库外 30 条 | |---|---|---| | `missing`:查询实词在 316 篇语料里根本不存在的个数 | 全部 0 | 25 条 ≥ 1 | | `best_doc`:查询信息量(IDF 之和)在**单篇**文档里的最大占比 | 0.825 – 1.0 | 0.23 – 0.841 | 组合规则(`missing ≥ 1 → 无关`;`missing = 0 且 best_doc ≥ 0.85 且 raw ≥ 0.10 → 有`;其余 → 存疑并标注"不可当证据引用") 在这 58 条上做到**库外漏出 0/30、库内降级 1/20**(现状 11/30 与 2/20)。两个信号的失败集几乎不相交:行为信号漏掉的那条 (`best_doc=0.841`)精排分只有 0.036,现状规则本来就能否掉它 —— 取 AND 互补。 **未做 / 风险**:信号要在引擎内用**引擎自己的分词与 df** 重算(离线标定用的是独立分词,数字会漂);`best_doc` 只回答 "库里有这个话题吗",回答不了"库里有答案吗"(`how do I calibrate a pH meter?` 的 `calibrate`+`meter` 真同现于一篇,`best_doc=1.0`); 样本量 20/30 不足以钉死 0.85,上沿余量只有 0.02(库内最低 0.825 vs 库外最高 0.841),需要补"同域库外"(新题/相邻题)样本。 落地时还要抬 `VERSION`(缓存 key 含 `PARSER_TOKEN = VERSION/rev`,否则旧响应照旧返回)。 **1.6.6 → 1.6.7 未退化(已证)**:同一库副本上双引擎对跑同一批查询 —— 库内 20 条 top1 **20/20 相同**、分数序列**逐位相同**、 结果条数 96 = 96、0 条被误判无命中;唯一字段级变化是 `related` 在 `no_hit` 时不再给出(1.6.6 那 19 条的 related 是从垃圾结果推出来的)。 重灌 rev5 后库内 **0 条丢结果**(17/20 top1 不变),整篇不可检索 2 → 0。 **标定脚本**(只读、可复用,**不入库**——查询集含用户真实库主题):工作区 `_calib_probe2.py`(50 条四组查询)、 `_behavior_calib2.py`(`missing` / `best_doc`)、`_cmp_retrieval.py`(双引擎同库对比)、`_rebuild_compare.py`(副本重灌对比)。 ### 2.7 检索语言归一化的归属(待定) 实测:库内约 **98%** 的文档正文为英文。中文查询时 BM25 半路空转(`extract_terms` 只产 CJK 二元组,打不到英文正文),向量侧又属跨语言模糊匹配,因此同一问题的中文查询命中明显差于英文。 - **结论倾向**:归一化放 **AI 层**(调用方把查询写成英文术语串),引擎保持"按原样检索"(`kb_engine.py` 已注释不接 zh→en 翻译);引擎侧最多加零成本检测提示,不改写查询。 - **状态**:方案草案已写(字段约定、query 模板与正反例、语言决策表、二次查询时机、可直接粘贴的工具描述与 README 文案),**未入库、未改任何代码**;待决定是否实施、是否随 1.6.6 一起。 - **补充实测(2026-09-11)**:向量路**没有相关性下限**——纯乱码中文 query("魑魅魍魉麒麟饕餮")仍返回 Top-3(三篇不同主题的文档,全部无关)。因此"零命中"几乎不出现;`note: 知识库为空或过滤条件过严` 只在过滤真的排空候选时触发(实测:给 journal 一个不存在的值)。若希望无关 query 返回空,需要引入最低分阈值(产品决策,未做)。 --- ## 3. 验证方法(改引擎后必跑) 回归脚本在**本机工作区**(不随仓库发布,路径见交接记录)。基线 16 项检查覆盖 1.6.5 的改动,另加 1.6.6 的功能检查: | 分组 | 检查 | |---|---| | 缓存撞锁(1.6.5) | 占住写锁时时检索仍返回结果;无竞争时第二次调用命中 `cached` | | Zotero 提交语义(1.6.5) | 第二篇中断后:首篇已落盘(`docs>=1`)、进度文件已回写 `processed=1` | | 章节还原(1.6.5) | 夹具确实触发引文链(存在 `References` 块);链前章节为 `Methods/1.2`;链后段落还原为 `Methods/1.2`;图注独立成 `Figure/Table` 且其后正文还原 | | 异步启动(1.6.5) | 子进程立即退出被识别为启动失败(`exit=N`)且不留 job 文件;引擎脚本缺失时直接失败 | | 清理与语义(1.6.5) | `kb_clear` 连 `*.json.tmp` 一起清;`pages=None` 时返回 `None`、有页时返回平行页列表 | | **元数据刷新(1.6.6)** | `metadata_only` 能把「首页有 DOI 但库内为空」的文档补齐(实测 3/3 抽到正确 DOI);`rebuild=true` 在空库时给出明确错误而不是崩;稳态 **50–95 ms/篇** | | **XMP DOI(1.6.6)** | 对「DOI 只在 XMP」的文档,刷新后 DOI 从 `None` 变为正确值 | | **陈旧检测(1.6.6)** | 新库/未标 `indexed_with` 的行计入 `stale_docs`;刷新后该计数下降并在全库刷新后归零 | | **生产元数据(1.6.6)** | 生产残片标题(`*.indd`)被拒绝、排版人员 `/Author` 被弃用,改用真实标题/作者,并恢复该篇的引文库内匹配 | | **语言提示(1.6.6)** | 中文 query 返回 `lang_note`,同义英文 query 不带该字段 | | **自动转后台(1.6.6)** | 待处理数 > `KB_ASYNC_THRESHOLD` 时返回 `background=true` + `job_id`,`status` 轮询到 `done` 且 totals 正确;低于阈值仍同步返回 | 跑之前先确认:`kb_engine.py` 与 `npm-package/kb_engine.py` **逐字节一致**(`sha256`),两份都要能 `py_compile` 通过。 --- ## 4. 记录约定 | 内容 | 写在哪 | |---|---| | 已发布版本的变更 | `CHANGELOG.md` | | 发布流程、隐私扫描、发版后校验 | `AGENTS.md` | | 未完成事项、审计结论、验证方法 | 本文件 §2 与 §3 | | 待实现功能 | 本文件 §5 | --- ## 5. 待实现功能(用户提出,待排期) ### 5.1 版本升级后提示是否强制重灌 **要解决的问题**:§2.6 已经实证——引擎的解析逻辑改进后,**老库不会自愈**(增量入库按 sha256 跳过未变文件),用户完全无从得知"库里这批数据是用旧解析器写的"。本次 49 篇陈旧元数据就是这么来的,而且要靠一次 322 秒的全量重灌才发现。 **设计要点**: 1. **记录"这条数据是用什么解析的"**。两个层次,建议都做: - 库级:`meta(key, value)` 表或 `PRAGMA`,记 `indexed_with`(引擎 `VERSION` + 解析器版本号); - 文档级:`docs.indexed_with TEXT`,这样能精确统计"有 N 篇是旧解析器写的",而不是笼统提示整库重灌。 2. **需要一个独立的"解析器版本号"**,不要直接用插件版本。建议手工维护常量(如 `PARSER_REV`),只在改动分块 / 元数据抽取 / 引文切分这类**影响已存数据**的逻辑时 +1;否则每次发版都提示,会变成噪音。 3. **提示时机**:`kb_stats` 返回值里带 `stale_docs` 数量;DSH 侧在**会话启动询问**(已有 `userQuestions` 120 s 竞速机制)或**首次入库/检索**时提示一次,文案要含三件事:影响范围(多少篇)、建议动作、不做的后果。 4. **动作选项**(按代价从低到高): - 忽略本次; - **只刷新元数据**(依赖 §2.6 建议的 `metadata_only` 通道:不重切块、不重嵌入,秒级); - 全量 `force` 重灌(实测 312 篇约 322 s;注意 `force` 会绕过去重检测,必须按**现有文件路径**传参,不能传目录)。 5. **倾向**:只提示、不自动执行;把"要不要重灌"交给用户。 **已有的可复用件**:`_migrate()` 的 schema 版本机制、`kb_stats` 的 `schema_version / migration / health` 字段、`.kb-jobs/` 的进度机制。 ### 5.2 显示入库进度 **现状**:DSH 侧 `kb_ingest` 是**同步**调用(`plugin/host.js` 给 30 分钟时限),**过程中没有任何进度输出**,只有最后一次结果。引擎内部其实已经有进度机制——`/.kb-jobs/.progress.json`,原子写 `{processed, errors, chunks}`——但只有 MCP 侧用得上(`kb_status` 轮询);**DSH 的 9 个工具里没有 `kb_status`**,所以这条能力在 DSH 侧等于不可见。 **候选方案(按成本从低到高)**: | 方案 | 做法 | 前置确认 | |---|---|---| | A. 中间更新 | 大批量入库自动走 `ingest_async`,host 内部轮询 `status`,把进度作为工具返回的**中间更新**输出 | DSH 工具是否支持流式/多次输出;不支持则退化为"只在结束时返回" | | B. 新增 `kb_status` 工具 | DSH 侧第 10 个工具,与 MCP 对齐;`kb_ingest` 超过阈值自动转后台并返回 `job_id`,用户/模型可主动查进度 | 阈值取值(MCP 用 25)与 9→10 个工具的文档同步 | | C. 客户端进度条 | `plugin/client.js` 的卡片视图里显示进度(host 推事件 + client 订阅) | 卡片是否有更新/订阅机制 | **附带收益**:方案 B 顺带解决"大库入库阻塞会话"——DSH 侧目前只能干等 30 分钟,MCP 侧早已能异步。 **建议**:先做 B(能力对齐、无新技术依赖),再看 A/C 的可行性。 --- ## 6. 1.6.6 已实现(原 §5 两项功能) §5.1(升级后提示是否重灌)与 §5.2 的方案 B(`kb_status` + 自动转后台)**已在 1.6.6 落地**,此处只记录落地方式与仍开放的部分,实现细节见 `CHANGELOG.md` 的 1.6.6 与 `docs/DESIGN.md`。 | 原计划 | 落地情况 | |---|---| | §5.1 库级 + 文档级解析器版本 | 采用**文档级** `docs.indexed_with`(`VERSION/revN`,schema v4);库级 `meta` 表暂不需要——`stale_docs` 已能给出精确篇数与样例 | | §5.1 独立解析器版本号 | `PARSER_REV` 常量,只在改动会写进库的解析逻辑时 +1;判定只比 rev,引擎 VERSION 变化不影响 | | §5.1 提示时机 + 三个动作 | `kb_stats` 返回 `stale_docs`/`stale_sample`/`parser_rev`/`indexed_with`;DSH 插件在会话内首次检索时询问一次(仅当 `stale_docs > 0`):暂不处理 / 只刷新元数据 / 全量重灌 | | §5.1 "只刷新元数据"通道 | `kb_ingest(metadata_only=true)`,实测 312 篇 28–30 s(约 90 ms/篇) | | §5.1 全量重灌的路径安全 | `kb_ingest(rebuild=true)`:引擎自己从库里取路径,避免调用方传目录导致重复入库 | | §5.2 方案 B | 第 10 个工具 `kb_status`(`running` 进度 / `done` totals / `error` / `not_found`);大批量由**引擎**判定并转后台(`async_if_large`,阈值 `KB_ASYNC_THRESHOLD` 默认 25),DSH 与 MCP 两侧行为一致 | **仍开放的(下一轮候选)**: 1. **§5.2 方案 A**(把进度作为工具调用的**中间更新**流式输出):需先确认 DSH 工具是否支持多次/流式输出;不支持则方案 B 已是上限。 2. **§5.2 方案 C**(`plugin/client.js` 卡片进度条):需确认卡片是否有更新/订阅机制。 3. **§2.6 的 C 类 DOI**(约 34 篇的 DOI 只出现在首页之外的正文里):仍需页眉/页脚带 + 与标题/期刊/XMP 的交叉校验,不能直接全篇搜索(会挂上参考文献里别人的 DOI)。 4. **§2.1 后台任务的自动失败终态**:仍未做(当前只有 1h stale 提示兜底)。 5. **§2.3 引擎退出行为验证**、**§2.4 `run_job` 白名单确认**、**§2.5 未审计区域**:状态不变。