Skip to content

fix: 检索与快照边界硬化——变异审计 8 面扫描与工具链收口 - #3

Open
MisonL wants to merge 198 commits into
mainfrom
fix/retrieval-boundary-hardening
Open

MisonL wants to merge 198 commits into
mainfrom
fix/retrieval-boundary-hardening

Conversation

@MisonL

@MisonL MisonL commented Oct 13, 2026 •

Copy link
Copy Markdown
Owner

目的

将 fix/retrieval-boundary-hardening 分支 197 个提交的系统性对抗审计成果收口进 main。

收口前做了一次四维 agents 全维度终审(安全、数据完整性、并发/生命周期、错误契约/UI),四名审查 agent 对全部分支改动做对抗性复查,确认缺陷逐条真实入口探针复现、修复、红/绿验证后并入(详见下文「终审轮修复」)。

本分支围绕 ETL → embedding → FAISS 快照 → 混合检索 → provider chat 全链路做边界硬化:以变异测试驱动(8 类变异面:drop/notin/isnot/eqneq/cmpdir/boolc/boolop/arith),每一处守卫缺失先用真实入口做红/绿验证,再补回归测试;验证过程中发现的验证基础设施假结论风险(陈旧 worktree 假绿、退出码 4 假红、行号漂移、自指测试)以防呆机制固化进工具。

影响范围

  • src/(32 个文件):providers、utils、services、retrieval、etl、ui 等模块的形态校验收敛、异常分类修复、协议白名单(server_verified_protocols)、bool/非有限值守卫、索引与快照写入边界加固;死代码消融 3 处
  • tests/(37 个文件):测试从 4185 增至 4488(+303 条回归测试,全部经红验证)
  • scripts/audit/:新增可复现的变异审计工具链(mutate.py / list_sites.py / verify_sites.py + regressions/green38.txt 回归夹具 + README),AGENTS.md 同步;含防呆保证(worktree 卫生检查、漂移定位、选择器预检、退出码分类、还原复查、自指测试探测器)
  • CHANGELOG.md:同步各轮审计条目

配置与生成数据影响

  • 无新增运行时配置项;config.toml.example / .env.example 未变更
  • data/kb/、data/logs/ 运行产物不受影响,仍不入库
  • 知识快照仍为 Dify 兼容 pickle,加载信任边界不变

终审轮修复(四维 agents 复查,commit dad5e9a)

维度 缺陷 修复
安全 ghp_/github_pat_/xox/AKIA/ASIA/jina_ 裸凭证不脱敏 _SELF_ATTESTING_PREFIX_RE 并入管线(12 用例)
数据完整性 _key_text/_parent_key 对 repr 超 80 字符值塌键 → 静默并条 对真实 repr 字节取 sha256(safe_repr 截断输出恰 80 字符,len(rendered)>80 判据永不触发——测试先红暴露)
数据完整性 numpy 2.x 标量键与 main 时代 str() 快照失配 → 召回静默 miss .item() 归一,逐字一致
数据完整性 embeddings 行数从不与文档对齐 → 张冠李戴/IndexError _validate_row_alignment 三量对齐,两个载入入口共用,原子性保持
错误契约 /config 回显抛 ValueError 终止整个会话 编辑器调用+回显收进恢复 try;回显失败只报告不回滚
错误契约 菜单非法 retrieval_method 显示 当前: None 改「未知」,与 Rerank 项契约一致
并发/生命周期 finally 裸 await aclose() 顶替正常收尾与在途异常 吞掉+记录;红验证还暴露局部 logger 撞名的 UnboundLocalError
并发/生命周期 retry_sync_stream 建立阶段 close 顶替原始 429(双向污染重试分类);iter() 拿到的是 SDK Stream.__iter__(生成器函数)的包装生成器,close 不关底层响应 吞错+补 response.close(),连接释放恢复确定性
并发/生命周期 embed_knowledge_base.main_async 从不释放 EmbeddingService(四入口唯一) 补 finally aclose

已核实不修(记录于 CHANGELOG):/config 期间 Ctrl+C 挂死(main 时代既有行为,修复需重构 questionary 线程模型);retry_*_stream_context 裸 close(anthropic manager 无 close 方法,生产不可达)。

验证命令(2026-10-13 本地全绿)

uv run pytest -q                                          # 4488 passed in 119.84s
uv run ruff format --check main.py src scripts tests      # 99 files already formatted
uv run ruff check main.py src scripts tests               # All checks passed
uv run bandit -r main.py src scripts -ll                  # 0 Medium/Low(16 High 均为既有 nosec 豁免路径,与基线一致)
uv run mypy --cache-dir /tmp/pyrag-kit-mypy main.py src   # no issues in 49 source files
uv run python -m compileall -q main.py src scripts tests
uv lock --check && git diff --check
uv run main.py --smoke-test                               # 1.4.0 smoke test ok
uv run python scripts/audit/verify_sites.py --selftest    # 失败: 0

变异审计回归夹具(手动运行,不接入默认 pytest):

uv run python scripts/audit/verify_sites.py --worktrees wt1,wt2,wt3,wt4 \
  scripts/audit/regressions/green38.txt
# 38 位点 — KILLED=34, SURVIVED=4(与 expected 逐条一致,exit 0)

🤖 Generated with Claude Code

MisonL and others added 30 commits September 23, 2026 22:54
#1)

feat: unify provider factory, protocol surface and credential boundary
chore: configure and apply ruff format
本轮最后一批修复此前未写入 CHANGELOG,若直接打 tag 会发出缺说明的 Release Notes。
补入「审查修复」节的内容:分片在分隔符耗尽时失效、分片开头标点被删除、pickle 加载
来源边界、manifest schema 准入、快照数据源自洽、legacy 导入临时目录残留、
SessionConfig 无校验、BM25 静默空结果与空语料崩溃、嵌入矩阵有限性、退出钩子对
cache_path 的无条件删除、数值解析宽松放行与溢出、列表类 env 字段的未处理异常、
发布包 ZIP 悬空符号链接、虚假成功提示、UI 输入上界、pytest 警告过滤收窄,
以及此前完全空缺的测试覆盖(legacy 导入、archive_bundle、cleanup_temp_files、
pickle 信任边界)。
审查中反复出现的同一类缺陷:校验只覆盖了"顶层形态",而**条目/派生值**没有
覆盖;失败时抛的不是本项目的 ValueError,而是裸 TypeError / IndexError /
AttributeError / OverflowError,穿透调用方全部 `except ValueError` 分类,且
错误消息指向与字段无关的位置。pickle 是二进制格式、不受十进制位数限制,
因此畸形快照能把这类取值带进生产路径。

FaissStore:
- `lexical.index` 的**条目**形态未校验,`[7]`/`[1.5]`/`[None]` 穿过顶层守卫
  与条数校验后在 `BM25Okapi._initialize` 抛裸 TypeError。
- `embeddings` 的维度未校验:0 维抛 `len() of unsized object`、1 维抛
  IndexError、legacy pkl 里的普通列表抛 AttributeError。更严重的是该检查
  发生在赋值**之后**,破坏了 `load()` 自己承诺的原子性——实测一次被拒的
  load 之后 documents 已是 2 条而 FAISS 索引仍是旧的 1 条。
- `semantic_search` 的查询向量:`np.asarray(10**4301, dtype=np.float32)` 抛
  OverflowError。守卫包在转换外层而非逐元素预检,因为
  `np.zeros(4, dtype=np.float32)` 是 `embed_query` 的真实输出,逐元素判有限性
  会把合法的 numpy 标量一并拒掉。
- `keyword_search` 的非字符串查询直接进 jieba,抛 AttributeError。

SnapshotRepository:`validate_snapshot_dir` 只查顶层与条数,坏快照因此能被
finalize 成**活动快照**,此后每次启动都在载入期失败。现与 FaissStore 对齐,
在激活之前拦住条目形态。

RetrievalService.retrieve(生产入口,每次提问都走):
- `top_k`/候选倍率:`max()` 对非数值抛裸 TypeError。
- `score_threshold`/`rerank_enabled`/`hybrid_fusion_strategy`:前者裸
  TypeError;中者**不抛异常**,真值语义把 None/0/[] 静默当"禁用"、把 "no"
  当"启用"——行为与配置不符却无提示。
- rerank provider 的 key 与 configurations:不可哈希 key 在 `dict.get()` 处
  就抛 TypeError,**早于**工厂守卫;非映射 configurations 抛 AttributeError。

ModelProviderFactory / Google:provider key 的成员判断对不可哈希取值抛
TypeError、消息插值对位数超限整数抛 ValueError(同一判据已在
`runtime/contracts._require_known_field` 认领过);`partial_args` 的 json_path
索引 `\d+` 无长度限制,超长索引在 `int()` 处抛整数转换超限。

验证:每个修复都走了红绿——先确认未改源码时新断言变红(多次用 git stash
复现),再改源码转绿,`cmp -s` 确认还原逐字节一致。全量 2306 passed。
严格 fuzz(3 种检索模式 × 2 种 rerank 开关 × 13 个字段 × 13 种畸形值)只放行
ValueError,复跑后逃逸为 0。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
同一缺陷类的三处收场不一致:`*_configurations` 声明为
`dict[str, ModelDetail]`,但 `SessionConfig._VALIDATORS` 明确不登记它们
(注释写明「按原样写入,由使用方负责其内部结构」),于是每个使用方都
必须自己判形态,而多数只判了一部分。

1) `key in mapping` 只证明**键**在,不证明**值**可用
   `{"k": None}` 的键确实存在,`ModelProviderFactory._get_model_detail`
   于是不走「未找到key」分支,把 `None` 原样返回;三个入口紧接着读
   `config.provider`,抛裸 `AttributeError`(不是 `ValueError`,穿透
   `Chatbot.reload_llm` 的 `except ValueError` 分类)。同一缺口还在
   `RetrievalService._get_rerank_provider` 与
   `display_utils._require_model_detail`(后者在
   `start_chat_session_async` 里于任何提示符之前被调用)。

   新增 `src/utils/config.py::require_detail_attributes`,三处共用。
   判据用**属性存在性**而不是 `isinstance(detail, ModelDetail)`:使用方
   只要求取值提供这几个属性,按具体类型收窄会把仓库里用
   `SimpleNamespace` 构造的等价配置一并拒掉——实测该写法让 17 个既有
   用例变红,因此改为属性存在性判定。

2) `provider_key not in configurations` 对不可迭代的容器抛裸
   `TypeError: argument of type 'int' is not iterable`;容器是字符串时
   走**子串**判定(`"k" in "k"` 为真),随后 `"k"["k"]` 抛
   `TypeError: string indices must be integers`。取值经
   `Chatbot.reload_llm` 从可直写的 `session_config.llm_configurations`
   原样传到这里。现先判 `isinstance(configurations, Mapping)`。

3) `RetrievalService._get_rerank_provider` 的 `cache_key` 两个分量
   `provider`/`model_name` 此前只经 `getattr(..., "")` 取值,有默认值
   但无类型判据。`ModelDetail` 构造时会拒绝非字符串,但普通属性没有
   赋值守卫(`validate_assignment` 未开)——实测
   `detail.provider = ["p"]`、`detail.model_name = ["m"]` 都直接成功,
   随后在 `self._rerank_providers.get(cache_key)` 处抛裸
   `TypeError: unhashable type`。`del detail.provider` 同样成功,兜出的
   空串会把「属性没了」伪装成「provider 名为空」。新增 `_is_hashable`:
   判据用可哈希而非 `isinstance(..., str)`,按类型收窄会误伤本就能用的
   非字符串可哈希取值。

4) 顺带修掉一处注释触发的 mypy 语法错误:`#     type: 'list'``;` 被
   mypy 识别成 PEP 484 风格的类型注释并去解析其内容,报
   `Invalid syntax` 且阻断整个项目的类型检查(`errors prevented further
   checking`)。`ast.parse`/`ruff`/`compileall`/全量测试在此前全部
   通过,失败模式只在 mypy 可见。

