Phase 4: Lint — 五類健檢 + LLM 重複/矛盾偵測 + Gitea Actions 草稿 + selfcheck

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
LittleYellow
2026-07-14 21:01:37 +08:00
parent f9a863e271
commit 156c6db2f8
5 changed files with 358 additions and 0 deletions

View File

@@ -0,0 +1,31 @@
# kb-lint 定時健檢Phase 4 草稿,部署到 Gitea 時啟用)
#
# 部署前提ASSUMPTION部署環境未定以下依常見 self-hosted 配置假設):
# - self-hosted runner 與 Ollama 同機或內網可達(必要時以 OLLAMA_BASE_URL 覆寫,
# 這是唯一允許的設定覆寫來源AGENTS.md §1.2
# - runner 具 Python 3.11+文件內容全程不離開內網AGENTS.md §1.1
# - 報告以 artifact 上傳供人工下載審核lint 絕不修改 repo 內容
name: kb-lint
on:
schedule:
- cron: "0 21 * * 0" # UTC 週日 21:00 = 台北時間週一 05:00
workflow_dispatch: {}
jobs:
lint:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Set up venv
run: |
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
- name: Run lint
# 結束碼 1 = 有待人工核准項目 → workflow 標紅作為通知信號
run: .venv/bin/python tools/lint.py --output reports
- name: Upload report
if: always()
uses: actions/upload-artifact@v3
with:
name: lint-report
path: reports/

1
.gitignore vendored
View File

@@ -2,3 +2,4 @@
__pycache__/
*.pyc
.pytest_cache/
reports/

211
tools/lint.py Normal file
View File

