Skip to content

Commit dc4179f

Browse files
MisonLclaude
andcommitted
fix: CodeRabbit 复核 minor 全量收口——google safe_repr 误用、载入原子性、测试健壮性
- 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>
1 parent a92ea87 commit dc4179f

12 files changed

Lines changed: 115 additions & 43 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,14 @@
66

77
### 修复
88

9+
- **CodeRabbit 全量复核:3 处 major + 14 处 minor 逐条分诊修复**。对 PR #3 全量 diff(189 提交/81 文件)跑 CLI 复核,17 条 findings 逐条对照当前代码核实:
10+
- **`_parent_key` 破坏旧快照父块召回(major,已复现)**(`src/retrieval/vdb/faiss_store.py`)。main 时代代码把父块索引按 `str(parent_id)` 写盘;本分支把整数 id 无差别换成 sha256 摘要键,而盘上键是字符串、原样保留——实测旧快照过 `_normalize_documents` 后父块键仍是 `"1"`,`resolve_parent_content(1)` 算出 `"<整数,4bf5…>"` 而 miss、返回 `None`,整数 `parent_id` 的父块召回整体失灵。摘要形态改为**只对 `str()` 表达不了的超限整数启用**:`str` 对可表达整数是精确转换、本身不塌键(塌键风险只来自 `safe_repr` 的截断),先试 `str()`、抛 `ValueError` 才落摘要——旧快照零破坏,超限整数仍唯一稳定。新增旧形态快照兼容回归测试(红验证:禁用 `str()` 回退后该测试红)。
11+
- **测试把删除的临时路径泄漏进全局(major)**(`tests/test_guard_coverage_regressions.py`)。两个用例直接赋值 `config.CONFIG_TOML_PATH` 且不还原,`TemporaryDirectory` 结束后全局仍指向已删路径。改 `monkeypatch.setattr`。
12+
- **`green39.txt` 夹具含重复位点(minor)**:第 64/65 行完全相同(`volcengine.py|1941|31|notin`),39 行实际只有 38 个唯一位点、KILLED 计数把它算两次。删重复行并更名 `green38.txt`(38 个唯一位点,34 KILLED/4 SURVIVED),`test_green38_fixture_parses_with_expected_verdicts` 断言与文档同步;本轮 `_parent_key` 等 `google.py` 修改又使 6 个坐标漂移,按上下文重基线(守卫语义逐一对照确认)。CHANGELOG 历史段保留当时文件名。
13+
- **`GoogleProvider` 两处 `safe_repr` 误用(minor,均实测复现)**(`src/providers/google.py`):`normalized_key` 经 `safe_repr` 规范化后 80 字符截断会把超长键名的 `apikey` 后缀切掉、`endswith` 判据失配而放行凭证键(实测 90 字符键名放行)——字符串直接规范化、非字符串才走 `safe_repr`;标量 `content` 无条件走 `safe_repr` 会把普通长文本消息加引号并截断成 `'前 77 字...'` 发给模型——字符串原样透传、非字符串保持 `safe_repr` 兜底(超限整数仍不崩)。各补长键名拒绝、长文本透传回归测试,均红验证。
14+
- **其余 minor**:`load_snapshot` 的词法缓存与 BM25 赋值推迟到全部可失败步骤之后(拒绝的载入不改 store 契约覆盖到最后一步);`background_poll_*` 错误消息把下界说成 1(实际 0.5 合法)改为「大于 0 且不超过」;`_weight_tuple` 溢出用例改 `pytest.raises` 并按实测把 `10**305` 归入值域判据分支;ExcelLogger 竞态用例冻结时钟(真实时钟跨秒会让断言空转);`_contents` 装饰器扫描按测试文件位置锚定仓库根(CWD 外启动不再扫空);`HIDDEN_IMPORTS` 前提检查补 `from X import` 形态;root 下跳过 `chmod 0o500` 权限用例;parse 用例改断言方法专属消息并修复两处 docstring 乱码;CHANGELOG 截断句补全(整数解析判据用例组)、重复段合并、「被红acted」改「被脱敏」。
15+
- **误报 1 条**:`httpx2` 不是 `httpx` 笔误——它是 `uv.lock` 声明的真实依赖(httpx2 2.12.0),930 条相关测试实测全过。
16+
917
- **跨平台测试偏差:`/private/tmp` 不能进公共危险路径参数表**(`tests/test_cleanup.py`)。`test_reject_unsafe_cache_dir_rejects_system_level_roots` 的参数表把 `"/private/tmp"` 与 `"/tmp"` 并列,但两者只在 **macOS** 上解析为同一路径(`/tmp` 是指向 `/private/tmp` 的符号链接)。守卫 `_reject_unsafe_cache_dir` 的判据是「解析后与受保护根**相等**」,Linux 上 `/private` 不是系统目录、该路径也不存在,返回 `None` 是正确行为(`cleanup_temp_files` 在 `rmtree` 前另有 `os.path.exists` 检查,放行无害)。quality workflow 只跑 `ubuntu-24.04`,该参数在 CI 上必红(实测 `1 failed, 4449 passed`)。改为 darwin 专属用例 `test_reject_unsafe_cache_dir_rejects_macos_tmp_resolution_target`(`skipif(platform != "darwin")`),公共表只留跨平台成立的系统根。
1018

