# 科研 DOCX → 知识库 L1 卡片 —— 实现与教训

日期：2026-09-30 ｜ 归属：zone-b ｜ 关联技能：`baoyu-kb-governance`（**default profile，不可从 zone-b 修改**，故本文件为 zone-b 侧副本）

## 产出

| 项 | 结果 |
|---|---|
| 源文件 | `科研FAQ-2026年9月.docx`（3,628 字 / 13 图）、`科研案例库-2026年9月.docx`（2,198 字 / 3 图） |
| 卡片 | **18 张 FAQ 卡**（含 1 张索引卡）+ **3 张客户案例卡** = 21 张 |
| 落点 | `知识库-copilot/生命科学/技术FAQ/`、`知识库-copilot/生命科学/案例/成功/` |
| 原件 | `…/技术FAQ/原件/`（13 张）、`…/案例/成功/原件/`（3 张），图片引用 16 处 **0 死链** |
| 索引 | `数据/kb_index.json` 重建 → 239 条，ls 线 **33 条**（新增 21） |
| 端到端 | AI 助手检索 **5/5 命中**（新卡排名均为 #1） |

**生成器**：`脚本/docx_sci_to_kb.py [--dry-run]`（SPEC 驱动，可复用）

```
科学 DOCX ──解析样式层级（textutil 会丢层级，不能用）
          ├→ 按 split 样式切卡（FAQ: 主问题=2 / 子问题=3；案例: 4）
          ├→ L2 图片 → <目录>/原件/，卡内相对路径引用（不 base64）
          └→ L1 卡片 → 知识库-copilot/生命科学/<分类>/（六字段 YAML）
```

## 7 个必踩的坑

1. **图片藏在表格单元格里** —— 右击插入的「图表框」是 `w:tbl` 包 `a:blip`。只扫 `w:p` 会漏图（本库漏 4 张）。必须 `el.iter(A+'blip')` 递归。
2. **分类骨架必须显式 skip** —— 切分样式之外的标题（大类/系列/货号）不跳就会当正文全量灌进**最后一张卡**（实测案例卡尾部混入 `qRT-PCR试剂/BR1F101/蛋白质研究`）。SPEC 加 `skip=('1','2','3')`。
3. **子卡父标题取「最近主问题」，不能取 `cards[-1]`** —— 从第二个子卡起 `cards[-1]` 已是上一个子卡，7 个子卡的父子关系会串成链，索引卡只列出 1 个子项。
4. **文件名会撞车** —— 原稿可能给两个不同案例起同一标题（本库两个「案例1：BR4D421+转膜问题」）。必须显式 `CASE_NAMES` 映射表 + 同名去重后缀，不能隐式推断。
5. **图片留在原文位置** —— 正文常有「上图是…」「左：… 右：…」的指代，统一挪到卡尾会让指代悬空。
6. **短正文 ≠ 索引卡** —— 判据是「**有无子卡**」，不是正文长度。否则「实际检测结果的趋势跟预期不符」（正文仅「待从经验中找答案」）会被当索引丢掉，资料缺口就消失了。
7. **🔴 检索验收必须传 `line`** —— `ai_chat._search_knowledge_base(q, 'ls')`；不传时 `line` 默认成 `'da'`，**visible 过滤会把整条 ls 线剔掉**，看起来像「新卡没入库」（本次已误判过一轮，实测新卡排名全是 #1）。

## 源文件缺陷：如实标注，不篡改

案例库有两处原稿问题：**标题与正文不符**（标题写「BR4D421+转膜问题」，正文是 BR1D303 + BR1E102 熔解曲线）、**两个案例标题完全相同**。处理原则：**正文一字不改**，卡片 YAML 后加 `> ⚠️` 引用块标注不符之处，**文件名与品类按正文内容归类**（为了可检索），并单独报给用户。

## 验收清单（改完必跑）

```bash
/usr/bin/python3 脚本/docx_sci_to_kb.py --dry-run   # 先看切卡结果
/usr/bin/python3 脚本/docx_sci_to_kb.py             # 落盘
/usr/bin/python3 脚本/kb_build_index.py             # 重建索引
/usr/bin/python3 脚本/kb_audit.py                   # 体检
/usr/bin/python3 脚本/kb_eval.py ls                 # 检索回归
```

- **`kb_index.json` 结构**：`{built, kb_dir, counts, entries}` —— 卡片列表键是 **`entries`**，不是 `cards`（按错键会误判成「索引里没有新卡」）。
- **`kb_index` 按 mtime 缓存**（`ai_chat._load_kb_index`）→ 改索引**不用重启 8765**。
- **`kb_eval ls` 基线 = 9/10**（记录值，不是 10/10）。唯一 FAIL「甲基化试剂盒怎么用」是**既有结构性缺口**：期望来源是「货号主数据」，而 `_product_lookup` 只在问题含货号时才触发，该问题无货号 → 必然 FAIL。与本类改动无关（新卡中「甲基化」内容数为 0），已实测证明。