红绿验证:全部新增用例先确认在未改源码时变红(对三个文件分别 stash 后
重跑),再确认改后变绿,随后 `git stash pop` + `cmp -s` 确认源码逐字节
还原。质量门全过:ruff format --check/ruff check/bandit -ll(0 issues,
含 0 High/0 Medium)/mypy(49 files,0 error)/compileall/
uv lock --check/git diff --check;`uv run main.py --smoke-test` 正常,
`CHAT_TOP_K=0` 的错误路径仍是脱敏的 `ValueError`。全量测试 2340 passed。

已知未修(reachability 有限,留待确认):`src/ui/config_menu.py` 的
`list(chat_config["llm_configurations"].keys())` 对非映射取值仍抛裸
`AttributeError`;该路径只在直接调用 `launch_config_editor` 时可达,
应用自身的启动流程已由 `display_chat_config` 在上游拦下。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`edit_model_params` 直接写 `list(chat_config["llm_configurations"].keys())`,
而 `SessionConfig._VALIDATORS` 明确不登记 `*_configurations`(「按原样写入,
由使用方负责其内部结构」),嵌入方可以把它直写成 `5`。实测抛裸
`AttributeError: 'int' object has no attribute 'keys'`——不是 `ValueError`,
而 chat 主循环只捕获 `KeyboardInterrupt`/`EOFError`,于是整个会话以裸
traceback 终止。Rerank 那处同源同形(`'str' object has no attribute
'keys'`)。现经 `_configuration_keys` 收敛,判据与
`display_utils._require_model_detail` 一致。

判据的取舍:**只判容器、不判取值**。`display_chat_config` 在上游读值时会直接
拒绝形态坏掉的配置项,菜单是用户唯一还能把激活项切走的入口——如果这里也按
取值判,配置一旦写坏就彻底没有修复路径了。这条由
`test_edit_model_params_lists_unusable_details_without_reading_them` 锁住。

顺带把 `elif choice == "rerank":` 拆成独立的 `if`,并给两个分支各加显式
`continue`,让「进入该分支后不再落到后续分支」这件事在源码里直接可见。

红绿验证:先把 `src/ui/config_menu.py` stash 回 HEAD,确认新增用例变红
(`AttributeError`),再确认改后变绿,随后 `git stash pop` + `cmp -s` 确认
源码逐字节还原。质量门全过:ruff format --check/ruff check/bandit -ll
(0 issues)/mypy(49 files,0 error)/compileall/`uv lock --check`/
`git diff --check`;`uv run main.py --smoke-test` 正常。全量测试 2344 passed。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
写入侧 `EmbeddingService._as_float32_matrix` 早就拒绝 NaN/inf 向量,理由也
写在注释里:FAISS 把 NaN 向量照单收下(`ntotal` 正常增加),既不报错也不跳过。
但两个载入入口 `FaissStore.load`(legacy pkl)与 `load_snapshot` 共用的
`_validate_embeddings_shape` 只判 `ndim`——同一份内容**写入侧拒绝、载入侧
放行**。

实测后果(载入一份含 NaN 向量的快照后):

    q=[0,0,0,0]     距离=[0.0, 0.0, 3.4028235e+38]  下标=[0, 2, -1]
    q=[1,1,1,1]     距离=[4.0, 4.0, 3.4028235e+38]  下标=[0, 2, -1]

坏文档的下标干脆不出现,也不报错——用户看到的是「搜到了」而不是「这份快照
坏了」。`upsert_embeddings` 是 `VectorStoreBase` 的公开契约方法,直接调它的
嵌入方绕开 `EmbeddingService`,坏向量还会被 `save_snapshot` 落盘、再由
`validate_snapshot_dir` 放行成活动快照(实测该路径全程无异常)。

判据必须落在 `astype(np.float32)` **之后**:`1e300` 在 float64 里是**有限的**,
cast 成 float32 才溢出成 `inf`。只对原数组调 `isfinite` 会让载入侧在这一点上
再次与写入侧分叉——那个 cast 正是写入侧能拦住这一类的原因。所以四个方向都有
用例:NaN、正无穷、负无穷、float64 有限但 cast 后溢出。

cast 只是校验用的副本(不改写 `embeddings` 本身),因此失败时 store 仍保持
原状;`catch_warnings` 只压**预期内**的 cast 溢出警告,并另加一条用例锁住
「正常向量不泄漏 RuntimeWarning」。

红绿验证:先把 `src/retrieval/vdb/faiss_store.py` stash 回 HEAD,确认 7 个新增
用例全部变红(其中 post-cast-overflow 那条在 HEAD 下还会从 `_rebuild_indices`
里泄漏 `RuntimeWarning: overflow encountered in cast`),再确认改后变绿,随后
`git stash pop` + `cmp -s` 确认源码逐字节还原。质量门全过:ruff format
--check/ruff check/bandit -ll(0 issues)/mypy(49 files,0 error)/
compileall/`uv lock --check`/`git diff --check`;`uv run main.py --smoke-test`
正常。全量测试 2352 passed。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`register_parent_documents` 直接写 `for ... in parent_documents.items()`,
非映射传进来时抛裸 `AttributeError: 'int' object has no attribute 'items'`。
载入侧的 `_normalize_documents` 对**同一形态**给的是带指引的
`ValueError: 知识快照不自洽:父块索引的顶层不是映射(int)…`。

两者都是 `VectorStoreBase` 的公开契约方法,而 `_normalize_documents` 的注释
本就写明「`load`/`load_snapshot` 是契约方法,直接调它们的嵌入方不经过那道
校验」——同一取舍只在一侧执行了。

可达性如实标注:本仓自带流程到不了写入侧这条分支
(`knowledge_build_service.py:76-77` 传的是 `dict`,且前面已用
`isinstance(..., dict)` 过滤),因此这是一致性修复而非崩溃修复——嵌入方直接
调这个公开方法时,拿到的错误应当和其它入口一样指向「配置项形态错了」。

另外,同组配对里还有一处**看似分叉但实测等价**的差异,已核实后**不改**:
写入侧 `if normalized["content"]` 丢弃空 content 的父条目,载入侧的字典
推导式没有对应判据。两者 `parent_documents` 的键集确实不同,但
`resolve_parent_content` 有 `isinstance(content, str) and content` 兜底,
两份 store 喂进 `_promote_parent_context` 走真实提升路径后
`page_content` 相同、`a == b` 为 True——属内部表示差异,不是可观察缺陷。

红绿验证:先把 `src/retrieval/vdb/faiss_store.py` stash 回 HEAD,确认 5 个
参数用例全部变红(`AttributeError: 'int' object has no attribute 'items'`),
再确认改后变绿,随后 `git stash pop` + `cmp -s` 确认源码逐字节还原。质量门
全过:ruff format --check/ruff check/bandit -ll(0 issues)/mypy(49
files,0 error)/compileall/`uv lock --check`/`git diff --check`;
`uv run main.py --smoke-test` 正常。全量测试 2359 passed。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
基类 `TextEmbeddingModel.embed_query` 先判 `if not vectors` 再取 `[0]`,
空结果得到带指引的 `ValueError("Embedding provider 返回空结果。")`。
`GoogleProvider` 为设 `task_type` 覆写了 `embed_query`/`aembed_query`,
却把那条兜底一并丢掉,直接下标取 `[0]`:

    google embed_query(provider 返回空)   -> IndexError: list index out of range
    google aembed_query(provider 返回空)  -> IndexError: list index out of range

空返回不是假想输入:`embed_documents` 的条数校验按 `len(texts)` 做,传空
`texts` 时 `len(values) == len(texts) == 0` 成立,方法正常返回 `[]`;网关
截断响应或返回空 `embeddings` 字段也走到同一形态。`IndexError` 不是
`ValueError`/`RuntimeError`,会穿透调用方按这两类做的错误分类与日志脱敏,
而消息与「provider 返回了什么」毫无关系。

这一条与前面几轮的缺陷同类:守卫存在于基类(或另一条路径),覆写/另一侧
把它丢掉了。四个 provider 里只有 Google 覆写了这两个方法,因此只有它分叉。

红绿验证:先写 5 个用例(4 个断言 `ValueError`、1 个断言正常路径),确认在
未改源码时 4 红 1 绿(`IndexError: list index out of range`,指向
`google.py:4401`),改后 5 全绿。其中两个用例专门锁住覆写的**存在理由** ——
`task_type` 必须仍是 `RETRIEVAL_QUERY`,避免「为补兜底而把覆写删掉」。

质量门全过:ruff format --check/ruff check/bandit -ll(0 issues)/
mypy(49 files,0 error)/compileall/`uv lock --check`/`git diff --check`;
`uv run main.py --smoke-test` 正常。全量测试 2364 passed。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
## 问题

流式工具调用的 `type` 在两条协议上取值不同,且都可合法表示函数调用:

- Chat Completions 与各家 OpenAI 兼容流式片段用 `function`;
- Responses 用 `function_call`。

`validate_complete_tool_call` 的 JSON 判据只认 `function_call`(其
`tool_call.get("type", "function_call")` 的默认值也是它),于是 `function`
既不缺参数(非空串)也跳过 JSON 校验——半截参数被当作完整工具调用交付。

实测四条真实流式路径传半截 JSON(`'{"order_id":'`)时,`tool_call_completed`
把这段文本原样交给调用方、无任何报错:

- OpenAI Chat Completions(`_chat_completion_events`)
- Google Gemini(`_complete_stream_tool_calls`)
- Anthropic Messages(`stream_events` / `astream_events`)
- Ark Chat Completions(`stream_events` / `astream_events`)

调用方随后 `json.loads` 才失败,或更糟:把不完整的参数当成完整调用执行。

## 根因

`merge_tool_call_fragment` 的补默认值与四个 provider 的片段构造都写
`function`,只有 Responses 路径(`_merge_response_tool_call`)补
`function_call`。相同语义的两个字面量在判据处不等价。

## 修复

- `model_provider.py`:新增 `_FUNCTION_TOOL_CALL_TYPES = {"function",
  "function_call"}`,JSON 判据按该集合判断。`custom_tool_call` 的任意文本
  语义保持不变。
- `anthropic.py`:`stream_events` 与 `astream_events` 的 finish 分支补上
  `validate_complete_tool_call`。其余三个 provider 的同类路径都有这一步,
  只有 Anthropic 的两条直接 `yield` 累积结果——它在本次修复之前连「缺
  arguments」都不拦(补上后为 4 处调用点,同步/异步各 2)。

## 验证

红-绿:新增 5 个用例(4 个 rejects + 1 个 accepts 对照组),先确认未改源码
时 4 个 rejects 变红(`DID NOT RAISE RuntimeError`)、对照组通过;改后全绿;
再 `git stash` 回退源码复现红、`git stash pop` 复现绿,并与备份逐字节 `cmp`
确认恢复无损。

全量:2369 passed。关卡:ruff format --check / ruff check / bandit -ll /
mypy / compileall / uv lock --check / git diff --check 全部通过。烟测:
`uv run main.py --smoke-test` 输出 `PyRAG-Kit 1.4.0 smoke test ok`;
`CHAT_TOP_K=0 uv run python main.py --smoke-test` 仍给脱敏的
`ValueError: 配置校验失败 - chat_top_k: ...`。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
## 问题 1(上一提交 17c4cb7 引入的回归)

上一提交把完整性判据从 `tool_type == "function_call"` 改成集合成员测试
`tool_type in _FUNCTION_TOOL_CALL_TYPES`,但 `type` 是 `list`/`dict` 时集合
查找抛 `TypeError: unhashable type`。`==` 对任何类型都安全,因此这是那次
改动自己开的口子:扩大了覆盖面(`function` 现在也进 `json.loads`),却在
判据本身引入崩溃。

可达性同 `enum_field` 注释里记录的那条:`openai` SDK 对 SSE chunk 的未知
顶层字段用 `extra="allow"` 原样保留,`{"type": ["function"]}` 能构造出来。
实测 `OpenAI Chat Completions` 路径上 `TypeError: unhashable type: 'list'`。

修复:按 `enum_field` 的同一口径把非字符串收敛成 `""`(落在「不是已知
类型」的分支上,与 `merge_tool_call_fragment` 的行为一致)。

## 问题 2(同一判据的异常覆盖不全)

`except json.JSONDecodeError` 只接住「JSON 语法不完整」。`json.loads` 还会:

