# 阶段 4：插件接入（wiki_context / wiki_verify）

> 日期：2026-11-05 ｜ 插件：dsh-wiki-notes **1.4.0** ｜ Bridge：`wiki_bridge.py` **0.1.0** ｜ 契约：**1.0** ｜ 管线：**2026-11-05**

## 这一阶段到底改了什么

插件从「Markdown 检索插件」变成「知识网关」：多了两个工具，但**判断逻辑一行没搬进来**。

```
模型 → wiki_context(query) → 插件校验参数 + 找库路径 → spawn Python wiki_bridge.py context
     → 读取一行 JSON → 原样交给模型（渲染成人看的文本）
模型 → wiki_verify(context_id, answer) → 同一个 Bridge → 快照里取回原 context 复核
```

插件负责：MCP 注册、参数校验、库路径发现、错误隔离、把异常翻成结构化错误。
Python 负责：检索规划、排序、证据抽取、support 判定、冲突、Answer Policy、回答核验、快照。

- `wiki_recall` **保留不动**，仍是"把命中小节原文给我"的简单入口。
- 两个新工具只在**配置了 `bridge.toolsRoot`** 时才注册可用；没配就报 `E_PYTHON_UNAVAILABLE` 并指路。

## 进程协议

| 项 | 约定 |
|---|---|
| 调用 | `spawn(python, [wiki_bridge.py, <command>])`，`cwd = toolsRoot` |
| 输入 | stdin **一行 JSON**（`{query, limit, engine, ttl_seconds, plugin_version}` 或 `{context_id, answer}`） |
| 输出 | stdout **一行 JSON**，可能是 context / verify / error 三种信封之一 |
| 诊断 | 只走 stderr（`[diag] …`），插件丢弃不解析 |
| 退出码 | 业务失败也返回 **0** + `error` 信封；非 0 才算 Bridge 崩了 |
| 命令 | `context` / `verify` / `ping`（ping 是内部健康检查，**不进契约**） |
| 编码 | 两侧都强制 utf-8（`PYTHONIOENCODING`/`PYTHONUTF8` + Python 侧 `reconfigure`） |

stdout 解析取**最后一行能 parse 的 JSON**，容忍 Python 偶尔往 stdout 漏杂音。

## 文件清单

**Python 侧（`<你的库根目录>\工具-tools`）**

| 文件 | 作用 |
|---|---|
| `wiki_bridge.py`（新，约 330 行） | 契约整形层：`shape_context()` / `_engine_slot()` / `_violation_norm()` / `_error_envelope()` |
| `context_snapshot.py` | 加 `_json_safe()`，清 lone surrogate 再落盘 |
| `answer_policy.py:216` | `numeric.check_failed` 的 type 改为 `numeric_check_failed`（契约 enum 只认这个） |

**插件侧（`<插件仓库目录>`）**

| 文件 | 改动 |
|---|---|
| `lib/bridge.js`（新，约 210 行） | `runBridge()` 进程管理 + 错误分类，`errorEnvelope()` 与 Python 同形 |
| `lib/config.js` | `DEFAULT_BRIDGE_CONFIG = {python, toolsRoot, timeoutMs: 120000, ttlSeconds: 1800}`、`readBridgeConfig` / `writeBridgeConfig`（支持 `DSH_WIKI_TOOLS`、`DSH_WIKI_PYTHON` 覆盖） |
| `lib/index.js` | `registerGateway()` 注册两工具、`callBridge()`、`renderContext()` / `renderVerify()`、`readPluginVersion()`、`gatewayDisposers` 单独 try/dispose |
| `package.json` | 1.3.0 → **1.4.0**，描述补两工具 |
| `tests/gateway.mjs`（新） | 22 项端到端，含跨通道逐字段一致 |

