Files
pp-qa-km/tools/selfcheck_search.py
LittleYellow 0b4c2bbb15 修復中文路徑 preflight 與 BM25 核心詞落空,測資改照現實建
兩個 bug 同源於自檢測資「照能過建、不照現實建」:

- ingest preflight:git status --porcelain 預設會把含中文或空格的路徑加引號
  轉義,raw/ 白名單比對因此失效,中文檔名的 convert 產出被誤判為 raw/ 以外的
  未提交變更而中止攝入。舊測資用 ASCII 檔名,整條路徑從未被走過。

- search:BM25Okapi 的 idf 在 df >= N/2 時 <= 0(rank_bm25 對負 idf 的替代值
  epsilon x average_idf 在 average_idf 為負時同樣為負),與 search() 的 s > 0
  過濾相乘,會讓「每頁都提到的核心詞」查詢全數落空——知識庫愈小、詞愈核心
  愈嚴重,MCP 的 search_wiki 一併受害。改用 Lucene 式恆正 idf。
  舊測資每個查詢詞都只出現在一頁(df=1),恰好避開此配置。

變更:
- tools/ingest.py:preflight 比對前剝除 git 的轉義引號
- tools/search.py:_BM25 子類覆寫 _calc_idf 為 log(1 + (N-df+0.5)/(df+0.5))
- tools/selfcheck_ingest.py:新增 preflight 中文檔名放行 / 非 raw 擋下的檢查
- tools/selfcheck_search.py:新增與既有頁共用核心詞的測資,鎖住 idf 退化
- tools/convert/selfcheck.py、tools/selfcheck_lint.py:測資檔名改中文+空格
- AGENTS.md §13.4:新增「測資照現實建,不是照能過建」規則
- README.md §3.2:補 convert 產出應保持未提交、由 ingest 一併 commit 的流程

驗證:四個自檢全數通過;分別移除兩個修法後對應檢查會失敗。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-22 07:21:45 +08:00

101 lines
4.6 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Phase 5 自檢BM25 檢索排序、MCP search_wiki / read_page、
路徑跳脫防護信任邊界、token 上限截斷。
執行:.venv/Scripts/python tools/selfcheck_search.py
"""
import os
import pathlib
import shutil
import sys
import tempfile
sys.path.insert(0, str(pathlib.Path(__file__).parent))
import kb
import search
CFG = """ollama: {base_url: "http://localhost:11434", timeout_seconds: 5}
tasks:
ingest: {model: "fake:test", temperature: 0.2, max_retries: 2}
lint: {model: "fake:test", temperature: 0.1, max_retries: 2}
fallback: {model: "fake:fb"}
limits: {mcp_response_max_tokens: 100, ingest_chunk_max_chars: 12000}
"""
def page(root, rel, title, desc, tags, body):
meta = {"type": {"summaries": "summary", "entities": "entity",
"concepts": "concept"}[rel.split("/")[1]],
"title": title, "description": desc, "tags": tags,
"timestamp": "2026-07-14", "sources": ["raw/converted/x.md#abcd1234"],
"status": "draft"}
(root / rel).write_text(kb.dump_page(meta, body), encoding="utf-8")
def main():
tmp = pathlib.Path(tempfile.mkdtemp(prefix="ppqa-search-"))
try:
root = tmp / "repo"
for sub in ("config", "wiki/summaries", "wiki/entities", "wiki/concepts"):
(root / sub).mkdir(parents=True)
(root / "config/models.yaml").write_text(CFG, encoding="utf-8")
page(root, "wiki/entities/payment-gateway.md", "支付閘道", "支付閘道模組",
["gateway"], "壓力測試時出現逾時缺陷,重試機制未生效。")
page(root, "wiki/concepts/aml-testing.md", "AML 測試準則", "可疑交易監控測試綜合",
["aml", "compliance"], "大額交易與分散交易樣態的監控測試要求。")
page(root, "wiki/summaries/long-doc.md", "長文件摘要", "截斷測試用",
["test"], "逾時。" * 500)
# 與 payment-gateway 共用核心詞「支付閘道」→ df=2, N=4正是 BM25Okapi
# 原式 idf 歸零的配置;沒有這頁就測不出核心詞落空的 bug
page(root, "wiki/summaries/gateway-report.md", "支付閘道版本沿革", "支付閘道改版紀錄",
["gateway"], "支付閘道於 2026 年改版,新增分期付款。")
hits = search.search(root, "支付閘道 逾時")
assert hits and hits[0]["path"] == "wiki/entities/payment-gateway.md", hits
assert all(h["score"] > 0 for h in hits)
hits2 = search.search(root, "可疑交易監控")
assert hits2[0]["path"] == "wiki/concepts/aml-testing.md", hits2
assert search.search(root, "zzz-nonexistent-term") == []
print("PASS: BM25 檢索排序(繁中 bigram")
# 核心詞(出現在多頁)不得因 idf <= 0 被整批濾掉
core = search.search(root, "支付閘道")
paths = {h["path"] for h in core}
assert {"wiki/entities/payment-gateway.md",
"wiki/summaries/gateway-report.md"} <= paths, core
assert all(h["score"] > 0 for h in core), core
print("PASS: 核心詞跨頁共用仍可檢索idf 恆正)")
os.environ["PP_QA_ROOT"] = str(root)
import importlib
sys.path.insert(0, str(pathlib.Path(__file__).parents[1] / "mcp"))
server = importlib.import_module("server")
res = server.search_wiki("支付閘道 逾時", top_k=99) # top_k 超限 → 收斂為 10
assert res[0]["path"] == "wiki/entities/payment-gateway.md"
pg = server.read_page("wiki/entities/payment-gateway.md")
assert pg["frontmatter"]["title"] == "支付閘道" and not pg["truncated"]
assert pg["source_refs"] == ["raw/converted/x.md#abcd1234"]
long = server.read_page("wiki/summaries/long-doc.md")
assert long["truncated"] and len(long["body"]) <= 150 and long["source_refs"]
print("PASS: MCP search_wiki + read_page含 token 截斷、source_refs 保留)")
for bad in ("../AGENTS.md", "wiki/../config/models.yaml", "wiki/entities/x.txt"):
try:
server.read_page(bad)
raise AssertionError(f"應拒絕:{bad}")
except (ValueError, FileNotFoundError) as e:
assert isinstance(e, ValueError), f"{bad} 應為 ValueError"
try:
server.read_page("wiki/entities/no-such.md")
raise AssertionError("應拋 FileNotFoundError")
except FileNotFoundError:
pass
print("PASS: 路徑跳脫防護(信任邊界)")
print("ALL PASS")
finally:
os.environ.pop("PP_QA_ROOT", None)
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
main()