- 因整数位数超过 `sys.get_int_max_str_digits()`(默认 4300)抛
  `ValueError` —— **不是** `RuntimeError`,穿透调用方按 `RuntimeError` 做的
  错误分类,消息只有 `Exceeds the limit (4300 digits)`,与「哪个工具调用」
  无关;
- 因嵌套深度超过解释器栈抛 `RecursionError` —— 类型上是 `RuntimeError` 子类,
  但消息只有解释器文案,没有工具调用名。

两者都让一段**结构完整**的 arguments 变成与工具调用无关的解析异常。

修复:分别收敛成带 provider 与工具名的 `RuntimeError`。`custom_tool_call`
的任意文本语义不变(新增用例锁住)。

## 验证

红-绿:新增 4 个用例,先确认未改源码时 3 个变红(`TypeError: unhashable
type: 'list'` 与裸 `ValueError: Exceeds the limit`)、对照组通过;改后全绿;
再 `git stash` 回退复现红、`stash pop` 复现绿,`cmp` 逐字节确认恢复无损。
回归测试落在端到端路径(`test_openai_chat_stream_survives_non_string_tool_
call_type`),不只是 `validate_complete_tool_call` 那一层。

全量:2373 passed。关卡:ruff format --check / ruff check / bandit -ll /
mypy / compileall / uv lock --check / git diff --check 全部通过。烟测:
`uv run main.py --smoke-test` → `PyRAG-Kit 1.4.0 smoke test ok`。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
## 问题

`validate_complete_tool_call` 里相邻两条判据方向相反:

- 「必须有非空 arguments」用**黑名单**:`tool_type != "custom_tool_call"`;
- 「必须校验 JSON」用**白名单**:`tool_type == "function_call"`。

任何不在白名单里的取值于是同时满足「必须有参数」与「不必校验参数」,
半截 JSON 被当作完整调用交付。四个问题同源于此:

1. `function` 绕过校验 —— 流式片段的 `type` 在两条协议上取值不同:Chat
   Completions 与各家 OpenAI 兼容片段用 `function`,Responses 用
   `function_call`。判据只认后者。实测四条真实流式路径(OpenAI Chat
   Completions、Google Gemini、Anthropic Messages、Ark Chat Completions)
   传 `'{"order_id":'` 时都把这段文本当完整调用交付。

2. 未知取值绕过校验 —— 白名单方向使任何其它取值(网关改写过的 `type`、
   大写形式、空串)同样豁免。实测 `type="tool_call"` 与 `type="FUNCTION"`
   都交付半截 JSON。**这一条原本就有**,不是前一轮引入的;前两轮(17c4cb7、
   1f7ade7)的修法都只是往白名单里加条目,方向本身就错了。

3. 非字符串 `type` 抛裸 `TypeError` —— 白名单写成集合成员测试后,
   `list`/`dict` 触发 `TypeError: unhashable type`。可达性走 SDK 流式解析
   路径:`_process_response_data` 在非严格校验下用 `construct_type` 构造
   chunk,跳过已声明字段的 `Literal` 校验,实测 `construct_type` 后
   `call.type` 就是 `['function']`。(与 `enum_field` 注释里那条
   `extra="allow"` 保留未知顶层字段是同一后果、不同机制。)

4. Anthropic 的两条流式路径完全没有交付前校验 —— `stream_events` /
   `astream_events` 直接 `yield` 累积结果,其余三个 provider 的同类路径都
   调了 `validate_complete_tool_call`。因此它在判据修好之前连「缺
   arguments」都不拦。

## 修复

统一为**黑名单**:只做一次类型收敛(非字符串无法与 `custom_tool_call`
相等,按函数调用处理),只有 `custom_tool_call` 豁免——它的任意文本是
Responses 的 SDK 契约(`input: str`,无 JSON 约束)。顺带收敛同一处
`json.loads` 的另两类失败(整数位数超限抛 `ValueError` 而非
`RuntimeError`;深嵌套抛 `RecursionError` 但消息无工具调用名)。

## 验证

红-绿:新增 6 个用例,先确认未改源码时变红、改后全绿;再 `git stash`
回退复现红、`stash pop` 复现绿,`cmp` 逐字节确认恢复无损。端到端用例用
真实 SDK 的 `construct_type` 构造 chunk(即 SDK 流式路径的真实解析方式),
并复现真实流式形态——`finish_reason` 与最后一个 delta 在同一个 chunk 上
(我最初的夹具把两者拆成两个 chunk,`delta: {}` 不产生 finish 事件,
导致端到端用例假红)。

全量:2375 passed。关卡:ruff format --check / ruff check / bandit -ll /
mypy / compileall / uv lock --check / git diff --check 全部通过。烟测:
`uv run main.py --smoke-test` → `PyRAG-Kit 1.4.0 smoke test ok`。

同时合并 `[Unreleased]` 里描述同一函数演进的三条 CHANGELOG 为一条,并修正
上一提交中不准确的机制引用(原写 `extra="allow"` 保留未知顶层字段,实际是
`construct_type` 跳过已声明字段校验)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
上一提交把条目插在 `## [Unreleased]` 与 `### 修复` 之间,条目因此落在
`### 修复` 之外——与 1.4.0 及更早版本的写法不一致(每个 Unreleased 条目
都应归入某个 `###` 分类段)。

本次只调整标题位置:`### 修复` 移到 `## [Unreleased]` 之后、条目之前。
条目内容不变。

验证:`uv run pytest -q` 2375 passed;ruff format --check / ruff check /
bandit -ll / mypy / compileall / uv lock --check / git diff --check 全部通过;
`uv run main.py --smoke-test` → `PyRAG-Kit 1.4.0 smoke test ok`。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
六维对抗审查产出 24 条已复现缺陷,按三类根因分批修复。每条都在修复前
实测复现、修复后补回归测试(新增 82 个用例;回退源码后其中 58 个变红)。

异常分类泄漏(12 处):守卫抛出的异常不在调用方的 except ValueError /
RuntimeError 之内。

- 流式工具调用累加器把网关给的 index/id 直接当字典键:openai SDK 的流式
  解析走 construct_type、跳过已声明字段的 Literal/int 校验,index=[1, 2]
  原样到达后 setdefault 抛裸 TypeError。新增 hashable_key_part,不可哈希
  等价于「没有稳定标识」,退回 id/位置键;Responses 路径与同文件既有的
  hash() 守卫对齐。
- choices 非序列时 _raise_for_chat_response_error 的两条判据都不命中,
  choices[0] 抛裸 KeyError/TypeError。补容器形态判据(流式 usage chunk
  不带 choices 的语义不变)。
- 请求级 timeout 未经校验直达 httpx,SDK 把 OverflowError/TypeError 统一
  包成 APIConnectionError,而 is_retryable_error 按类名匹配 connection
  判定可重试——注定失败的请求被重试 3 次。现与构造期 options.timeout
  共用 normalize_client_timeout(httpx.Timeout 对象原样保留)。
- Google 的 _convert_tools 对畸形工具定义抛裸 AttributeError/TypeError;
  同形态在 anthropic.py 早已转成带序号的 ValueError。
- cleanup_old_logs 的 os.path.exists 对文件也返回 True,log_path 指向
  普通文件时抛裸 NotADirectoryError。改用 isdir。
- display_utils / config_menu 对可直写的 hybrid_fusion_strategy 用裸
  str()/.upper():位数超限整数抛 ValueError、非字符串抛 AttributeError。
  统一走 _render_value(safe_repr 会给字符串加引号,不能用)。
- SessionConfig.__setitem__ 的组合校验读兄弟字段时不做收敛,与 set_many
  不对称:兄弟是 list 时抛裸 TypeError,兄弟权重是 nan 时静默放行
  (nan == 0.0 恒为 False)。
- _runtime_verified_protocols 在容器形态校验之前就把 options 当 Mapping
  用,与 else 分支的 ValueError 分类不一致。
- 深层嵌套 TOML 让 tomllib 抛 RecursionError(继承 RuntimeError 而非
  ValueError),get_settings 的四个 handler 一个都接不住。

读写 / 入口校验不对称(6 处):

- 流式工具调用的 arguments 未归一化,同一输入在两条路径上类型不同。
- options.max_tokens 绕过 _validate_generation_count;请求级 max_tokens /
  candidate_count 同样无校验。
- Google 的 output_dimensionality 只过键名白名单,取值原样透传。
- 父分片器缺 overlap < size 判据(LangChain 对相等静默接受),而它在两种
  结构模式下都会构造。
- upsert_embeddings 不校验向量有限性,载入侧已校验——同一份内容「写得进、
  读不出」,且 save_snapshot 会把它落盘。
- load_snapshot 未对齐 semantic.index 的行数,第 2 篇文档静默不可检索。

静默语义破坏 / 数据丢失(6 处):

- Vertex 流式 delta 字典漏写 will_continue,使 stream_function_call_
  arguments 模式下每个多分片调用都抛 RuntimeError。
- json_path 下标无上界,模型返回 $.items[20000000] 即分配 2000 万元素。
- normalize_mapping 把 options 内每层嵌套键小写化,破坏 JSON Schema 的
  required/properties 对应关系。
- retrieval_candidate_multiplier 的 int(v) 静默截断小数、放行 "1_0"/"+7",
  与其它整数字段判据不一致。
- _key_text 的 safe_repr 降级描述让不同的超限整数塌成同一个去重键。
- _should_apply_score_threshold 用原始值与 "rrf" 比较,而 HybridReranker
  会先 strip().lower()——"RRF" 走 RRF 融合却套用阈值过滤,结果被清空。

同时修复 CHANGELOG.md 的结构:`# 更新日志` 标题与 1.4.0 的 `### 审查修复`
段头在早前的编辑中丢失,12 条条目脱离段落挂在文件顶部。

验证:uv run pytest -q → 2457 passed(原 2375 + 新增 82);红绿验证为回退
源码后 58 个新用例变红、恢复后全绿;ruff format --check / ruff check /
bandit -r -ll / mypy / compileall / uv lock --check / git diff --check 全部
通过;uv run main.py --smoke-test → PyRAG-Kit 1.4.0 smoke test ok;
CHAT_TOP_K=0 时仍为脱敏的 ValueError 且退出码 1。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
承接上一提交。上轮把第 12 条只修了一半——补的 `overlap < size` 判据拦不住
finding 演示的 1499/1500(该取值下 `overlap < size` 成立),本次补上比例上界。
另并入第二轮对抗审查(volcengine + anthropic 维度)的 5 条已复现缺陷。

分片重叠比例上界(`kb_chunk_overlap <= kb_chunk_size / 2`):

LangChain 的 RecursiveCharacterTextSplitter 只在 `overlap > size` 时报错,
**接近** size 时静默接受,而每个分片只前进 `size - overlap` 个字符。实测一篇
15000 字符的文档在 size=1500 时:overlap=150(10%)产出 14 个分片(1.1x),
overlap=1490(99%)产出 1651 个(137.6x),overlap=1499 产出 13751 个
(1145.9x)——全部进入 embedding 与 FAISS 写入,是「配置看着正常、代价上千倍」
的静默放大。50% 是主流 RAG 实践的通行上界,实测该点放大仅 1.9x。判据同时加在
Settings.validate_overlap_smaller_than_size(拦配置来源)与
RecursiveTextSplitter._validate_parent_sizes / _validate_hierarchical_sizes
(拦直接构造——它是公开类)。

anthropic / volcengine 渠道:

- Anthropic 服务端工具被静默降级成空 schema 的普通函数工具。白名单只认
  `server_` 前缀与三个 2025-01-24 字面量,而 SDK 里真实存在二十种服务端工具
  类型(web_search_20250305、code_execution_20250522、memory_20250818、
  web_fetch_20250910、browser_toolset_20260801 …)。实测请求体从
  {'type': 'web_search_20250305', 'name': 'web_search', 'max_uses': 5} 变成
  {'name': 'web_search', 'description': '', 'input_schema': {...}},max_uses
  被丢弃,web_search 静默失效。现从 SDK 的 ToolUnionParam 联合类型推导集合
  (SDK 新增工具时自动跟随),再与旧字面量取并集——SDK 会**移除**旧模型
  (实测 computer_20250124 已被 computer_toolset_20260801 取代),只取推导
  集合会让这些取值从「原样透传」退回「降级」,比修复前更糟。