**运行时目录 `<DSH 运行时插件目录>\` 与源码仓库是 junction（同一批文件）**——改源码即改运行时，但**必须重启 DSH 才重新加载**。

## 契约映射要点

| Python 输出 | 契约字段 | 处理 |
|---|---|---|
| `engines["search.py"]` | `engines.search` | key 改名（契约里不叫 `search.py`） |
| `engines["wiki_recall"]` | `engines.wiki_recall` | 同名 |
| 未跑到的引擎 | `status: disabled` | `not_run` 翻成 `disabled`，**不和 empty 合并** |
| `pipeline.{stage,degraded,degraded_sources}` | `status.{stage,degraded,degraded_sources}` | 直接搬 |
| `evidence[].supports[].quote` | 同名 | **不裁剪、不截断**（阶段 0 的样例脚本裁到 110 字，生产路径不裁） |
| `_violation_norm(v)` | `violations[]` | 11 条 rule 映射成 11 个 type，未映射的走前缀兜底并在 reason 前标 `[未映射规则 X]` |
| `snapshot` | `snapshot{context_id, expires_at, saved}` | 保存失败时自造 `ctx_<uuid16>` 并明说 `saved:false` |

四版本各自独立写在响应里：`plugin_version`（插件 package.json）、`bridge_version`、`contract_version`、`pipeline_version`。

## 错误码与降级矩阵

| 情况 | 码 | 模型看到的效果 |
|---|---|---|
| 没配 toolsRoot / python 路径不存在 | `E_PYTHON_UNAVAILABLE` | 只有错误信封，**没有 status、没有 evidence** |
| python 起了但崩了 | `E_BRIDGE_CRASH` | 同上 |
| 输出不是 JSON | `E_BRIDGE_BAD_JSON` | 同上 |
| 超过 120s | `E_BRIDGE_TIMEOUT`（retryable） | 同上 |
| 库没配置 | `E_WIKI_ROOT_MISSING` | 提示去配置根目录 |
| query 为空 / answer 为空 / 缺 context_id | `E_QUERY_EMPTY` / `E_VERIFY_EMPTY_ANSWER` / `E_CONTEXT_NOT_FOUND` | 插件层就挡住，不启动子进程 |
| 某个引擎故障（另一个正常） | 无码，`status.degraded=true` | `engines.search.status=error` + `degraded_sources`，证据照出 |

**铁律**：Python 不可用 ≠ 知识不足。前者是环境故障（只有 error 信封），后者是 `status.support=insufficient`（有完整 context）。两者在响应形状上就不一样。

## 配置

两份 `wiki-notes.config.json`（源码仓库 + 运行时目录）都加了：

```json
"bridge": {
  "python": "C:\\Users\\<用户名>\\python\\python.exe",
  "toolsRoot": "<你的库根目录>\\工具-tools",
  "timeoutMs": 120000,
  "ttlSeconds": 1800
}
```

## 测试

**Python 侧**

| 脚本 | 结果 |
|---|---|
| `eval_bridge_contract.py`（新，51 项） | 51/51：context/verify 三类/4 种错误信封/两次调用稳定/ping，每类都过 schema |
| `eval_answer_policy` | 8/8 |
| `eval_numeric_claim` | 13/13 |
| `eval_support` | 3 passed |
| `eval_engine_state` | 6/6 |
| `eval_4d` | 7/7 |
| `eval_support_reason` | 34/34 |
| `eval_snapshot` | 12/12 |
| `eval_conflicts` / `eval_dedup` | 8 / 8 |
| `eval_method` / `eval_blocks` | 7/7、9/9 |
| `e2e_check --check` | 8 题漂移 0（重庆申论页入库后重录 1 条基线，非回归） |
| `protect.py` | 35 条漂移 0 |

**插件侧**：`tests/gateway.mjs` 22/22；原有 17 个测试全绿（smoke/help/topics/lintdiff/lint/params/fields/robust/api/explain/cli/contract/draft/watch/client/status），旧测试只把 mock 改成按名取 `wiki_recall`（注册方式改成按名取后，旧 mock 的「取第一个工具」假设过期了）。

**实测开销**（本机，冷启动）：`ping` 117ms（纯 spawn + import），完整 `context` 993ms（内部 855ms）。一次问答可接受；高频短问答建议后续做常驻或连接复用。

## 踩过的坑

1. **中文 query 变乱码** → payload 里出现 lone surrogate → 快照写盘 `UnicodeEncodeError`。修：两侧强制 utf-8 + `_json_safe()`。
2. **`build(root=字符串)` 炸 `'str' object has no attribute 'rglob'`** → 必须传 `Path`。
3. **`quote_support: "clue"` 不在 schema enum 里** → 实跑就撞上，补进枚举。
4. **`verify.status` 两套叫法**（实现 `context_missing`、schema `context_not_found`）→ 统一为 `context_missing`。
5. **插件注册 3 个工具后旧测试拿到的是最后一个** → mock 按名过滤。
6. **gateway 测试硬编码 `1.3.0`** → 改为从 `package.json` 读。

## 已知局限（照实写）

1. **页面读取有两处实现**：`wiki_recall` 走 JS 的 corpus 读盘，`wiki_context` 走 Python 的 `read_page`。用户要求"只能有一个权威实现"，这一条**尚未做到**——两条链路各有各的清洗与截断规则，同一页可能给出略有差异的正文。收口方式待定（要么 wiki_recall 也转 Python，要么 Python 复用 JS 的语料索引）。
2. **插件不校验 schema**：只透传 + 基础参数校验，字段形状靠测试兜底，运行时不拦。模型不会因为 Bridge 多写一个字段就报错。
3. **无常驻进程**：每次都 spawn，`python -c import` 就吃掉约 120ms。
4. **timeout 固定 120s**，不按命令区分（context 与 verify 的实际耗时差一个量级）。
5. **`ping` 不进契约**：它是内部健康检查，schema 的 `oneOf` 里没有它。
6. **错误码靠字符串对齐**：Python 与 JS 各写一份同名字符串，没有生成式校验；改一处忘另一处，只有测试能发现。

## 下一步（阶段 3 原计划）

- ~~`wiki_observe` 自动记录（现在 `observe_id` 恒为 null，观察链路还没接上）。~~
  **已接通（1.4.2）**：插件侧记下 `context_id ↔ observe_id` 这一对，wiki_verify 转发时带上，
  观察日志里 verify 记录的 `parent_observe_id` 能指回它的 context。
  插件侧补了显式 `observe_id` 参数兜底；Python 侧契约与实现未动。
- 降级策略细化：单引擎 error 时是否给模型一条「本次只有一路召回」的显式提示。
- 第 1 条局限的收口：页面读取单一权威实现。