1119
- **测试夹具与 CHANGELOG 里的 base64 假凭证字面量改为运行时构造**(`tests/providers/test_security_boundaries.py`、`CHANGELOG.md`)。Basic 认证脱敏用例的夹具值是 `base64("user:password")`,被 GitGuardian 按 `Bearer Token` 检测器报为 PR #3 的硬编码秘密——它并非真实凭证(解码即教材占位符),但字面量本身会持续触发扫描并阻塞合并。夹具改为 `base64.b64encode(b"fixture-user:fixture-password").decode()` 在运行时构造,明文占位符不构成密钥,编码结果与断言语义逐字节不变;CHANGELOG 两处同源字面量一并改为 `<base64 凭证>` 描述。脱敏行为本身与断言强度未变(红/绿验证同前)。

‎scripts/audit/regressions/green38.txt‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -35,13 +35,13 @@ src/ui/config_menu.py|209|15|isnot|tests/ui/test_config_menu.py::test_edit_retri
3535
src/ui/config_menu.py|436|15|isnot|tests/ui/test_config_menu.py::test_edit_model_params_writes_selected_rerank_configuration|KILLED
3636
src/retrieval_test/excel_logger.py|346|84|isnot|tests/retrieval_test/test_retrieval_cli.py::test_excel_log_drops_none_scores_from_all_scores_column|KILLED
3737
src/providers/factory.py|356|16|isnot||SURVIVED
38-
src/providers/google.py|1792|27|drop|tests/providers/test_guard_coverage_regressions.py::test_google_tool_result_part_resolves_name_from_call_id|KILLED
39-
src/providers/google.py|1794|61|drop|tests/providers/test_guard_coverage_regressions.py::test_google_tool_result_accepts_valid_name|KILLED
40-
src/providers/google.py|2563|19|drop|tests/providers/test_guard_coverage_regressions.py::test_google_stream_refusal_part_uses_text_fallback|KILLED
41-
src/providers/google.py|2599|37|drop|tests/providers/test_guard_coverage_regressions.py::test_google_stream_candidate_refusal_emitted_once|KILLED
38+
src/providers/google.py|1798|27|drop|tests/providers/test_guard_coverage_regressions.py::test_google_tool_result_part_resolves_name_from_call_id|KILLED
39+
src/providers/google.py|1800|61|drop|tests/providers/test_guard_coverage_regressions.py::test_google_tool_result_accepts_valid_name|KILLED
40+
src/providers/google.py|2576|19|drop|tests/providers/test_guard_coverage_regressions.py::test_google_stream_refusal_part_uses_text_fallback|KILLED
41+
src/providers/google.py|2612|37|drop|tests/providers/test_guard_coverage_regressions.py::test_google_stream_candidate_refusal_emitted_once|KILLED
4242
src/providers/google.py|306|19|notin|tests/providers/test_google_genai.py::test_google_provider_loads_credentials_file_and_forwards_it|KILLED
43-
src/providers/google.py|3674|11|isnot|tests/providers/test_guard_coverage_regressions.py::test_google_async_validate_tuning_reward_forwards_composite_config|KILLED
44-
src/providers/google.py|571|15|drop|tests/providers/test_guard_coverage_regressions.py::test_google_validate_http_options_keeps_blank_query_values|KILLED
43+
src/providers/google.py|3687|11|isnot|tests/providers/test_guard_coverage_regressions.py::test_google_async_validate_tuning_reward_forwards_composite_config|KILLED
44+
src/providers/google.py|577|15|drop|tests/providers/test_guard_coverage_regressions.py::test_google_validate_http_options_keeps_blank_query_values|KILLED
4545
src/providers/__base__/model_provider.py|1202|11|notin||SURVIVED
4646
src/providers/__base__/model_provider.py|336|7|isnot||SURVIVED
4747
src/providers/openai_compatible.py|1153|11|isnot|tests/providers/test_guard_coverage_regressions.py::test_chat_request_forwards_extra_headers|KILLED

