Files
pp-qa-km/tools/selfcheck_lint.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

120 lines
5.5 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 4 自檢沙盒植入各類違規schema 缺欄位、壞 source_ref、過期頁、
攝入缺口、needs_ocr、孤兒頁、相似頁對驗證 lint 全部抓到、產出報告、
且不修改任何檔案。LLM 比對以假 LLM 驗證矛盾偵測路徑。
執行:.venv/Scripts/python tools/selfcheck_lint.py
"""
import json
import pathlib
import shutil
import sys
import tempfile
sys.path.insert(0, str(pathlib.Path(__file__).parent))
import kb
import lint
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: 2000, ingest_chunk_max_chars: 12000}
"""
SHA_A = "abcd1234" + "0" * 56
SHA_B = "beef5678" + "0" * 56
SRC_OK = "raw/converted/好來源 報告.md"
SRC_UNCOVERED = "raw/converted/未覆蓋 來源.md"
def page(root, rel, meta, body="內文。"):
p = root / rel
p.write_text(kb.dump_page(meta, body), encoding="utf-8")
def build(root):
import datetime
today = datetime.date.today().isoformat()
for sub in ("config", "raw/converted", "wiki/summaries", "wiki/entities", "wiki/concepts"):
(root / sub).mkdir(parents=True)
(root / "config/models.yaml").write_text(CFG, encoding="utf-8")
# 來源檔名一律「中文 + 空格」——真實 QA 文件就長這樣§12測資要照現實建不是照能過建
(root / "raw/manifest.json").write_text(json.dumps({"version": 1, "files": {
SRC_OK: {"kind": "converted", "sha256": SHA_A,
"original_path": "raw/originals/好來源 報告.docx",
"original_sha256": SHA_A, "converter": "from_docx",
"converted_at": "2026-07-01T00:00:00"},
SRC_UNCOVERED: {"kind": "converted", "sha256": SHA_B,
"original_path": "raw/originals/未覆蓋 來源.docx",
"original_sha256": SHA_B, "converter": "from_docx",
"converted_at": "2026-07-01T00:00:00"},
"raw/originals/掃描件 無文字層.pdf": {"kind": "original", "sha256": SHA_B,
"media_type": "pdf",
"added_at": "2026-07-01T00:00:00",
"status": "needs_ocr"},
}}), encoding="utf-8")
ref = f"{SRC_OK}#{SHA_A[:8]}"
good = {"type": "entity", "title": "支付閘道", "description": "支付閘道模組",
"tags": ["gateway"], "timestamp": today, "sources": [ref], "status": "draft"}
page(root, "wiki/entities/good.md", good)
bad = {"type": "entity", "title": "壞頁", "description": "缺 tags",
"timestamp": today, "sources": ["raw/converted/ghost.md#deadbeef"],
"status": "draft", "type": "entity"}
page(root, "wiki/entities/bad-schema.md", bad)
old = dict(good, title="舊概念", type="concept", timestamp="2024-01-01")
page(root, "wiki/concepts/old.md", old)
dup_a = dict(good, title="支付閘道逾時缺陷", tags=["gateway", "timeout"],
description="逾時缺陷模式")
dup_b = dict(good, title="支付閘道逾時缺陷模式", tags=["gateway", "timeout"],
description="逾時的缺陷模式")
page(root, "wiki/entities/dup-a.md", dup_a, "重試機制在峰值失效。")
page(root, "wiki/entities/dup-b.md", dup_b, "重試機制在峰值一律生效。")
(root / "index.md").write_text(
"- [支付閘道](wiki/entities/good.md)\n- [舊概念](wiki/concepts/old.md)\n"
"- [A](wiki/entities/dup-a.md)\n- [B](wiki/entities/dup-b.md)\n", encoding="utf-8")
def run_lint(args):
try:
lint.main(args)
return 0
except SystemExit as e:
return e.code
def main():
tmp = pathlib.Path(tempfile.mkdtemp(prefix="ppqa-lint-"))
real = kb.llm_json
try:
root = tmp / "repo"
build(root)
before = {p: p.read_bytes() for p in root.rglob("*.md")}
code = run_lint(["--no-llm", "--root", str(root)])
assert code == 1, code
report = next((root / "reports").glob("lint-*.md")).read_text(encoding="utf-8")
for expected in ("缺欄位 tags", "source_ref 不在 manifest",
"old.md", "建議人工複審", "未覆蓋 來源.md", "攝入缺口",
"needs_ocr", "bad-schema.md` — 不被 index.md"):
assert expected in report, f"報告缺少:{expected}\n{report}"
assert "good.md" not in report
after = {p: p.read_bytes() for p in root.rglob("*.md") if "reports" not in str(p)}
assert all(before[p] == after[p] for p in after), "lint 修改了檔案!"
print("PASS: schema/stale/coverage/orphan 全抓到,報告產出,檔案零修改")
kb.llm_json = lambda cfg, task, prompt, validate: (
{"relation": "contradiction", "explanation": "重試機制生效與否說法相反"}, "fake:test")
code = run_lint(["--root", str(root)])
assert code == 1
report = sorted((root / "reports").glob("lint-*.md"))[-1].read_text(encoding="utf-8")
assert "疑似矛盾" in report and "dup-a.md ↔ wiki/entities/dup-b.md" in report, report
print("PASS: LLM 矛盾頁偵測Phase 6 成功標準的機制驗證)")
print("ALL PASS")
finally:
kb.llm_json = real
shutil.rmtree(tmp, ignore_errors=True)
if __name__ == "__main__":
main()