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