## 后续修复（同日）：卡片真正「被用上」的两个断点

卡片入库 ≠ 会被用上。查证后发现两处断点，均已修复并验证：

### 断点 1：客户360 背景检索硬编码 `'da'`

- **现象**：`ai_chat._load_customer360()` 第 5 维（知识库）写死 `_search_knowledge_base(cust, 'da')`，导致生命科学客户提问时**整条 ls 线被 `KB_STRICT_LINE` 滤掉** —— 卡片在库里有，但客户360 背景永远看不到。
- **修复**：函数加 `line='da'` 形参（默认值保持旧行为，向后兼容）；内部改用 `line`；两处调用点（L3052/L3179）改为传 `user.get('line', 'da')`。
- **验证**：非超管 + 可见名单，`line='ls'` → 客户360 长度 8950 且含 `BR1D303_qPCR熔解曲线异常` 案例卡；`line='da'` → 10857 且不含 ✅。
- **⚠️ 教训**：`KB_STRICT_LINE = True` 的线隔离是**静默的** —— 传错 `line` 不报错、只是结果里没有；排查时容易误判成「卡片没入库」。这类「静默过滤」的参数，验收必须**对比两条线**，不能只测一条。

### 断点 2：`kb_index.json` 无自动重建

- **现象**：索引是静态快照，只有手动跑 `kb_build_index.py` 才更新 → 往 `知识库-copilot/` 新加卡片后**进了目录却检索不到**。
- **修复**：把 `('脚本/kb_build_index.py', '知识库索引重建', ROOT)` 加进 `~/.hermes/scripts/daily_full_refresh.py` 的 `PIPELINES`（挂在 cron「全量数据刷新-早9点」）。为此把循环从 `for script, desc in PIPELINES` 改成支持可选的**第 3 元素 cwd**（该脚本必须从 ROOT 跑，DATA 下跑不起来）。
- **验证**：**重建幂等** —— 连跑两次 entries（239 条）逐字相同，仅顶层 `built` 时间戳变；用只含该步的测试副本真跑，输出 `[知识库索引重建] 完成 (1s)`、exit 0。
- **耗时**：1.12 s，放进每日全量刷新无负担。

### 断点 3（最容易漏）：改完 `ai_chat.py` 必须重启 8765

`销售工作台服务.py` 是**函数内 `import ai_chat`**，进程已跑 2 天 11 小时 → 首次请求即缓存旧模块，**不重启则不生效**。
重启：`launchctl kickstart -k gui/$(id -u)/ai.hermes.workbench`（标签 **`ai.hermes.workbench`**，不是 `workbench`）。
重启后新 PID、`/` → 200、`/api/health` → 401（鉴权正常）。

> 对照：**`kb_index.json` 不用重启**（按 mtime 缓存），**`ai_chat.py` 必须重启** —— 同一次改动里两者行为相反，别搞混。

## 未覆盖的原文分支（待补）

原 FAQ 稿的 `RCR` / `克隆` / `WB` 三个分类**只有标题、无内容**；案例库的产品分类骨架（cDNA合成试剂 / qRT-PCR试剂 / PCR相关产品 / 蛋白裂解 / 蛋白定量 / 蛋白胶 / 预染蛋白Marker / 显色 / 工具酶 / 产物纯化 / DNA提取 / RNA提取）也是**空目录**。未凭空补写，已在交付中列为待补料。

---

## 附：竞品大表解析的两个「假厂家」陷阱（2026-09-30）

做 `生命科学产品线-竞品大表-V1.0.xlsx` 转写+分析时，`厂家` 列（格式「厂家-货号（品名）」）按换行拆分后**会造出假厂家**：

1. **同一单元格列同厂多货号** → 碧云天单元格「P1005-xxx ⏎ **P1045**-yyy」拆出的 `P1045` 被当成第 33 家厂商（真值 32）。
2. **原表用 `/` 占位空值** → 拆出的 `/` 被当成一家厂商（真值再减 1 → **31 家**）。

**修法**：拆分出的片段必须**能归一化到已知厂家表**才计数，否则丢弃（`split_vendors()` 的 `KNOWN` 过滤）。修前 33 → 修后 31，与「按单元格计」口径一致。
**教训**：中文表格里「厂家-货号」这种半结构化字段，**换行拆分必须配白名单校验**，否则占位符和后续货号都会变成假实体 —— 这类假实体一旦进入统计，会让所有占比分母都错。