- response_format 非 Mapping 抛裸 AttributeError(anthropic 与 volcengine
  两处)。options 那一侧早有守卫,但请求参数来源直接进这两个函数;google.py
  的同一判据给的是显式 ValueError。
- Anthropic 请求级 max_tokens 完全无校验:取值原样进 params,由 SDK 深处的
  _calculate_nonstreaming_timeout 执行 maximum_time * max_tokens / 128_000
  时才炸。10**400 抛 OverflowError(ArithmeticError 子类,既不落
  except ValueError 也不落 except RuntimeError),"abc"/[1] 抛 TypeError,
  1.5/-5/True 静默通过。现抽出 _require_positive_max_tokens 供两个入口共用。
- Anthropic 非流式路径漏读 refusal 键,拒答被静默丢弃。流式读
  `refusal or text or reason`,非流式只读 `reason or text`;实测
  content=[{"type": "refusal", "refusal": "被拒绝"}]、stop_reason="end_turn"
  时非流式返回 text=''、refusal=None,invoke() 输出空列表——一次安全拦截变成
  「空成功」。refusal 是 SDK RefusalBlock 的正式字段名,现三处逐字一致。
- Ark 动态资源路径在未命中触发字段时跳过整套参数校验:
  resources.responses.create() / create(store=True) / create(text={...}) 直达
  SDK,撞上 _utils 的 `assert "model" in kwargs`,抛 AssertionError——既不落
  except ValueError 也不落 except RuntimeError;同样参数走 Facade 得到的是
  带指引的 ValueError。现对 create/async_create 无条件校验,retrieve/delete/
  list/子资源仍按触发字段判断。

验证:uv run pytest -q → 2503 passed(上一提交 2457 + 新增 46);红绿验证为
回退源码后 32 个用例变红、恢复后全绿(源码与备份逐字节一致);ruff format
--check / ruff check / bandit -r -ll / mypy / compileall / uv lock --check /
git diff --check 全部通过;uv run main.py --smoke-test → PyRAG-Kit 1.4.0
smoke test ok。比例上界经全量测试确认未误伤任何既有用例。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
第三轮对抗审查的两处缺陷,均先实测复现、修复后加回归测试。

1. Anthropic 的 extra_headers 只校验容器、不校验取值。
   validate_secret_free_request_overrides 确认整体是 Mapping 后原样保留,
   实测 CompletionRequest(extra_headers={"anthropic-beta": 42}) 构造成功。
   非字符串值的收场都不在渠道的错误契约内:交给 httpx 抛
   TypeError: Header value must be str or bytes;本模块对 anthropic-beta 做
   .split(",") 时抛 AttributeError: 'int' object has no attribute 'split'。
   新增 _require_string_header_values:非字符串给带头名的 ValueError,None
   剔除(与「该头未设置」等价)。校验放在 _user_profile_fields 的两条分支
   之前——user=None 时该函数原本直接返回原样取值,而这是最常见的调用形态。

   同处另两个缺陷:
   - anthropic-beta 头值写 None 时抛裸 AttributeError('NoneType' object
     has no attribute 'split')。现于合并前剔除 None。
   - beta_count_tokens / async_beta_count_tokens 把 _user_profile_fields 的
     返回值丢进 _,转头写回原始的 request.extra_headers,被剔除的值于是
     原样发给 httpx。同文件 beta=False 的计数入口用的正是 profile_headers。

2. EmbeddingService.embedding_model_detail 只判键在不在、不判值可用。
   {"local-hash": None} 的键确实存在,None 被原样返回,两个调用点紧接着读
   .provider / .model_name,在那里抛裸 AttributeError;而同一份配置走
   ModelProviderFactory.get_embedding_provider 得到的是带字段名的
   ValueError——两条路径的报错契约本该一致。现复用
   require_detail_attributes(判据是属性存在性而非具体类型,测试用
   SimpleNamespace 构造等价配置)。最晚暴露的路径是零分块的 markdown:
   它跳过 embedding 调用、直接走到写清单那一步读 model_name。

验证:
- 红绿:回退源码后 16 个新用例中 13 个变红,3 个「不误伤」的反向守卫保持绿。
- uv run pytest -q → 2519 passed(此前 2503)。
- ruff format --check / ruff check / bandit -ll / mypy / compileall /
  uv lock --check / git diff --check 全部通过。
- uv run main.py --smoke-test → PyRAG-Kit 1.4.0 smoke test ok;
  CHAT_TOP_K=0 仍得到脱敏后的 ValueError。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
第四轮对抗审查。CompletionRequest 是普通 dataclass,__post_init__ 只校验
extra_headers/extra_query/extra_body;声明为 str | None 与 Sequence[str] | None
的 user/betas 不受约束,而两者都进 HTTP 请求(分别转成 user_profile_id 头与
anthropic-beta 头)。走真实 SDK(httpx2.MockTransport 拦网)实测修复前:

- invoke(user=5) / beta_create(user=5) 抛
  AttributeError: 'int' object has no attribute 'encode'(['a'] 同理,b"x"
  则抛 'bytes' object has no attribute 'encode')。AttributeError 不落本渠道
  的 except ValueError / except RuntimeError 分类。七个入口实测全部如此。
- beta_create(betas=5) 抛裸 TypeError: 'int' object is not iterable。
- beta_create(betas=[1, 2]) 发出 anthropic-beta: 1,2;betas=b"x" 发出 120
  (list(b"x") 得 [120]);betas={"a": 1} 静默取键发出 a;betas="s" 被
  list() 拆成 ['s']。服务端收到不存在的 beta 名。

新增 _require_profile_id(只接受 str | None)与 _require_beta_tokens(显式拒绝
str/bytes/Mapping 三类「可迭代但不是字符串列表」的形态——list() 对它们不报错、
只会静默给出错误结果——并逐个校验元素类型)。

betas 的校验放在 _build_message_params 的 beta_values 构造处,而非只放在
_user_profile_fields 里:后者仅在 user is not None 时被调用,而 betas 走
beta_values 直接进 params。只加在前者时 beta_create(betas=5) 仍抛裸 TypeError,
回归用例 test_anthropic_betas_validated_without_user 专门盯这条路径。

验证:
- 红绿:回退源码后 14 个新用例中 12 个变红,2 个「不误伤」反向守卫保持绿。
- uv run pytest -q → 2533 passed(此前 2519)。
- ruff format --check / ruff check / bandit -ll / mypy / compileall /
  uv lock --check / git diff --check 全部通过。
- uv run main.py --smoke-test → PyRAG-Kit 1.4.0 smoke test ok。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
第五轮对抗审查。四组缺陷同一类:判据只覆盖「可迭代但元素非法」,漏了
「不可迭代」那一半,或把形态判据放在读取形态的代码之后。

1. 四个 provider 的 _convert_tools 都写 `for index, tool in enumerate(tools)`,
   而同函数对 "s"/{"a": 1}/b"x" 这类可迭代但元素非法的形态早已给带序号的
   ValueError——只有标量漏了。实测 google.invoke(tools=5) 与
   openai.complete(tools=5)(responses 协议)抛
   TypeError: 'int' object is not iterable。

2. extract_system_prompt 是 12 处调用的共享 helper,且在多数 provider 里
   先于 normalize_messages(后者有完整判据)被调用。实测 messages=5 抛裸
   TypeError、messages="s" 抛裸 AttributeError: 'str' object has no attribute
   'get'(字符串是合法 Sequence,只判可迭代拦不住)。判据现与
   normalize_messages 逐字对齐;条目非 Mapping 给带下标的 ValueError 而非静默
   跳过——跳过会让写错的消息悄悄消失、system 提示随之丢失。

3. jina 与 siliconflow_rerank 的同步/异步四个入口把 len(documents) 写在
   _validate_inputs 之前。实测 documents=5 抛裸
   TypeError: object of type 'int' has no len(),而紧随其后的校验对同一取值
   给的是带指引的 ValueError。校验现提到日志之前。

4. TextEmbeddingModel 的六个实现没有一处判 texts 的形态。texts=5 在四处抛裸
   TypeError、在 local_hash 抛 'int' object is not iterable;texts="abc" 更
   隐蔽——字符串是合法 Sequence,被静默当成三条单字符文本向量化(local_hash
   实测返回 3 条),调用方以为传了一条文本、实际入库三条垃圾向量。新增
   require_text_list 并接入 local_hash/google/openai_compatible/volcengine 的
   同步与异步共 8 个入口。空列表合法(语义是「没有文档要向量化」),与 rerank
   的「必须非空」有意不同。

验证:
- 红绿:回退源码后 9 个新用例中 8 个变红,1 个「不误伤」反向守卫保持绿。
- uv run pytest -q → 2558 passed(此前 2533)。
- ruff format --check / ruff check / bandit -ll / mypy / compileall /
  uv lock --check / git diff --check 全部通过。
- uv run main.py --smoke-test → PyRAG-Kit 1.4.0 smoke test ok;
  CHAT_TOP_K=0 仍得到脱敏后的 ValueError。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
第六轮对抗审查。Pipeline.process → Cleaner.clean → Splitter.split 是建库主路径
上相邻的三步(KnowledgeBuildService._process_file 对知识库里的每个文件各调
一次),三处此前都不判入参形态:

- Pipeline.process(5) → 裸 AttributeError: 'int' object has no attribute
  'metadata'(方法第一行就读 document.metadata)。"abc"/[5]/None 同理。
  process 是 _process_file 的直接下游,异常落在 KnowledgeBuildService.build
  按 ValueError 建立的分类之外。
- BasicCleaner.clean(5) → 裸 TypeError: 'int' object is not iterable;
  clean("abc")/clean([5])/clean([None]) → 裸
  AttributeError: 'str' object has no attribute 'content'。
- RecursiveTextSplitter.split(5) → 裸 TypeError: object of type 'int' has no
  len()(日志行里的 len(documents));split("abc")/split([5]) → 裸
  AttributeError: 'str' object has no attribute 'metadata'。
  split_hierarchical 是公开方法且同样暴露:split_hierarchical(5) → 裸
  TypeError: 'int' object is not iterable。

三处的 "abc" 尤其要紧:字符串是合法的 Sequence,只判「可迭代」拦不住,会被
当成一串字符逐字处理。现三处共用同一判据(容器必须是 Sequence 且显式排除
str/bytes/bytearray,条目必须是 Document 实例并带下标)。分片器把判据抽成
_require_documents 供 split 与 split_hierarchical 共用——只守卫 split 会留下
「直接调 split_hierarchical」的口子。条目判据用 Document 实例而非鸭子类型:
三个阶段后续都直接读它的 content/metadata,而 Document 是 pydantic 模型
(构造期已校验 content: str),要求实例化能让错误定位到构造处。

验证:
- 红绿:回退源码后 22 个新用例中 19 个变红,3 个「不误伤」反向守卫保持绿。
- uv run pytest -q → 2578 passed(此前 2558)。
- ruff format --check / ruff check / bandit -ll / mypy / compileall /
  uv lock --check / git diff --check 全部通过。
- uv run main.py --smoke-test → PyRAG-Kit 1.4.0 smoke test ok。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`SessionConfig.set_many` 与 `Chatbot.chat_config` setter 的标注是映射,
但运行时不受约束,非映射取值一路走到 `.items()` / `set_many` 的迭代才炸。

实测(修复前):
- `cfg.set_many(5)` / `set_many(None)` → 裸 `TypeError: 'int' object is not
  iterable`,穿透调用方按 `ValueError` 做的错误分类。
- `cfg.set_many("abc")` → 字符串是合法的可迭代对象,被拆成一串单字符字段名,
  抛 `KeyError: "SessionConfig 没有字段 'a'。"`;`bot.chat_config = "abc"`
  同理。同一个「配置写错」的收场不该取决于写错的是标量还是映射。

两处都在任何写入之前判形态(`str` / `bytes` / `bytearray` 显式排除),
`chat_config` setter 的既有原子性契约(`deepcopy` 副本上写入)保持不变。