‎src/providers/google.py‎

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -545,7 +545,13 @@ def _validate_http_options(cls, value: Any) -> None:
545545
found: list[str] = []
546546

547547
def normalized_key(key: Any) -> str:
548-
return "".join(char for char in safe_repr(key).lower() if char.isalnum())
548+
# 字符串直接规范化:``safe_repr`` 对字符串加引号、并在 80 字符处
549+
# 截断成 ``'xxx...'``,超长键名的后缀(``...apikey``/``...secret``)
550+
# 会被截掉,``is_sensitive_key`` 的 ``endswith`` 判据随即失配——
551+
# 一个长键名的凭证键就会被放行(实测复现)。非字符串才走
552+
# ``safe_repr``(不崩)。
553+
text = key if isinstance(key, str) else safe_repr(key)
554+
return "".join(char for char in text.lower() if char.isalnum())
549555

550556
def is_sensitive_key(key: Any) -> bool:
551557
normalized = normalized_key(key)
@@ -1859,14 +1865,21 @@ def function_response_value(value: Any) -> Any:
18591865
else:
18601866
raise ValueError(f"Google 不支持的消息内容块类型: {part_type!r}。")
18611867
else:
1862-
# 标量 content 的渲染走 ``safe_repr`` 而不是裸 ``str()``:
1863-
# ``normalize_messages`` 只校验 ``messages`` 是「对象序列」,不校验
1864-
# content 的形态(实测 ``{"role": "user", "content": 10**4301}``
1865-
# 原样通过),因此这里是标量 content 的唯一收口。``str(10**4301)``
1866-
# 抛 ``ValueError: Exceeds the limit``——类型对、内容却与「消息
1867-
# 内容写错了」无关。判据与同文件的 ``_normalize_finish_reason`` 一致
1868-
# (非字符串走 ``safe_repr``)。
1869-
parts = [types.Part(text=safe_repr(raw_content))]
1868+
# 字符串 content 原样透传;其余标量 content 的渲染走 ``safe_repr``
1869+
# 而不是裸 ``str()``:``normalize_messages`` 只校验 ``messages``
1870+
# 是「对象序列」,不校验 content 的形态(实测
1871+
# ``{"role": "user", "content": 10**4301}`` 原样通过),因此这里
1872+
# 是标量 content 的唯一收口。``str(10**4301)`` 抛
1873+
# ``ValueError: Exceeds the limit``——类型对、内容却与「消息
1874+
# 内容写错了」无关。字符串**必须**排除在 ``safe_repr`` 之外:
1875+
# 后者会加引号并在 80 字符处截断,把普通长文本消息改写成
1876+
# ``'前 77 字...'`` 发给模型。非字符串的兜底判据与同文件的
1877+
# ``_normalize_finish_reason`` 一致。
1878+
parts = [
1879+
types.Part(
1880+
text=raw_content if isinstance(raw_content, str) else safe_repr(raw_content)
1881+
)
1882+
]
18701883
for index, call in enumerate(message.get("tool_calls", []) or []):
18711884
if not isinstance(call, Mapping):
18721885
raise ValueError(f"Google assistant tool_calls[{index}] 必须是对象。")

‎src/providers/openai_compatible.py‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -561,7 +561,7 @@ def _background_poll_config(self) -> tuple[float, float]:
561561
or interval > _MAX_CLIENT_TIMEOUT_SECONDS
562562
):
563563
raise ValueError(
564-
f"Responses background_poll_interval 必须是 1 到 {_MAX_CLIENT_TIMEOUT_SECONDS:.0f} 之间的正数。"
564+
f"Responses background_poll_interval 必须是大于 0 且不超过 {_MAX_CLIENT_TIMEOUT_SECONDS:.0f} 的正数。"
565565
)
566566
if (
567567
isinstance(timeout, bool)
@@ -571,7 +571,7 @@ def _background_poll_config(self) -> tuple[float, float]:
571571
or timeout > _MAX_CLIENT_TIMEOUT_SECONDS
572572
):
573573
raise ValueError(
574-
f"Responses background_timeout 必须是 1 到 {_MAX_CLIENT_TIMEOUT_SECONDS:.0f} 之间的正数。"
574+
f"Responses background_timeout 必须是大于 0 且不超过 {_MAX_CLIENT_TIMEOUT_SECONDS:.0f} 的正数。"
575575
)
576576
# 上界与客户端 timeout 同一个常量(``_MAX_CLIENT_TIMEOUT_SECONDS``):
577577
# 该常量存在的理由就是「凡在该值上再做算术的下游都会炸」——这里

