兩個 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>
101 lines
4.6 KiB
Python
101 lines
4.6 KiB
Python
"""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()
|