回归用例 14 个(tests/test_boundary_hardening_regressions.py),回退源码后
其中 10 个变红,4 个「不误伤」反向守卫保持绿。

顺带更新 tests/test_display_utils.py 的 `_UNTRUSTED_FREE_ARGUMENTS` 行号:
`src/chat/core.py` 顶部新增导入与守卫使 `_display_retrieved_docs` 的两处
rich 调用下移 8 行,豁免表按行号登记,需同步。

验证:`uv run pytest -q` 2592 passed;ruff format/check、bandit -ll、
mypy、compileall、uv lock --check、git diff --check 全通过;
`uv run main.py --smoke-test` ok,`CHAT_TOP_K=0` 仍为脱敏 ValueError。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`retrieval_method` 声明为 `RetrievalMethod`,但 `SessionConfig` 是公开类、普通
属性没有 `__setattr__` 守卫,嵌入方(或携带异常取值的 pickle 快照)可以直写成
任何形态。三个取用点此前各自假设它是枚举成员。

实测(修复前):
- `RetrievalService.retrieve` 用 `==` 判分支,而它是该函数里**唯一没有形态
  收敛**的字段(同函数的 top_k/候选倍率/score_threshold/rerank_enabled/
  hybrid_fusion_strategy 都有 `_coerce_*` 助手)。`"hybrid"`/`None`/`5`/
  `"语义"` 全部静默落进 else 分支,**退化成纯关键词检索**(记录调用可见只走了
  keyword_search)。用户看到的是「混合检索配好了但只召回关键词命中」,而不是
  一条配置错误。`"hybrid"` 是 `"混合检索"` 的英文写法,最容易踩。
- `display_chat_config` 读 `.value`,而它在 `start_chat_session_async` 里于任何
  提示符**之前**被调用:直写 `5`/`None` 抛裸 `AttributeError: 'int' object has
  no attribute 'value'`,用户刚看到「知识快照加载成功」会话就终止。
- `config_menu.edit_retrieval_params` 读同一个 `.value`,菜单渲染不出来。

现新增共享助手 `require_retrieval_method`(src/utils/config.py,RetrievalMethod
的归属地),三个取用点与 `SessionConfig._validate_retrieval_method` 共用它——
配置边界与运行期读取侧从此只有一处判据、一种消息。判据按**属性存在性**而不是
具体类型,与同文件 `require_detail_attributes` 同一约定(仓库既有测试用
`SimpleNamespace(value="混合检索")` 作替身;按类型收窄会把这 4 个既有用例拒掉,
实测如此)。接受枚举成员与它的字符串取值;不做大小写/别名归一。

回归用例 19 个(tests/test_boundary_hardening_regressions.py),回退源码后其中
12 个变红,7 个「不误伤」反向守卫保持绿。其中一条专门断言「被拒前未发起任何
一路检索」——只断言抛了 ValueError 会让「先检索再校验」的实现也通过。

验证:`uv run pytest -q` 2612 passed;ruff format/check、bandit -ll、mypy、
compileall、uv lock --check、git diff --check 全通过;
`uv run main.py --smoke-test` ok,`CHAT_TOP_K=0` 仍为脱敏 ValueError。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`chat_temperature` 有完整的校验器(`Settings.validate_chat_temperature` 与
`SessionConfig._validate_chat_temperature` 各一份,判据一致),但两个**读取点**
都直读原属性,而 `SessionConfig` 是公开类、普通属性没有 `__setattr__` 守卫。

实测(修复前):
- `ChatService.generate_reply` 把 `session_config.chat_temperature` 直接放进
  `CompletionRequest`,再原样成为 SDK 的 kwargs。`"abc"`/`None`/`[0.5]`/
  `True`/`-1` 全部**静默通过**——而它们正是 `session_config["chat_temperature"]
  = ...` 会明确拒绝的取值。同一个字段「写得进就合法、直写就非法」两种口径,
  非法值要到服务端才暴露。
- `Chatbot.chat_async` 的兜底分支(chat_service 缺失时)读同一个值,同一后果。

新增共享助手 `require_chat_temperature`(src/utils/config.py),两个读取点与
`SessionConfig._validate_chat_temperature` 共用它——与上一轮 `require_retrieval_
method` 同一模式:配置边界与运行期读取侧只有一处判据、一种消息。

同时修正 `_should_apply_score_threshold` 兜底分支的判据方向:它的 docstring 写着
「这里再读一次原属性会绕过那道校验」,但**兜底分支恰恰就在读原属性**。判据是
`strategy == "rrf"`,而 `"RRF"`/`" rrf "`/`"Rrf"` 走的是 RRF 融合却不等于这个
字面量——阈值过滤被错误地套用在排名倒数的量纲上(结果静默清空),正是
`_coerce_fusion_strategy` 记录过的缺陷,只是留在了库调用方这条路径上。同一处把
`retrieval_method` 也改为经 `require_retrieval_method` 收敛。

回归用例 18 个(tests/test_boundary_hardening_regressions.py),回退源码后其中
11 个变红,7 个「不误伤」反向守卫保持绿。两条专门断言「被拒前未发起请求」——
只断言抛了 ValueError 会让「先发请求再校验」的实现也通过。

顺带更新 tests/test_display_utils.py 的 `_UNTRUSTED_FREE_ARGUMENTS` 行号:
`src/chat/core.py` 顶部新增导入使 `_display_retrieved_docs` 的两处 rich 调用
下移 4 行,豁免表按行号登记,需同步。

验证:`uv run pytest -q` 2630 passed;ruff format/check、bandit -ll、mypy、
compileall、uv lock --check、git diff --check 全通过;
`uv run main.py --smoke-test` ok。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`ModelDetail.provider` 声明为 `str`,但 `SessionConfig.llm_configurations`/
`rerank_configurations` 的 `_VALIDATORS` 明确不登记(「按原样写入,由使用方负责
其内部结构」),嵌入方可以往映射里放任意取值。`display_chat_config` 在
`start_chat_session_async` 里于任何提示符**之前**被调用。

实测(修复前):
- `provider=["openai"]` -> 在 `active_providers = {…provider}` 这个**集合**里抛
  `TypeError: unhashable type: 'list'`;`provider=None`/`5` 在
  `provider.lower()` 处抛 `AttributeError`。两处判据(集合成员与 .lower())都要
  收敛。用户刚看到「知识快照加载成功」会话即终止。
- `model_name`/`active_llm_configuration`/`active_rerank_configuration` 是位数
  超限整数时抛 `ValueError: Exceeds the limit`。而**同一个函数**的 top_k/
  score_threshold/权重早已走 `_render_value`,注释还明确写着「本函数在任何提示符
  之前被调用,会话在启动横幅这一步就终止」——判据只做了一部分单元格。
- 五处 `logger.debug(f"…{取值}")` 同样:f-string 在 DEBUG 关闭时也**已经求值**,
  裸插值对同一取值照样抛。

现抽出共享助手 `require_provider_name`(src/utils/config.py),
`RetrievalService._get_rerank_provider` 里原本内联的同一判据改为复用它;四处裸插值
统一经 `_render_value`;五处 logger 改为惰性 `%s`。判据只要求「是字符串」,不查
`_provider_map` 白名单——白名单是工厂的职责,在展示层提前套它会让「配置里有个
尚未支持的 provider」从「启动时列出配置」变成「启动时崩溃」。

回归用例 15 个(tests/test_boundary_hardening_regressions.py),回退源码后其中
9 个变红,6 个「不误伤」反向守卫保持绿。

验证:`uv run pytest -q` 2640 passed;ruff format/check、bandit -ll、mypy、
compileall、uv lock --check、git diff --check 全通过;
`uv run main.py --smoke-test` ok。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`config_menu._top_k_ceiling` 与 `_multiplier_ceiling` 读取另一个可直写的规模字段
来算动态上界。修复前实测:
- `retrieval_candidate_multiplier` 被直写成 `"abc"`,用户在菜单里选择「调整
  Top K」时,`max()` 抛裸 `TypeError: '>' not supported between instances of
  'str' and 'int'`;
- `top_k` 被直写成 `None`,用户选择「调整候选倍率」时对称复现。

配置编辑器在聊天主循环的 `try` 之外,内层只接 `KeyboardInterrupt`/`EOFError`,
裸 TypeError 会以 traceback 终止整个会话。`retrieve` 对同一字段早有收敛判据,
现将它上移为 `require_scale_field`(src/utils/config.py),服务层与 UI 共用;
UI 不反向依赖服务层私有函数。非正整数继续按既有读取语义收敛为 1,`bool`/非整数/
超上界取值给出带字段名的 `ValueError`。

回归用例 16 个(tests/test_boundary_hardening_regressions.py),回退源码后其中
11 个变红,5 个「不误伤」反向守卫保持绿。

验证:`uv run pytest -q` 2656 passed;ruff format/check、bandit -ll、mypy、
compileall、uv lock --check、git diff --check 全通过;
`uv run main.py --smoke-test` ok。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`markup_text` 对**值**参数早已用 `_render_value` 完整收敛(位数超限的整数降级成
量级描述),对占位符数量不符与 NUL 字符也都抛 `ValueError`;唯独**模板**参数没有
形态判据,而函数体第一行就对它做成员测试。

实测(修复前)非字符串模板抛裸 `TypeError`,消息随形态各不相同:
- `5`/`None` -> `argument of type 'int' is not iterable`
- `list`/`tuple`/`dict` -> `expected string or bytes-like object`
- `bytes` -> `a bytes-like object is required, not 'str'`

三种消息都不指向「模板写错了」,也不是 `ValueError`——同一个「入参形态非法」的
处置,同一个函数里两种收场。现统一收敛成带取值的 `ValueError`。

可达性:44 个调用点里 43 个模板是字符串字面量,剩下 1 个
(scripts/embed_knowledge_base.py 的 `mask_api_key(api_key_value)`)两个分支都
返回字面量,因此本仓自带流程走不到——修的是**公开函数**的契约一致性。

回归用例 12 个(tests/test_boundary_hardening_regressions.py),回退源码后其中
7 个变红,5 个「不误伤」反向守卫保持绿。

验证:`uv run pytest -q` 2668 passed;ruff format/check、bandit -ll、mypy、
compileall、uv lock --check、git diff --check 全通过;
`uv run main.py --smoke-test` ok,`CHAT_TOP_K=0` 仍为脱敏 ValueError。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
两个 provider 的响应容器此前只写 `field(x, "candidates", []) or []` /
`field(x, "output", []) or []`——那只挡住 None 与空值,挡不住其它形态。实测走
**真实入口**(GoogleProvider.complete / VolcengineProvider.complete):

- Google `candidates=5` -> 裸 `TypeError: 'int' object is not iterable`;
  `candidates={"0": {}}` -> 更晚一步,在 `candidates[0]` 处抛裸 `KeyError: 0`。
  本文件有 6 处直接迭代 candidates(_response_text、_extract_refusal、
  _extract_result 两处、_stream_events_from_chunk、
  _stream_chunk_has_terminal_event),现全部经新增的 _require_candidates 收敛。
- Ark Responses `output=5` -> 裸 `TypeError`;Ark Chat `message.tool_calls=5` ->
  同一个裸 `TypeError`。现经新增的 _require_ark_list 收敛(6 处调用点)。

这些异常都不是 RuntimeError——而两个 provider 的错误分类(以及调用方的 except)
按 RuntimeError 写。同一个形态在 openai_compatible._raise_for_chat_response_error
对 choices 早已有显式守卫,三个 provider 的收场必须一致。candidates / output
缺失(None)的既有语义保持不变:流式路径的正常中间态依赖它。

回归用例 18 个(tests/test_boundary_hardening_regressions.py),回退源码后其中
7 个变红,11 个「不误伤」反向守卫保持绿。用例全部走真实入口而不是直接调私有
解析函数——「守卫加在哪个函数上」与「真实路径是否经过它」是两回事。

同时修正 tests/providers/test_google_genai.py 的一处夹具:它用未指定 candidates
的裸 MagicMock,而真 SDK 的字段类型是 list[Candidate] | None。裸 MagicMock 的
属性是一个可迭代的空 mock,旧代码只做 `or []` 与迭代,这种不真实形态恰好能通过;
形态守卫会把它拒掉。夹具已写成真实形状。