‎src/retrieval/vdb/faiss_store.py‎

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1002,15 +1002,23 @@ def load_snapshot(
10021002
f"{position} 条语料不是序列({type(entry).__name__}),"
10031003
"无法作为关键词索引语料使用。请重建快照。"
10041004
)
1005-
self._tokenized_docs_cache = tokenized_docs if tokenized_docs is not None else []
1006-
self.bm25_index = self._build_bm25_index(self._tokenized_docs_cache)
1005+
# ``_build_bm25_index`` 的入参已经过上面三道守卫(顶层形态、条数、
1006+
# 条目形态),是「最后一个可以失败的步骤」——失败也只发生在局部
1007+
# 变量上,``self`` 仍是旧快照的完整状态(「拒绝的载入不改 store」
1008+
# 契约覆盖到最后一步)。缓存赋值整体推迟到本块末尾、与新快照字段
1009+
# 同批进行,避免「缓存已是新值、documents 还是旧值」的中间态。
1010+
lexical_cache = tokenized_docs if tokenized_docs is not None else []
1011+
lexical_bm25 = self._build_bm25_index(lexical_cache)
10071012
# 全部校验通过,到这里才改动 store。``_rebuild_indices`` 走 ``else`` 分支
10081013
# 时会读 ``self.documents``,因此必须排在这些赋值之后。
10091014
self.documents = documents
10101015
self.parent_documents = parent_documents
10111016
self.embeddings = embeddings
10121017
self.faiss_index = faiss_index
1013-
if not lexical_path.exists():
1018+
if lexical_path.exists():
1019+
self._tokenized_docs_cache = lexical_cache
1020+
self.bm25_index = lexical_bm25
1021+
else:
10141022
self._rebuild_indices()
10151023

10161024
def import_legacy_snapshot(

‎tests/providers/test_google_genai.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -215,6 +215,9 @@ def test_google_get_client_requires_explicit_auth_without_key_or_vertex_mode(mon
215215
{"base_url": "https://proxy.invalid/v1?api_key=secret"},
216216
{"base_url": "https://user:password@proxy.invalid/v1"},
217217
{"httpx_client": object()},
218+
# 长键名尾缀凭证:``normalized_key`` 若经 ``safe_repr`` 规范化,80 字符
219+
# 截断会把超长键名的 ``apikey`` 后缀切掉,``endswith`` 判据失配而放行。
220+
{"headers": {("x-" + "tooling" * 15 + "apikey"): "secret"}},
218221
],
219222
)
220223
def test_google_http_options_reject_credentials_or_custom_clients(http_options):

‎tests/providers/test_sdk_capabilities.py‎

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -9795,10 +9795,10 @@ def _anthropic_parse_provider():
97959795
],
97969796
)
97979797
def test_anthropic_parse_requires_output_format(method, message):
9798-
"""同步两个 parse 入口缺 ``output_format`` 必须给专��消息。"""
9798+
"""同步两个 parse 入口缺 ``output_format`` 必须给专属消息。"""
97999799
provider = _anthropic_parse_provider()
98009800

9801-
with pytest.raises(ValueError, match="必须提供 output_format"):
9801+
with pytest.raises(ValueError, match=message):
98029802
getattr(provider, method)(None)
98039803

98049804

@@ -9810,10 +9810,10 @@ def test_anthropic_parse_requires_output_format(method, message):
98109810
],
98119811
)
98129812
def test_anthropic_async_parse_requires_output_format(method, message):
9813-
"""异步两个 parse 入口是**另一份**实现,同样要有专��消息。"""
9813+
"""异步两个 parse 入口是**另一份**实现,同样要有专属消息。"""
98149814
provider = _anthropic_parse_provider()
98159815

9816-
with pytest.raises(ValueError, match="必须提供 output_format"):
9816+
with pytest.raises(ValueError, match=message):
98179817
asyncio.run(getattr(provider, method)(None))
98189818

98199819

@@ -10033,7 +10033,7 @@ def test_openai_compatible_background_timeout_must_be_positive_number(timeout):
1003310033
provider = object.__new__(OpenAICompatibleProvider)
1003410034
provider._options = {"background_timeout": timeout}
1003510035

10036-
with pytest.raises(ValueError, match="background_timeout 必须是 1 到 86400 之间的正数"):
10036+
with pytest.raises(ValueError, match="background_timeout 必须是大于 0 且不超过 86400 的正数"):
1003710037
provider._background_poll_config()
1003810038

1003910039

@@ -10046,7 +10046,7 @@ def test_openai_compatible_background_timeout_upper_bound():
1004610046
provider = object.__new__(OpenAICompatibleProvider)
1004710047
provider._options = {"background_timeout": 1e19}
1004810048