@@ -0,0 +1,211 @@
"""wiki 健檢AGENTS.md §7schema 完整性、過期主張、覆蓋缺口、孤兒頁、
重複/矛盾頁偵測LLM。產出 markdown 報告;絕不修改或刪除任何檔案,
所有項目一律標記待人工核准。
用法python tools/lint.py [--output reports] [--no-llm] [--root DIR]
結束碼0 = 無發現1 = 有發現(供 CI 判斷)。
"""
import argparse
import datetime
import difflib
import itertools
import json
import pathlib
import re
import sys
sys.path.insert(0, str(pathlib.Path(__file__).parent))
import kb
# ponytail: 過期門檻寫死 180 天naive heuristic若各類頁面需要不同節奏
# 升級路徑:依 type/tags 分級門檻。
STALE_DAYS = 180
# ponytail: LLM 逐對比較上限 15 對,避免 O(n²) 呼叫爆量;升級路徑:以 embedding
# 預篩Phase 6 之後評估 EmbeddingGemma / snowflake-arctic-embed
MAX_LLM_PAIRS = 15
SIM_THRESHOLD = 0.6
PAIR_PROMPT = """判斷以下兩個 QA 知識庫 wiki 頁是否「重複」(描述同一事物,應合併)或「矛盾」(對同一事實給出不相容的主張)。只輸出 JSON
{{"relation": "duplicate""contradiction""none", "explanation": "一句話理由(繁體中文)"}}
頁 A{a_title}{a_desc}
內文節錄:
<<<
{a_body}
>>>
頁 B{b_title}{b_desc}
內文節錄:
<<<
{b_body}
>>>"""
def validate_pair(obj):
if obj.get("relation") not in ("duplicate", "contradiction", "none"):
return "relation 須為 duplicate|contradiction|none"
if not isinstance(obj.get("explanation"), str):
return "explanation 須為字串"
return None
def load_pages(root, findings):
pages = []
for p in kb.iter_pages(root):
rel = p.relative_to(root).as_posix()
try:
meta, body = kb.parse_page(p.read_text(encoding="utf-8"))
pages.append((rel, meta, body))
except Exception as e:
findings.append(("schema", rel, f"無法解析:{e}"))
return pages
def check_schema(pages, manifest, findings):
for rel, meta, _ in pages:
for err in kb.validate_meta(meta):
findings.append(("schema", rel, err))
for s in meta.get("sources") or []:
path, _, sha8 = str(s).rpartition("#")
entry = manifest["files"].get(path)
if entry is None:
findings.append(("schema", rel, f"source_ref 不在 manifest{s}"))
elif not entry["sha256"].startswith(sha8):
findings.append(("schema", rel, f"source_ref hash 與 manifest 不符:{s}"))
def check_stale(pages, findings):
today = datetime.date.today()
for rel, meta, _ in pages:
try:
ts = datetime.date.fromisoformat(str(meta.get("timestamp")))
except ValueError:
continue # schema 檢查已報
age = (today - ts).days
if meta.get("status") == "stale":
findings.append(("stale", rel, "已標記 stale待人工複審或更新"))
elif age > STALE_DAYS:
findings.append(("stale", rel, f"最後更新 {age} 天前,建議人工複審後標記 stale"))
def check_coverage(pages, manifest, findings):
referenced = {str(s).rsplit("#", 1)[-1]
for _, meta, _ in pages for s in meta.get("sources") or []}
for path, e in manifest["files"].items():
if e["kind"] == "converted" and e["sha256"][:8] not in referenced:
findings.append(("coverage", path, "已轉換但沒有任何 wiki 頁引用(攝入缺口)"))
if e["kind"] == "original" and e.get("status") == "needs_ocr":
findings.append(("coverage", path, "needs_ocr掃描件待人工決定 OCR 方案"))
def check_orphans(root, pages, findings):
# ponytail: 以「檔名出現在任何連結目標中」判斷被連結,不解析相對路徑;
# 升級路徑:正規化解析所有 markdown 連結。
link_targets = ""
idx = root / "index.md"
if idx.is_file():
link_targets += idx.read_text(encoding="utf-8")
for _, _, body in pages:
link_targets += body
linked = set(re.findall(r"\(([^)]+\.md)\)", link_targets))
linked_names = {pathlib.PurePosixPath(t).name for t in linked}
for rel, _, _ in pages:
if pathlib.PurePosixPath(rel).name not in linked_names:
findings.append(("orphan", rel, "不被 index.md 或任何其他頁連結"))
def candidate_pairs(pages):
cands = {}
for (ra, ma, ba), (rb, mb, bb) in itertools.combinations(pages, 2):
sim = difflib.SequenceMatcher(
None, f"{ma['title']} {ma['description']}",
f"{mb['title']} {mb['description']}").ratio()
shared_tags = len(set(ma.get("tags") or []) & set(mb.get("tags") or []))
if sim >= SIM_THRESHOLD or shared_tags >= 2:
cands[(ra, rb)] = (sim, (ma, ba), (mb, bb))
ranked = sorted(cands.items(), key=lambda kv: -kv[1][0])
return ranked[:MAX_LLM_PAIRS]
def check_pairs_llm(cfg, pages, findings):
for (ra, rb), (sim, (ma, ba), (mb, bb)) in candidate_pairs(pages):
try:
obj, _ = kb.llm_json(cfg, "lint", PAIR_PROMPT.format(
a_title=ma["title"], a_desc=ma["description"], a_body=ba[:1500],
b_title=mb["title"], b_desc=mb["description"], b_body=bb[:1500]),
validate_pair)
except RuntimeError as e:
findings.append(("llm-pair", f"{ra}{rb}", f"LLM 比對失敗:{e}"))
continue
if obj["relation"] == "duplicate":
findings.append(("duplicate", f"{ra}{rb}", f"疑似重複:{obj['explanation']}"))
elif obj["relation"] == "contradiction":
findings.append(("contradiction", f"{ra}{rb}", f"疑似矛盾:{obj['explanation']}"))
SECTION_TITLES = {
"schema": "Schema 完整性", "stale": "過期主張staleness",
"coverage": "覆蓋缺口", "orphan": "孤兒頁",
"duplicate": "重複頁LLM 判定)", "contradiction": "矛盾頁LLM 判定)",
"llm-pair": "LLM 比對失敗(需重跑)",
}
def write_report(out_dir, findings, n_pages, llm_note):
now = datetime.datetime.now()
out_dir.mkdir(parents=True, exist_ok=True)
path = out_dir / f"lint-{now.strftime('%Y%m%d-%H%M%S')}.md"
counts = {}
for check, _, _ in findings:
counts[check] = counts.get(check, 0) + 1
lines = [f"# Lint 報告 — {now.strftime('%Y-%m-%d %H:%M')}", "",
f"- 檢查頁面數:{n_pages}",
f"- 發現項目:{len(findings)}" + "".join(
f"{SECTION_TITLES[k]} {v}" for k, v in counts.items()) + ""
if findings else "- 發現項目0",
f"- LLM 比對:{llm_note}", "",
"> lint 絕不修改或刪除任何檔案;以下所有項目皆**待人工核准**後才處置。", ""]
for check in SECTION_TITLES:
items = [(w, msg) for c, w, msg in findings if c == check]
if not items:
continue
lines += [f"## {SECTION_TITLES[check]}", ""]
lines += [f"- [ ] `{w}` — {msg}" for w, msg in items]
lines.append("")
path.write_text("\n".join(lines), encoding="utf-8")
return path
def main(argv=None):
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--output", default="reports")
ap.add_argument("--no-llm", action="store_true", help="跳過重複/矛盾的 LLM 比對")
ap.add_argument("--root", default=str(kb.ROOT))
a = ap.parse_args(argv)
root = pathlib.Path(a.root).resolve()
cfg = kb.load_config(root)
manifest = json.loads((root / "raw" / "manifest.json").read_text(encoding="utf-8"))
findings = []
pages = load_pages(root, findings)
check_schema(pages, manifest, findings)
check_stale(pages, findings)
check_coverage(pages, manifest, findings)
check_orphans(root, pages, findings)
if a.no_llm:
llm_note = "已略過(--no-llm"
else:
llm_note = f"model={cfg['tasks']['lint']['model']},上限 {MAX_LLM_PAIRS}"
check_pairs_llm(cfg, pages, findings)
out = pathlib.Path(a.output)
report = write_report(out if out.is_absolute() else root / out,
findings, len(pages), llm_note)
print(f"報告:{report}")
print(f"發現 {len(findings)}" if findings else "無發現")
if findings:
raise SystemExit(1)
if __name__ == "__main__":
main()

115
tools/selfcheck_lint.py Normal file
View File

@@ -0,0 +1,115 @@
"""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
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")
(root / "raw/manifest.json").write_text(json.dumps({"version": 1, "files": {
"raw/converted/good-src.md": {"kind": "converted", "sha256": SHA_A,
"original_path": "raw/originals/g.docx",
"original_sha256": SHA_A, "converter": "from_docx",
"converted_at": "2026-07-01T00:00:00"},
"raw/converted/uncovered.md": {"kind": "converted", "sha256": SHA_B,
"original_path": "raw/originals/u.docx",
"original_sha256": SHA_B, "converter": "from_docx",
"converted_at": "2026-07-01T00:00:00"},
"raw/originals/scan.pdf": {"kind": "original", "sha256": SHA_B,
"media_type": "pdf", "added_at": "2026-07-01T00:00:00",
"status": "needs_ocr"},
}}), encoding="utf-8")
ref = f"raw/converted/good-src.md#{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", "建議人工複審", "uncovered.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()