验证:`uv run pytest -q` 2678 passed;ruff format/check、bandit -ll、mypy、
compileall、uv lock --check、git diff --check 全通过;
`uv run main.py --smoke-test` ok。

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`OpenAICompatibleProvider` 有 8 处直接迭代响应容器(`output`、
`message.tool_calls`、流式 `delta.tool_calls`),此前每处都只写
`field(x, ..., []) or []`——那只挡住 None 与空值。实测走真实入口:

- Chat 路径 `message.tool_calls=5` -> 裸 `TypeError: 'int' object is not
  iterable`;
- Responses 路径 `output=5` -> 同一个裸 `TypeError`。

两者都不是 RuntimeError,而调用方的错误分类按它写。现新增
`_require_response_list`,判据与同文件 `_raise_for_chat_response_error` 对
`choices` 的既有守卫一致,8 处全部接入。影响 qwen/siliconflow/ollama/
lm-studio/deepseek/grok 等 7 个薄子类共用的路径。

同时厘清一个此前被我说错的事实:同一类守卫在三个 SDK 上**可达性不同**,
因为响应构造方式不同——

  google-genai            _from_response -> cls.model_validate(response)   校验
  openai                  _process_response_data -> construct_type         不校验
                          (_strict_response_validation 实测默认 False)
  volcenginesdkarkruntime 同样只有 construct_type                          不校验

因此上一轮(1f513b5)的 Google 守卫只在测试替身下可达——那批用例打桩了
`_get_client`,恰好绕过了校验所在的那一层,而原文把 Google 与 Ark 一并称为
「实测走真实入口」是不准确的。Ark 的那批线上可达。Google 守卫本身保留
(契约一致性有价值,且它暴露了一个不真实的既有夹具),但定位已在代码注释、
测试 docstring 与 CHANGELOG 里改正;另加两条前提断言把这个 SDK 差异固定在
测试里,某个 SDK 版本改了构造方式就会变红。

回归用例 8 个,回退源码后其中 5 个变红(另 3 个是 SDK 前提断言与「不误伤」
守卫,回退后仍为绿——这正是前提断言的作用)。

验证:`uv run pytest -q` 2686 passed;ruff format/check、bandit -ll、mypy、
compileall、git diff --check 全通过。

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`SessionConfig._validate_rerank_enabled` 只认 `bool`(`tests/runtime/
test_contracts.py` 里就有 `("rerank_enabled", "yes")` 这一行),但那只管
**写入**。`SessionConfig` 是公开类、普通属性没有 `__setattr__` 守卫,直写
`"false"`/`"no"`/`0` 之后,UI 的 5 个读取点走真值判断,与服务层的
`_coerce_rerank_enabled` 分成两套判据。实测直写 `"false"`:

  * `display_chat_config` 的配置表显示 **「启用」** 并渲染出激活的 Rerank
    模型(`chat_table` 状态格、Rerank 模型段落、API Key 段落三处分支),
  * 菜单显示「当前: 启用」,且 `questionary.confirm` 在用户直接回车时把
    `default` **原样**返回(`prompt_toolkit` 的 `set_answer` 分支),于是回车
    得到字符串 `"false"` 并写回配置——界面显示「是」,写进去的是字符串;

而 `RetrievalService.retrieve` 对同一个值直接抛
`ValueError: rerank_enabled 必须是布尔值`。把 rerank 写成 `"false"` 的人看到
的是「已启用」,且界面不提示任何错误——与配置意图相反。

现新增共享助手 `require_rerank_enabled`(`src/utils/config.py`),与
`_validate_rerank_enabled`/`_coerce_rerank_enabled` 共用同一判据(只认
`bool`,**不**接受 `0`/`1`/`"yes"`——真值语义正是这条缺陷的根因)。服务层
的 `_coerce_rerank_enabled` 改为复用它,两处不再各写一份消息。

两处读取点的处置**有意不同**:`display_chat_config` 与同一张表的
`retrieval_method` 一致,直接拒绝(它本就在启动横幅处对非法取值报错);
`config_menu` 则降级——菜单由 `launch_config_editor` 经 `asyncio.to_thread`
调用,调用点只接 `(KeyboardInterrupt, EOFError)`,抛 `ValueError` 会终止整个
会话,而菜单是用户唯一还能改配置的入口(`display_chat_config` 在上游已经
拒绝)。因此菜单里非法取值显示「未知」、`confirm` 的默认项传 `None`
(questionary 的合法默认,不预选),用户仍能进菜单把它改回布尔。

回归用例 10 个,回退源码后其中 9 个变红(另 1 个是「不误伤」反向守卫,
回退后仍为绿——真布尔取值修前修后表现一致,这正是它的作用)。

验证:`uv run pytest -q` 2696 passed;`main.py --smoke-test` 通过;
ruff format/check、bandit -ll、mypy、compileall、git diff --check 全通过。

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`RetrievalService.retrieve` 开头就把 `session_config.top_k` 收敛进局部变量
(`require_scale_field`),但两处下游**重新读原属性**:

  * `retrieve` 出口的切片 `ranked_results[: max(1, session_config.top_k)]`;
  * `_rerank_if_needed` 的 `max(1, min(session_config.top_k, ...))`——该方法的
    docstring 明确写着「供库调用方直接调本方法」,而库调用方**不经过**
    `retrieve` 开头那道收敛。

该字段可被直写(`SessionConfig` 是公开类、普通属性没有 `__setattr__` 守卫)。
静态非法值会被开头那道收敛拦下,但 `retrieve` 是 async、中间有多处 `await`
——**检索飞行中**改写属性就绕过了它。实测两条路径都抛裸
`TypeError: '>' not supported between instances of 'str' and 'int'`
(库调用方路径是 `'<'`),而调用方的错误分类按 `ValueError` 写。

修法与同函数里 `rerank_enabled` 的既有处置一致:出口切片改用**已收敛的局部
变量**;`_rerank_if_needed` 新增 `top_k` 形参,由 `retrieve` 传入收敛后的值,
`None` 时自行经 `require_scale_field` 收敛(供库调用方直接调用的场景)。两处
都只保留一份判据。

回归用例 5 个,回退源码后全部变红(报错正是 retrieval_service.py:628 的裸
TypeError)。

验证:`uv run pytest -q` 2701 passed;`main.py --smoke-test` 通过;
ruff format/check、bandit -ll、mypy、compileall、git diff --check 全通过。

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
`display_utils._require_model_detail` 的 docstring 明确以「同一个 key 走
`ModelProviderFactory._get_model_detail` 得到 `ValueError`,错误分类本就该
一致」为理由建立契约,但工厂侧有一条它没有的守卫——**成员判断前先收敛键
形态**。`key not in mapping` 对**不可哈希**的取值抛裸
`TypeError: unhashable type: 'list'`,而本函数在 `start_chat_session_async`
里于任何提示符**之前**被调用:实测直写
`active_rerank_configuration = ["siliconflow"]` 时,用户刚看完启动横幅就收到
裸 traceback,会话随即终止。可达路径与其它 `active_*` 字段同源(`Settings`
与 `SessionConfig` 都没有为它们登记 validator,普通属性也没有 `__setattr__`
守卫)。

守卫**只收不可哈希这一类,不要求 key 是字符串**。初版写成「必须是字符串」,
被既有用例 `test_display_chat_config_renders_values_beyond_the_conversion_limit`
挡下:该用例明确要求位数超限的**整数键**可渲染(可哈希的非字符串键在映射里
能正常取到值,那是显示层的既有契约)。工厂侧要求字符串是因为它要拿 key 去建
provider,两处的接受面本就不同;一致的是**异常分类**。判据改为对成员判断接住
`TypeError` 并转成 `ValueError`,接受面不变。

回归用例 12 个,回退源码后其中 4 个变红(报错正是 display_utils.py:296 的裸
TypeError);另 8 个是「不误伤」反向守卫,回退后仍为绿——可哈希的缺失键
(`"missing"`/`5`/`None`/超大整数)仍是原来的「未找到key」消息、位数超限的
整数键仍能取到值、既有的渲染用例保持绿。

验证:`uv run pytest -q` 2710 passed;`main.py --smoke-test` 通过;
ruff format/check、bandit -ll、mypy、compileall、git diff --check 全通过。

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
MisonL and others added 19 commits October 11, 2026 21:11
同一缺陷类的第三处,也是危害最直接的一处。aretrieve_documents 在 finally
里裸 await retrieval_service.aclose(),两种收场都丢信息:

- 检索成功、关闭失败:那批命中结果被丢弃,调用方只看到与检索无关的关闭
  异常(实测返回 1 条命中的检索整体抛 RuntimeError: 关闭失败);
- 检索已抛异常、关闭也失败:关闭异常替换掉原始异常,真正的失败原因从栈上
  消失。

改为与仓库内既有的两处退出清理同一契约(retrieval_test.core._aclose_quietly
与 Chatbot.close):失败只记已脱敏的警告,不打断主流程。

新增 3 条回归断言(结果保留、原始异常保留、日志脱敏);把 finally 还原成
裸 await 后三条全部变红。全量 4390 passed;8 项质量门 + 审计 selftest 全绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
iterate_async / retry_sync_stream / retry_async_stream 三处的 finally 都
负责关闭迭代器,而 finally 里抛出的异常会替换掉 try 中正在上抛的那个。
这里比同批其它缺陷更严重:调用方按异常类型分类,retry_async_stream 用
tenacity 的 is_retryable_error(类名判据含 connection)决定要不要重试。

实测一个 ConnectionError(可重试)的流在 aclose() 也失败时被 ValueError
(不可重试)顶替,一次可重试的连接错误只尝试了 1 次就放弃(期望 3 次)。

三处统一为「关闭是尽力而为的清理」:finally 第一条语句取 sys.exc_info()
记录是否有在飞异常,关闭失败时若有则保留原始异常、若无才把关闭异常报出去。
判定必须早于内层 except——进到 except 之后取返回的就是关闭异常本身。

新增 3 条回归断言(含重试次数保持 3 次);把三处 finally 还原后全部变红。
green39 夹具随新增 import sys 偏移 +1 行,已按上下文重新定位两个位点。
全量 4393 passed;8 项质量门 + 审计 selftest 全绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Document.metadata 声明为 dict[str, Any],取值不受控,而 Pipeline.process
的五行日志与分片器三个分支都把 source 直接插进 f-string/%s。实测
Document(content=..., metadata={"source": 10**4301}) 构造成功(pydantic 不
校验 metadata 内部)后,process / split / split_hierarchical 全部抛
ValueError: Exceeds the limit (4300 digits)——一条 info/debug 日志让建库
主路径失败,而 KnowledgeBuildService 的错误分类只按 ValueError 处理,
用户看到的是「建库失败:整数转换超限」,与真实原因毫无关系。

仓库已有专为此设的助手(ui.display_utils.document_source),但 ETL 不能
反向依赖 ui 层,故按同一判据在 ETL 侧取最小实现 _safe_source:字符串原样
返回(正常路径日志逐字不变,已用 caplog 钉死),其余形态走 safe_repr。

新增 4 条回归断言(参数化共 11 实例,含正常路径逐字不变);把两处守卫
还原成裸取值后两条变红。附带记一个测试写法坑:parametrize 默认对取值调
str() 生成用例 id,把 10**4301 直接放进参数列表会在收集期就抛同一条
Exceeds the limit,须显式给 ids=。

全量 4400 passed;8 项质量门 + 审计 selftest 全绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Document.__str__ 直接 f-string 插值 self.metadata(dict[str, Any],取值不
受控),一个位数超过 sys.get_int_max_str_digits()(默认 4300)的整数就让
整条 f-string 抛 ValueError: Exceeds the limit。__repr__ 是 pytest 失败
报告、日志与调试器的兜底渲染,它一抛,报错现场反而什么都看不到——实测
pytest 把失败对象显示成 <[ValueError('Exceeds the limit (4300 digits)...')
raised in repr()] Document object at 0x...>。