10049-
with pytest.raises(ValueError, match="background_timeout 必须是 1 到 86400 之间的正数"):
10049+
with pytest.raises(ValueError, match="background_timeout 必须是大于 0 且不超过 86400 的正数"):
1005010050
provider._background_poll_config()
1005110051

1005210052

‎tests/retrieval/test_weighted_fusion.py‎

Lines changed: 13 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -436,24 +436,25 @@ def test_rerank_result_accepts_valid_pairs():
436436

437437

438438
@pytest.mark.parametrize(
439-
"weight",
439+
("weight", "message"),
440440
[
441-
pytest.param(10**308, id="just-over-double-sum"),
442-
pytest.param(10**305, id="large-but-summable"),
441+
pytest.param(10**308, "和为正的有限数值", id="just-over-double-sum"),
442+
pytest.param(10**305, "必须在 0.0 到 1.0 之间", id="large-but-in-unit-range-violation"),
443443
],
444444
)
445-
def test_weight_sum_overflow_is_a_value_error(weight):
446-
"""两个权重各自合法但和超出范围时,收场必须是 ``ValueError``。"""
445+
def test_weight_sum_overflow_is_a_value_error(weight, message):
446+
"""两个权重各自合法但**派生值**越界时,收场必须是 ``ValueError``。
447+
448+
实测两个大整数权重在 ``_weight_tuple`` 的两条守卫上分岔:``10**308 * 2``
449+
在派生和的 ``isfinite`` 判据上被拒(转 float 溢出成 ``inf``);``10**305``
450+
的和在 double 内,但单个权重超出 ``[0, 1]`` 值域判据,同样以带指引的
451+
``ValueError`` 收场。两条都必须是 ``ValueError`` 而不是裸
452+
``OverflowError``——守卫被删时用例红。
453+
"""
447454
reranker = HybridReranker(weight, weight)
448455

449-
# 10**305 * 2 仍在 double 范围内,因此那条路径应正常返回;
450-
# 10**308 * 2 超出,必须在构造期或 rerank 期抛 ValueError。
451-
try:
456+
with pytest.raises(ValueError, match=message):
452457
reranker.rerank([{"metadata": {}, "page_content": "x", "score": 0.5}])
453-
except ValueError:
454-
pass
455-
except ArithmeticError as exc: # pragma: no cover - 回归失败时触发
456-
pytest.fail(f"溢出应以 ValueError 收场,实际是 {type(exc).__name__}: {exc}")
457458

458459

459460
@pytest.mark.parametrize(

‎tests/retrieval_test/test_retrieval_cli.py‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ def test_excel_logger_keeps_the_plain_name_when_nothing_collides(tmp_path):
299299
assert re.fullmatch(r"recall_test_log_\d{8}_\d{6}\.xlsx", os.path.basename(logger.filepath))
300300

301301

302-
def test_excel_logger_skips_names_taken_by_other_files(tmp_path):
302+
def test_excel_logger_skips_names_taken_by_other_files(tmp_path, monkeypatch):
303303
"""已存在的同名文件(例如上一次运行留下的)必须被跳过,而不是被覆盖。
304304
305305
判据是 ``O_CREAT | O_EXCL`` 而不是 ``os.path.exists``:后者是「先查后写」,
@@ -310,8 +310,18 @@ def test_excel_logger_skips_names_taken_by_other_files(tmp_path):
310310

311311
from src.retrieval_test.excel_logger import ExcelLogger
312312

313-
# 预置一个与本次同秒同名的文件,模拟「上一次运行在同一秒留下的日志」。
314-
timestamp = datetime.now().astimezone().strftime("%Y%m%d_%H%M%S")
313+
# 把时钟钉在固定秒:真实时钟在「预置文件」与 logger 内部第二次取时间之间
314+
# 跨秒的话,logger 会用新时间戳、不撞预置名,用例在断言上静默变成空转。
315+
frozen = datetime(2026, 10, 13, 12, 0, 0).astimezone()
316+
317+
class _FrozenDatetime(datetime):
318+
@classmethod
319+
def now(cls, tz=None):
320+
return frozen if tz is None else frozen.astimezone(tz)
321+
322+
monkeypatch.setattr("src.retrieval_test.excel_logger.datetime", _FrozenDatetime)
323+
324+
timestamp = frozen.strftime("%Y%m%d_%H%M%S")
315325
occupied = tmp_path / f"recall_test_log_{timestamp}.xlsx"
316326
occupied.write_bytes(b"PREEXISTING")
317327

0 commit comments

Comments
 (0)