Phase 4: Lint — 五類健檢 + LLM 重複/矛盾偵測 + Gitea Actions 草稿 + selfcheck
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
31
.gitea/workflows/lint.yaml
Normal file
31
.gitea/workflows/lint.yaml
Normal 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
1
.gitignore
vendored
@@ -2,3 +2,4 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
.pytest_cache/
|
.pytest_cache/
|
||||||
|
reports/
|
||||||
|
|||||||
211
tools/lint.py
Normal file
211
tools/lint.py
Normal file
@@ -0,0 +1,211 @@
|
|||||||
|
"""wiki 健檢(AGENTS.md §7):schema 完整性、過期主張、覆蓋缺口、孤兒頁、
|
||||||
|
重複/矛盾頁偵測(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
115
tools/selfcheck_lint.py
Normal 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()
|
||||||
Reference in New Issue
Block a user