修法与仓库既有的 _render_value/_path_text 同一取舍:只在渲染真的失败时才
降级到 safe_repr。不无条件套用 safe_repr,因为它在 80 字符处截断,而真实
分片 metadata 的 repr 长约 127 字符,无条件套用会改变所有正常文档的调试
输出。

新增 3 条回归断言(参数化共 6 实例,含正常路径逐字不变);把守卫还原成
裸插值后两条变红。全量 4406 passed;8 项质量门 + 审计 selftest 全绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
71eb29c 修过同文件 _process_file 的读取失败(UnicodeDecodeError/OSError 都
收敛成带文件名的 ValueError),但 *.md 清单还有第二处读取点没跟上:
_compute_source_digest 在 build 里于全部 embedding 完成之后再逐个 read_bytes
同一批文件。

实测两种真实形态都抛裸 OSError——glob 之后文件被删(TOCTOU)抛
FileNotFoundError、chmod 000 后抛 PermissionError——此时最贵的一步
(embedding)已经跑完,失败收场却是一条没有定位、没有「重试什么」指引的
系统错误,不是 build 其余拒绝路径承诺的带指引 ValueError。

按文件逐个收敛(而不是整体 try),消息与 _process_file 的两条 except
逐字对齐;正常路径的 digest 值逐字不变。

新增 4 条回归断言(含一条真实时序用例:embed_in_batches 期间删文件,断言
失败消息仍带文件名且临时快照被清理);把守卫还原成裸 read_bytes 后其中
3 条变红。全量 4410 passed;8 项质量门 + 审计 selftest 全绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
补齐最后一个未覆盖的变异面:`+`↔`-`、`*`↔`/`。此前各轮扫过否定(drop)、
成员/身份/等值/方向比较(notin/isnot/eqneq/cmpdir)与布尔字面量(boolc),
算术二元运算从未纳入。

要点与取舍:

- 只收**数值**二元运算。任一操作数是字符串字面量的 `BinOp`(`"a" + "b"` 这类
  拼接)不进位点——其变异体在运行期抛 `TypeError` 而非改变计算,属噪声。
- 站点键取**左操作数的结尾**。链式 `a + b + c` 会被解析成两层 `BinOp`,两层
  起点的 `col_offset` 都指向第一个 `a`;用起点会让 `find_site` 命中两个节点而
  判 MUTFAIL、把链式位点整片漏掉。左操作数各自的结尾互不相同,是唯一键。
- 加减互换、乘除互换各归一组;`%`、`//`、`**` 不在面内(语义相邻变异是另一组
  方向,且现存位点极少,收益不抵扫描成本)。

新增 4 条单元断言(加减/乘除互换、链式键唯一性、字符串拼接排除、链式只翻
第一个运算符)。selftest 与 `tests/scripts/` 全绿(26 passed)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
新增 `arith` 变异面(`+`↔`-`、`*`↔`/`)对 `src/` 105 个位点全量扫描:
KILLED=59、SURVIVED=45、TIMEOUT=1(主机 load 峰值 101 导致,非判定;单独
复跑为 SURVIVED)。45 个存活点用真实入口探针分诊为 5 组真缺口(9 个位点)
与 37 个等值变异。补测试而非改源码——守卫本身正确,缺口在测试只覆盖了能
「蒙对」的输入。

真缺口(逐条红验证:注入变异后新测试红、还原后绿):

- `google.py` 2204/2216:`_partial_args_to_mapping` 的中间容器类型由下一段
  token 决定;既有用例的 json_path 全是单段,`tokens[position + 1]` 从未被读。
- `retrieval_service.py` 187:`_weight_tuple` 的 `/ total`;既有断言的 total
  恒为 1.0,`/1.0` 与 `*1.0` 恒等。
- `retrieval_service.py` 217:`_rrf_rerank` 的 `rrf_k + rank`(两路各一位点);
  既有用例只断言排序,而 RRF 分值对 `k±rank` 顺序不变。
- `retrieval_test/core.py` 68 与 `excel_logger.py` 321:排名 `index + 1` /
  `i + 1`;既有用例从未断言排名列。
- `ui/config_menu.py` 238:权重补数 `1.0 - vector`;既有 weights 用例一律提交
  空输入(不写回)。

等值变异 37 个:22 个是 `perf_counter() - start` 耗时日志,其余为日志计数、
`safe_repr` 位数**量级估算**、`to_bytes` 字节数、URL query 切片起点、日志
`maxBytes` 常量——均不改变任何判定。

全量 4421 passed(较前 +11);8 项质量门 + 审计 selftest 全绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
在新 HEAD 上重跑 green39 夹具,预期 33/5/1 却报 7 条不一致。逐条用
「变异 → --deselect 自指元测试 → 全量套件」取真判定后,7 条分三类:

1. 验证器自指假报(4 条,修 verify_sites.py):
   `test_green39_fixture_sites_resolve_at_head` 读**工作区源码 AST** 校验
   夹具坐标;扫描期间工作区处于变异态,变异点落进它引用的节点时它必红——
   夹具里 4 个 SURVIVED(anthropic.py:972、factory.py:356、model_provider.py:
   1202/336)因此全部假报 KILLED;剔除后全量 4414 passed(7 deselected)即
   真存活。新增 `_SOURCE_INSPECTING_TESTS`(6 个读源码文本的测试节点,逐个
   用「只剩它红」实测),run_pytest 统一 --deselect;它们只在未变异基线上跑,
   普通 pytest 与 --selftest 不受影响。新增测试核对该列表存在且节点真实
   (--deselect 对不存在的 node id 静默通过,写错名字不报错)。

2. 夹具自身过期(2 条):
   - openai_compatible.py:1391 记 MUTFAIL(「守卫已被修复移除」的活样本),
     但坐标在 HEAD 上能精确解析且变异被杀死——活样本已过期。
   - google.py:571 记 SURVIVED,实测被 f5a5366 引入的
     test_google_validate_http_options_keeps_blank_query_values 杀死——
     真 KILLED,上次重基线漏了 drop 面复核。

3. killer 选择器失效(1 条,防呆命中):
   openai_compatible.py:1153 记的 test_openai_chat_request_forwards_metadata
   在定点模式下不杀它(isnot 落在 top_k is not None 守卫)。换用实测能杀的
   test_chat_request_forwards_extra_headers(裸名,避 parametrize id 漂移),
   夹具注释写明「换 killer 必须定点重验」。

夹具期望更新为 35 KILLED / 4 SURVIVED,断言同步。修复后重跑夹具:
39 个位点全部与 expected 一致、退出码 0、无「需要处理」。全量 4422 passed;
8 项质量门 + 审计 selftest 全绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
上一轮修好「自指元测试污染判定」后留下一个盲区:_SOURCE_INSPECTING_TESTS
只是人工清单,守卫只验证「清单条目存在」,不验证「清单完整」。--deselect 对
漏登记/写错的名字静默通过——新增一个读 src/ 源码的测试不会自己进清单,扫描
会再次把它算进判定,假 KILLED 静默复现。

新增静态探测器 detect_source_inspecting_tests():锚点(字面量 src/scripts/
main.py,或模块级 __file__ 派生的全局)+ 读取动作(read_text/ast.parse/glob/
import_module…)同时成立才判定,先剥离各作用域首条字符串避免文档误触发。
新增守卫断言「探测器发现的一切 ⊆ 清单」,断言前先自证探测器非空(否则空集上
恒真、用例形同虚设)。清单 7→9 条。

红验证:临时植入未登记的读源码测试→守卫变红并点名该 node,删除后转绿、
git status 干净;探测器单元用例以「去掉锚点条件」注入验证为真 killer。
--selftest 增加同款核对与一组正反例。全量 4424 passed,8 项质量门全绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
下一个变异面:BoolOp 首运算符 and↔or。src/ 实测 735 个位点,是继
eqneq(358) 与 arith(105) 之后最大的未扫面。

站点键取第一个值的结尾(同 arith 用左操作数结尾的理由):链式
a and b or c 的两层 BoolOp 起点 col_offset 都指向 a,用起点会碰撞而
整片漏扫;实测 735 位点该键零碰撞,未来碰撞也会因 find_site 命中 2 个
节点而 MUTFAIL,不会静默错位。

运算符夹缝用 tokenize 精确取第一个 AND/OR 的 NAME 令牌,不用正则——
注释夹缝形态 a  # keep and note + 换行 and b 里,按关键字找会先命中
注释文本(注释是 COMMENT 令牌,不会误配)。

测试 4 条:双向施加、链式键、只翻第一个、注释夹缝不误翻。
全量套件与 green39 夹具跑在本变更前的基线上(见下一条提交前已复跑,
35/4 逐条一致)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
本轮 boolop 扫描(and↔or)实测:多项 or 链里靠后的项若已覆盖前项为真
的所有情形(如 `not is_double_convertible(x)`),翻转前项的 or 是等价变异
而非缺口;`isinstance(x, bool) or not isinstance(x, (int, float))` 这类
两条件互斥(bool 是 int 子类)的 or 翻成 and 后恒假,看似真缺口,实际
常被同一 or 链后项或下游校验兜住。

判据落进 README 是给后续扫描者的约束:存活点必须红验证(注入变异→候选
测试确实变红)确认,仅凭「某输入下前两项会分叉」的推演会误报——本轮
model_provider.py:752 就这样被误判过,红验证后改判为等价变异。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
boolop(and↔or)全量扫描的 SURVIVED 位点分诊,确认
runtime_verified_protocols 的白名单守卫缺一半覆盖:

`if supported_protocols is not None and normalized_protocol not in
supported_protocols: raise` —— 既有用例
test_runtime_verified_protocols_rejects_unknown_protocol 只覆盖「协议不在
白名单→拒绝」(["nope"]),从未走 and 的假分支。把 and 互换为 or 后:
  * 协议**在**白名单内(合法配置)也 raise → 被误拒;
  * supported_protocols=None 时 `None is not None` 为假,
    `or normalized_protocol not in None` 抛 TypeError。
两条路径都不在契约内,而全量套件仍绿。

补 2 条:协议在白名单内必须接受;supported_protocols=None 时不施加白名单。
红验证:注入 and→or 后两条均变红,还原后复绿(test_factory.py 63 passed)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
boolop 扫描确认:jina/siliconflow_rerank 的 _parse_response 超范围判据
`isinstance(raw_score, int) and not isinstance(raw_score, bool) and not
is_double_convertible(raw_score)` 中间那个 `not isinstance(..., bool)` 是**有意**
排除 bool 的(bool 是 int 子类,float(True)==1.0 走的不是「超范围」这条)。
把其前的 and 互换为 or 后,raw_score=True 会命中误报「超出双精度范围」。

既有测试覆盖不到这条独立分支:
  * test_rerank_invalid_score_is_explicit 只喂字符串/nan/inf(都带键走 float() 路径);
  * test_rerank_response_score_beyond_double_range_is_explicit 只喂超大整数
    (两种变体都 raise,区分不了)。
bool 分支从未被覆盖。

补 test_rerank_bool_score_is_not_reported_as_out_of_range(参数化 True/False ×
两个 provider)。红验证:注入 and→or 后 4 条全红,还原后复绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
boolop 扫描确认:jina/siliconflow_rerank 的 _parse_response 里
`not isinstance(direct_index, int) or isinstance(direct_index, bool) or
not 0 <= direct_index < len(documents)` 中段的 `isinstance(..., bool)` 是
**有意**排除 bool 的。把其前的 or 互换为 and 后,index=True 且
len(documents) >= 2 时整条判据为假 → bool 被当 index=1 接受(落到
positions[...],产出 ([True], [0.9]))。

既有测试覆盖不到这条分支:invalid_index 只喂越界整数 4、accepts_positive
只喂合法整数 1,从未穿 bool;且单元素 documents 时范围判据恰好仍拒绝、
掩盖缺陷(docs ≥ 2 才分岔)。

补 test_rerank_bool_index_is_rejected(两个 provider)。红验证:注入
or→and 后 2 条全红,还原后复绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
boolop 扫描确认:faiss_store._build_index_text 的
`if isinstance(source, str) and source:` 里前半个 isinstance 是**有意**短路——
只有字符串才进 `Path(source).stem`。把其后的 and 互换为 or 后,非字符串的
真值 source(123/["a.md"]/{"path":...}/b"a.md" 等)也命中 →
`Path(123)` 抛 `TypeError: expected str, bytes or os.PathLike object`。

该路径在索引重建(_rebuild_indices)与快照加载都会走到,检索结果可来自任何
VectorStoreBase 实现(第三方 store 的 metadata 不受本项目载入期归一化约束)。
既有 test_build_index_text_keeps_original_content_when_source_missing 只喂空
metadata(source 缺省为 "",是字符串),非字符串分支从未被覆盖。

补 test_build_index_text_tolerates_non_string_source(4 种非字符串类型)。
红验证:注入 and→or 后 4 条全红,还原后复绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
① resolve_parent_content 的空串归一(faiss_store.py:473)
`return content if isinstance(content, str) and content else None` 的后半个
`and content` 是**有意**把空串归一成 None 的(契约「无内容→None」,调用方按
is None 判断)。and→or 后 content=="" 返回 ""。既有
test_faiss_resolve_parent_content_tolerates_none_valued_entries 只喂 "父块正文"
与 None 条目,空串分支从未覆盖。补 test_..._empty_string_returns_none。

② semantic_search 的单侧 None 短路(faiss_store.py:586)
`if self.faiss_index is None or self.embeddings is None: return []` 两条件不互斥,
任一为 None 即短路。or→and 后只有两者同时 None 才短路,半初始化状态
(index=None、embeddings 非空)越过守卫,落到 self.faiss_index.search() 抛
`AttributeError: 'NoneType' object has no attribute 'search'`。补
test_semantic_search_returns_empty_when_only_index_is_missing。

两条均红验证:注入 and↔or 后各自变红,还原后复绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
① security.py:661 — URL userinfo 任一半单独出现都须判为凭证
`parsed.username is not None or parsed.password is not None` 两半各判各的。
or→and 后只有两半同时存在才判凭证,「只带用户名」/「只带密码」的 URL 整体
漏检,凭证边界静默失效。既有用例只覆盖 query 键名与值内令牌,未覆盖 userinfo
任一半单独出现。补 test_security_rejects_url_userinfo_whichever_half_is_present。

② security.py:650 — 畸形 URL 回退分支的凭证消息须带真实字段路径
`fallback = [path or "value"] if ... else []` 的 `path or "value"` 是空路径兜底;
正常路径 path 恒非空。or→and 后非空 path 反被换成 "value"——凭证仍被拒,但
用户看到「凭证出现在 value」,无法定位字段。既有用例只 match="凭证",不校验
路径。补 test_security_unparseable_url_reports_the_real_field_path。

③ display_utils.py:256 — coerce_score 须接受 numpy 标量
`isinstance(value, (int, float)) or hasattr(value, "__float__")` 的后半接住
numpy 标量(非 float 子类)。or→and 后整条恒假,真实分数被静默降成 0.0,
既影响融合排序又被分数阈值全部过滤。既有用例取值全是 Python 原生数字/字符串/
None。补 test_coerce_score_accepts_numpy_scalars。

三条均红验证:注入变异后各自变红,还原后复绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
① retrieval_service.py:942/986 — 重排与 Embedding 都失败时报先发生的错误
`first_error = first_error or exc` 的 or 是「保留第一个错误」守卫。or→and 后,
首个错误被后发生的覆盖——用户看到「嵌入失败」,真正的首个失败原因从栈上消失。
既有 test_close_closes_embedding_after_rerank_close_failure 只让重排失败
(Embedding 成功,走不到 or),单失败场景盖不住双失败分支。
补 test_close_reports_the_first_error_when_both_closes_fail 与
test_aclose_reports_the_first_error_when_both_closes_fail(同步异步各一)。

② security.py:655 — 畸形 URL 回退分支只对敏感 query 键拒绝
`if key and is_sensitive_option_key(key)` 的 and→or 后,任何非空键(普通键如 a)
都被当凭证,无害畸形 URL 被误拒。既有 test_..._still_checks_query_key_names 只喂
敏感键 ?myApiKey=(两种变异都 raise),非敏感键从未覆盖。
补 test_security_unparseable_url_accepts_benign_query_keys。

三条均红验证:注入变异后各自变红,还原后复绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
把本轮 boolop(and↔or)全量扫描(735 位点,两批)的分诊结果写进 CHANGELOG:
8 组真缺口(11 位点)已补测试、6 位点确证等价、2 例 TIMEOUT 经定点复跑
确认是「已有测试杀死」而非缺口。含工具边界说明(只翻第一个运算符)。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@sourcery-ai

sourcery-ai Bot commented Oct 13, 2026

Copy link
Copy Markdown

Sorry @MisonL, your pull request is larger than the review limit of 150,000 diff characters

@coderabbitai

coderabbitai Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration
  • Configuration used: defaults
  • Review profile: CHILL
  • Plan: Advanced
  • Run ID: 55e013c6-824c-444e-bf27-c3c4a8ec6da3
  • Autofix · Keep fixing CodeRabbit findings and required CI, and resolving merge conflicts

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gitguardian

gitguardian Bot commented Oct 13, 2026 •

Copy link
Copy Markdown

️✅ There are no secrets present in this pull request anymore.

If these secrets were true positive and are still valid, we highly recommend you to revoke them.
While these secrets were previously flagged, we no longer have a reference to the
specific commits where they were detected. Once a secret has been leaked into a git
repository, you should consider it compromised, even if it was deleted immediately.
Find here more information about risks.


🦉 GitGuardian detects secrets in your source code to help developers and security teams secure the modern development process. You are seeing this because you or someone else with access to this repository has authorized GitGuardian to scan your pull request.

MisonL and others added 3 commits October 13, 2026 11:14
- /private/tmp 仅在 macOS 上经 /tmp 符号链接解析命中守卫;Linux 上该路径
  不存在、放行是正确行为,拆出为 darwin 专属用例(quality 只跑 ubuntu-24.04)
- Basic 脱敏夹具的 base64("user:password") 字面量改为运行时构造,源码不再
  含可被凭证扫描器误判的字符串;CHANGELOG 两处同源字面量一并脱去
- 红/绿验证:注入「跳过 Basic 脱敏」变异后 3 条用例全红,还原复绿;
  全量 4450 passed,8 项质量门 + 审计 selftest 全绿

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- faiss_store._parent_key 对可表达的整数回退 str() 形态(与 main 时代快照
  键一致,实测旧快照整数 id 召回 miss 已复现),仅 str() 抛 ValueError 的
  超限整数才走 sha256 摘要键;新增旧形态快照兼容回归测试(红/绿验证)
- test_guard_coverage_regressions 两个用例的 CONFIG_TOML_PATH 直接赋值改
  monkeypatch,不再把指向已删临时目录的路径泄漏给后续用例
- green39 夹具去掉第 64/65 行的重复位点,更名 green38(38 个唯一位点,
  34 KILLED/4 SURVIVED),测试断言与文档引用同步;CHANGELOG 历史段保留
  当时文件名

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- google.normalized_key 字符串直接规范化(长键名 apikey 后缀经 safe_repr
  80 字符截断后 endswith 失配而放行,实测复现),标量 content 字符串原样
  透传(长文本被加引号截断成 '前 77 字...' 发给模型);各补回归测试并红验证
- load_snapshot 词法缓存/BM25 赋值推迟到最后一个可失败步骤之后,拒绝的
  载入不再留「缓存新、documents 旧」的中间态
- background_poll_* 错误消息下界改为「大于 0」;weighted_fusion 溢出用例
  按 _weight_tuple 实际两条守卫分岔改 pytest.raises;ExcelLogger 竞态用例
  冻结时钟;装饰器扫描按 __file__ 锚定仓库根;HIDDEN_IMPORTS 前提检查补
  from 形态;root 跳过 chmod 用例;parse 用例断言方法专属消息并修乱码
- CHANGELOG:补全截断句、合并重复段、修「被红acted」;green38 夹具 6 个
  google.py 坐标因本轮修改漂移,重基线(全量 4453 passed,8 门全绿)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@MisonL
MisonL force-pushed the fix/retrieval-boundary-hardening branch from 21a5147 to dc4179f Compare October 13, 2026 06:13
MisonL and others added 4 commits October 13, 2026 14:20
- SessionConfig.__getitem__ 读路径对非法字段名改抛 KeyError(_collections_abc
  的 in/get 只接 KeyError,5 in cfg 此前抛 ValueError);消息经 safe_repr,
  超限整数不再二次撞 Exceeds the limit。写路径保持 ValueError。双向契约测试
  钉死,红验证:还原旧实现后 7 条用例红
- 配置编辑器动态上界对非法直写值回落到单字段上界(此前 require_scale_field
  抛的 ValueError 在 chat/core 的 to_thread 调用点之外,会终止整个会话);
  retrieval_method 非法时显示未知、default=None 进菜单。既有三条断言「应抛
  ValueError」的用例按会话存活契约改写(该契约与 _rerank_menu_label 注释一致)
- 两处测试名实不符:constructor_rejects_temperature 补构造器路径断言;
  empty_snapshot_root 真走 build_run_config → load_snapshot 并断言归属判据
  专有消息(红验证:禁用 resolve_within 成员判定后该用例红)
- CHANGELOG 补第二轮复核条目;green38 夹具 2 个 config_menu 坐标重基线
  (全量 4461 passed,8 门 + selftest 全绿)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
四名审查 agent(安全、数据完整性、并发/生命周期、错误契约/UI)对分支做
对抗性终审,逐条真实入口探针复现后修复,全部红/绿验证:

- 裸自证前缀凭证(ghp_/github_pat_/xox/AKIA/ASIA/jina_)不脱敏,
  security.py 新增 _SELF_ATTESTING_PREFIX_RE 并入管线(12 用例;
  假凭证主体用拼接构造,避免 Push Protection 对合成样本误拦)
- _key_text/_parent_key 对 repr 超 80 字符的值塌键:对真实 repr 字节取
  sha256 摘要(safe_repr 截断输出恰 80 字符,判据必须落在真实 repr 上);
  _parent_key 对 numpy 标量经 .item() 归一为 main 兼容键
- load_snapshot/load 新增 _validate_row_alignment:embeddings 行数、
  semantic.index 条数与分块数三量对齐,拒绝保持原子性
- /config 的编辑器调用与配置回显收进恢复 try——回显 ValueError 曾终止
  整个会话;finally 的 aclose 改为吞掉替身异常;局部 logger 撞名修复
- retry_sync_stream:建立阶段 close 吞错不顶替原始异常;补关底层响应
  (openai/anthropic __iter__ 是生成器,close 包装层不关连接)
- embed_knowledge_base.main_async 补 finally aclose(唯一不释放
  EmbeddingService 的建库入口)
- 菜单非法 retrieval_method 显示「未知」与 Rerank 项契约一致;
  _parent_key docstring 阈值声明修正;green38 夹具坐标重基线

全量 4488 通过;8 项质量门全绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
四名 agent 对 dad5e9a 复查,逐条探针复现后修复,全部红/绿验证:

- 空/截断 legacy pkl 的裸 EOFError 被 main 误判为用户中断正常退出,
  (OSError, EOFError, UnpicklingError) 收敛为带指引 ValueError
- 「未知」占位串传给 method 子菜单 default 构造期炸——非法
  retrieval_method 恰是该字段唯一还能修回的入口;不在候选时传 None
- load_snapshot 补存在性组合与维度不变量:缺 semantic.index(有
  lexical 不重建)、缺 embeddings.npy、维度错配三种形态此前静默
  空检索或崩空消息 AssertionError
- 悬空 ACTIVE_SNAPSHOT 标记显式 FileNotFoundError,不再静默降级
  空 store 或导入旧数据改写标记
- retry_sync_stream 建立阶段 validator 失败补关底层 response
  (429 重试路径每次重试泄漏一条连接,实测 3 次=3 个未关闭)
- 凭证正则扩 gho_/ghs_/ghu_/ghr_、xoxe-/xapp-,体加含数字前瞻
  消除知识库正文误伤;ReDoS 探针全过
- legacy 空库导入报错顺序与消息修正;green38 夹具两轮漂移重基线

全量 4508 通过;8 项质量门全绿。

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant