feat: diag_refs 對帳診斷 + ingest 進度可見性/Ctrl-C 安全 (Closes #2) #5
14
README.md
14
README.md
@@ -126,6 +126,16 @@ ollama list # 對照 tasks.ingest.model / tasks.lint.model / tasks.fallba
|
|||||||
- **結束碼**:`0` = 無發現;`1` = 有待人工核准項目(供 CI 判斷 / 標紅通知)。
|
- **結束碼**:`0` = 無發現;`1` = 有待人工核准項目(供 CI 判斷 / 標紅通知)。
|
||||||
- 排程:[.gitea/workflows/lint.yaml](.gitea/workflows/lint.yaml)(每週一台北時間 05:00,草稿,部署 Gitea 時啟用)。
|
- 排程:[.gitea/workflows/lint.yaml](.gitea/workflows/lint.yaml)(每週一台北時間 05:00,草稿,部署 Gitea 時啟用)。
|
||||||
|
|
||||||
|
> **`source_ref 不在 manifest` 的成因分類**:lint 只報「對不上」,不分辨成因。跑
|
||||||
|
> [tools/diag_refs.py](tools/diag_refs.py) 把每個對不上的 source_ref 分成五類(hash 不符 /
|
||||||
|
> 路徑字串岔開 / 檔名 hash 後綴岔開 / 檔在磁碟未登記 / 完全無對應),各附修法——
|
||||||
|
> 「補帳本」(§14.3)與「修規則」(§12)的處置相反,先分類再動手。純唯讀不改任何檔,
|
||||||
|
> 結束碼同 lint(`0` 全部解得開 / `1` 有對不上),可當搬機或改 manifest 後的閘門。
|
||||||
|
>
|
||||||
|
> ```powershell
|
||||||
|
> .venv\Scripts\python tools\diag_refs.py
|
||||||
|
> ```
|
||||||
|
|
||||||
### 3.4 查詢 `search` — BM25 檢索
|
### 3.4 查詢 `search` — BM25 檢索
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -180,7 +190,7 @@ wiki/entities/ # 實體頁(系統/模組/API/法規/專案)
|
|||||||
wiki/concepts/ # 概念頁(跨來源綜合)
|
wiki/concepts/ # 概念頁(跨來源綜合)
|
||||||
index.md # 目錄式導航(由 ingest 自動重建)
|
index.md # 目錄式導航(由 ingest 自動重建)
|
||||||
log.md # append-only 操作日誌
|
log.md # append-only 操作日誌
|
||||||
tools/ # convert/ ingest.py lint.py search.py kb.py(共用模組)
|
tools/ # convert/ ingest.py lint.py diag_refs.py search.py kb.py(共用模組)
|
||||||
mcp/server.py # MCP 薄殼
|
mcp/server.py # MCP 薄殼
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -197,6 +207,7 @@ mcp/server.py # MCP 薄殼
|
|||||||
| `須在 main 分支執行` | 攝入前先切回 `main`。 |
|
| `須在 main 分支執行` | 攝入前先切回 `main`。 |
|
||||||
| `工作區有 raw/ 以外的未提交變更` | 先 commit / stash 非 `raw/` 的變更再攝入。 |
|
| `工作區有 raw/ 以外的未提交變更` | 先 commit / stash 非 `raw/` 的變更再攝入。 |
|
||||||
| `... 不在 manifest 的 converted 條目中` | 該來源尚未轉換;先跑 `convert.py`。 |
|
| `... 不在 manifest 的 converted 條目中` | 該來源尚未轉換;先跑 `convert.py`。 |
|
||||||
|
| lint 報 `source_ref 不在 manifest` | 跑 `tools/diag_refs.py` 分類成因:帳本掉條目→補登走 PR(§14.3);路徑字串岔開→修規則(§12),別逐頁改。 |
|
||||||
| `LLM 輸出驗證失敗(含 fallback)` | 本地模型連主模型 + fallback 都無法產出合規 JSON;檢查 Ollama 是否在線、模型是否拉好。 |
|
| `LLM 輸出驗證失敗(含 fallback)` | 本地模型連主模型 + fallback 都無法產出合規 JSON;檢查 Ollama 是否在線、模型是否拉好。 |
|
||||||
| `push 失敗(無 remote 或離線)` | 正常;分支已保留本地,手動 push 後開 PR。 |
|
| `push 失敗(無 remote 或離線)` | 正常;分支已保留本地,手動 push 後開 PR。 |
|
||||||
|
|
||||||
@@ -210,5 +221,6 @@ mcp/server.py # MCP 薄殼
|
|||||||
.venv\Scripts\python tools\convert\selfcheck.py
|
.venv\Scripts\python tools\convert\selfcheck.py
|
||||||
.venv\Scripts\python tools\selfcheck_ingest.py
|
.venv\Scripts\python tools\selfcheck_ingest.py
|
||||||
.venv\Scripts\python tools\selfcheck_lint.py
|
.venv\Scripts\python tools\selfcheck_lint.py
|
||||||
|
.venv\Scripts\python tools\selfcheck_diag_refs.py
|
||||||
.venv\Scripts\python tools\selfcheck_search.py
|
.venv\Scripts\python tools\selfcheck_search.py
|
||||||
```
|
```
|
||||||
|
|||||||
134
tools/diag_refs.py
Normal file
134
tools/diag_refs.py
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
"""source_ref ↔ manifest 對帳診斷(AGENTS.md §7 schema / §14 對帳的輔助工具)。
|
||||||
|
|
||||||
|
lint.py 對「source_ref 不在 manifest」只報一句,無法區分成因;本工具把每個
|
||||||
|
對不上的 source_ref 分類成可據以修復的型別(見 CAT_TITLE / FIX),讓「帳本掉
|
||||||
|
條目」「路徑字串岔開」「檔名 hash 後綴岔開」「檔真的遺失」彼此不再混為一談。
|
||||||
|
|
||||||
|
純唯讀——不改任何檔(含 manifest),有對不上以結束碼 1 表示(可當搬機/改
|
||||||
|
manifest 後的閘門)。分類邏輯與 lint 一致:以最後一個 '#' 切出 path 與 sha8。
|
||||||
|
|
||||||
|
用法:python tools/diag_refs.py [--root DIR]
|
||||||
|
結束碼:0 = 全部解得開,1 = 有對不上。
|
||||||
|
"""
|
||||||
|
import argparse
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).parent))
|
||||||
|
import kb
|
||||||
|
|
||||||
|
CAT_TITLE = {
|
||||||
|
"hash-mismatch": "Hash 不符(帳本有此路徑,但 sha256 與 ref 不同)",
|
||||||
|
"normalize": "路徑字串岔開(正規化後與帳本 key 相同:斜線/前綴/大小寫)",
|
||||||
|
"stem": "檔名 hash 後綴岔開(帳本有同檔名幹、hash 後綴不同的 key)",
|
||||||
|
"unregistered": "檔在磁碟、帳本無此條目",
|
||||||
|
"absent": "完全無對應(帳本、磁碟、檔名幹皆無)",
|
||||||
|
}
|
||||||
|
FIX = {
|
||||||
|
"hash-mismatch": "raw 被就地改動或重轉(違反 §1.4);還原檔案,或連同 source_ref 走 PR(§14.2)。",
|
||||||
|
"normalize": "修產生岔開字串的那一個環節(路徑正規化),別逐頁改 source_ref(§12 change the ruler)。",
|
||||||
|
"stem": "source_ref 指到不同 hash 後綴的檔名;核對正確檔名,別改帳本去遷就(§14.2)。",
|
||||||
|
"unregistered": "帳本掉了條目、檔還在;走 branch+PR 補登 manifest(§14.3),別動 wiki 頁。",
|
||||||
|
"absent": "source_ref 可能攝入時寫歪,或原始檔真的遺失;上報 HITL 據實記錄(§14.2)。",
|
||||||
|
}
|
||||||
|
# 分類輸出順序(愈可機械修復的排愈前)
|
||||||
|
ORDER = ["hash-mismatch", "normalize", "stem", "unregistered", "absent"]
|
||||||
|
|
||||||
|
|
||||||
|
def _norm(path):
|
||||||
|
"""正規化路徑字串以偵測「同一檔、不同寫法」:反斜線→斜線、去開頭 ./、轉小寫。"""
|
||||||
|
s = path.replace("\\", "/")
|
||||||
|
if s.startswith("./"):
|
||||||
|
s = s[2:]
|
||||||
|
return s.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def _stem(path):
|
||||||
|
"""取檔名幹:去目錄、去 .md、去結尾的 -<8 碼 hex>(convert 的檔名 hash 後綴)。"""
|
||||||
|
name = path.replace("\\", "/").rsplit("/", 1)[-1]
|
||||||
|
name = re.sub(r"\.md$", "", name, flags=re.I)
|
||||||
|
return re.sub(r"-[0-9a-f]{8}$", "", name, flags=re.I).lower()
|
||||||
|
|
||||||
|
|
||||||
|
def classify_ref(source_ref, files, exists):
|
||||||
|
"""把單一 source_ref 分類。files:manifest['files'](path→entry);
|
||||||
|
exists(path)→bool 判磁碟是否有該檔。回傳 (category, detail)。
|
||||||
|
category 為 "resolved" 或 CAT_TITLE 的任一鍵。"""
|
||||||
|
path, _, sha8 = str(source_ref).rpartition("#")
|
||||||
|
entry = files.get(path)
|
||||||
|
if entry is not None:
|
||||||
|
if entry["sha256"].startswith(sha8):
|
||||||
|
return "resolved", path
|
||||||
|
return "hash-mismatch", f"帳本 sha256={entry['sha256'][:8]} ≠ ref #{sha8}"
|
||||||
|
norm = _norm(path)
|
||||||
|
for k in files:
|
||||||
|
if _norm(k) == norm:
|
||||||
|
return "normalize", f"帳本 key = '{k}'"
|
||||||
|
stem = _stem(path)
|
||||||
|
for k, e in files.items():
|
||||||
|
if e.get("kind") == "converted" and _stem(k) == stem:
|
||||||
|
return "stem", f"帳本 key = '{k}'"
|
||||||
|
if exists(path):
|
||||||
|
return "unregistered", f"磁碟有 '{path}',帳本無此 key"
|
||||||
|
return "absent", f"'{path}' 帳本無、磁碟無、無同名幹 key"
|
||||||
|
|
||||||
|
|
||||||
|
def collect(root, files, exists):
|
||||||
|
"""掃全庫 wiki 頁,回傳 (findings, n_refs)。findings:category→[(page, ref, detail)]。"""
|
||||||
|
findings = {c: [] for c in ORDER}
|
||||||
|
n_refs = 0
|
||||||
|
for p in kb.iter_pages(root):
|
||||||
|
rel = p.relative_to(root).as_posix()
|
||||||
|
try:
|
||||||
|
meta, _ = kb.parse_page(p.read_text(encoding="utf-8"))
|
||||||
|
except Exception as e:
|
||||||
|
findings["absent"].append((rel, "(無法解析頁面)", str(e)))
|
||||||
|
continue
|
||||||
|
for s in meta.get("sources") or []:
|
||||||
|
n_refs += 1
|
||||||
|
cat, detail = classify_ref(s, files, exists)
|
||||||
|
if cat != "resolved":
|
||||||
|
findings[cat].append((rel, str(s), detail))
|
||||||
|
return findings, n_refs
|
||||||
|
|
||||||
|
|
||||||
|
def format_report(findings, n_refs):
|
||||||
|
total = sum(len(v) for v in findings.values())
|
||||||
|
lines = [f"檢查 source_ref:{n_refs},對不上:{total}", ""]
|
||||||
|
if total == 0:
|
||||||
|
lines.append("全部解得開(source_ref 與 manifest 一致)。")
|
||||||
|
return "\n".join(lines)
|
||||||
|
for cat in ORDER:
|
||||||
|
items = findings[cat]
|
||||||
|
if not items:
|
||||||
|
continue
|
||||||
|
lines.append(f"## {CAT_TITLE[cat]}({len(items)})")
|
||||||
|
lines.append(f"→ 修法:{FIX[cat]}")
|
||||||
|
for page, ref, detail in items:
|
||||||
|
lines.append(f" - [{page}] {ref}")
|
||||||
|
lines.append(f" {detail}")
|
||||||
|
lines.append("")
|
||||||
|
return "\n".join(lines)
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv=None):
|
||||||
|
ap = argparse.ArgumentParser(description=__doc__)
|
||||||
|
ap.add_argument("--root", default=str(kb.ROOT))
|
||||||
|
a = ap.parse_args(argv)
|
||||||
|
root = pathlib.Path(a.root).resolve()
|
||||||
|
mpath = root / "raw" / "manifest.json"
|
||||||
|
try:
|
||||||
|
files = json.loads(mpath.read_text(encoding="utf-8"))["files"]
|
||||||
|
except (OSError, KeyError, json.JSONDecodeError) as e:
|
||||||
|
raise SystemExit(f"讀不到 / 解析不了 manifest:{mpath}({e})")
|
||||||
|
|
||||||
|
findings, n_refs = collect(root, files, lambda p: (root / p).is_file())
|
||||||
|
print(format_report(findings, n_refs))
|
||||||
|
if sum(len(v) for v in findings.values()):
|
||||||
|
raise SystemExit(1)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
114
tools/selfcheck_diag_refs.py
Normal file
114
tools/selfcheck_diag_refs.py
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
"""Phase 4 自檢:沙盒植入五種 source_ref 岔開型別(hash 不符、路徑正規化、
|
||||||
|
檔名 hash 後綴岔開、檔在磁碟未登記、完全無對應)+一個正常解得開的 ref,
|
||||||
|
驗證 diag_refs 逐一分類正確、報告涵蓋各型別、結束碼 1、且不修改任何檔案。
|
||||||
|
另驗全部解得開時結束碼 0。
|
||||||
|
|
||||||
|
執行:.venv/Scripts/python tools/selfcheck_diag_refs.py
|
||||||
|
"""
|
||||||
|
import io
|
||||||
|
import json
|
||||||
|
import pathlib
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
from contextlib import redirect_stdout
|
||||||
|
|
||||||
|
sys.path.insert(0, str(pathlib.Path(__file__).parent))
|
||||||
|
import kb
|
||||||
|
import diag_refs
|
||||||
|
|
||||||
|
SHA_A = "abcd1234" + "0" * 56
|
||||||
|
SHA_B = "beef5678" + "0" * 56
|
||||||
|
# 帳本 key 一律 posix 正斜線+中文/連字號檔名(真實 convert 產出的形狀)
|
||||||
|
K_OK = "raw/converted/www-pluspay-faq-06b02fd9.md"
|
||||||
|
K_STEM = "raw/converted/www-pluspay-terms-11112222.md" # 同幹、不同 hash 後綴
|
||||||
|
|
||||||
|
FILES = {
|
||||||
|
K_OK: {"kind": "converted", "sha256": SHA_A, "original_path": "raw/originals/faq.html",
|
||||||
|
"original_sha256": SHA_A, "converter": "from_web", "converted_at": "2026-07-01T00:00:00"},
|
||||||
|
K_STEM: {"kind": "converted", "sha256": SHA_B, "original_path": "raw/originals/terms.html",
|
||||||
|
"original_sha256": SHA_B, "converter": "from_web", "converted_at": "2026-07-01T00:00:00"},
|
||||||
|
}
|
||||||
|
|
||||||
|
# ref → 預期分類。unregistered 的檔會實際落地;absent 的不落地。
|
||||||
|
CASES = {
|
||||||
|
"resolved": f"{K_OK}#abcd1234",
|
||||||
|
"hash-mismatch": f"{K_OK}#deadbeef",
|
||||||
|
"normalize": r"raw\converted\www-pluspay-faq-06b02fd9.md#abcd1234", # 反斜線
|
||||||
|
"stem": "raw/converted/www-pluspay-terms-99998888.md#cccccccc", # 同幹、異後綴、非 key
|
||||||
|
"unregistered": "raw/converted/www-pluspay-instructions-77776666.md#77777777",
|
||||||
|
"absent": "raw/converted/www-pluspay-ghost-00001111.md#cafebabe",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def unit():
|
||||||
|
"""classify_ref 純函式逐型別驗證。"""
|
||||||
|
on_disk = {"raw/converted/www-pluspay-instructions-77776666.md"}
|
||||||
|
exists = lambda p: p in on_disk
|
||||||
|
for expect, ref in CASES.items():
|
||||||
|
cat, detail = diag_refs.classify_ref(ref, FILES, exists)
|
||||||
|
assert cat == expect, f"{ref!r} 應分類為 {expect},實得 {cat}({detail})"
|
||||||
|
print("PASS: classify_ref 六型別(含 resolved)分類正確")
|
||||||
|
|
||||||
|
|
||||||
|
def build(root):
|
||||||
|
for sub in ("raw/converted", "wiki/summaries", "wiki/entities", "wiki/concepts"):
|
||||||
|
(root / sub).mkdir(parents=True)
|
||||||
|
(root / "raw/manifest.json").write_text(
|
||||||
|
json.dumps({"version": 1, "files": FILES}), encoding="utf-8")
|
||||||
|
# unregistered 的檔要真的存在於磁碟,才會被判為「檔在、帳本無」而非 absent
|
||||||
|
(root / "raw/converted/www-pluspay-instructions-77776666.md").write_text(
|
||||||
|
"# 未登記\n", encoding="utf-8")
|
||||||
|
today = "2026-07-01"
|
||||||
|
for i, (name, ref) in enumerate(CASES.items()):
|
||||||
|
meta = {"type": "entity", "title": f"頁{name}", "description": "測試頁",
|
||||||
|
"tags": ["t"], "timestamp": today, "sources": [ref], "status": "draft"}
|
||||||
|
(root / f"wiki/entities/p{i}.md").write_text(kb.dump_page(meta, "內文。"), encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
def run(argv):
|
||||||
|
buf = io.StringIO()
|
||||||
|
code = 0
|
||||||
|
try:
|
||||||
|
with redirect_stdout(buf):
|
||||||
|
diag_refs.main(argv)
|
||||||
|
except SystemExit as e:
|
||||||
|
code = e.code
|
||||||
|
return code, buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
unit()
|
||||||
|
tmp = pathlib.Path(tempfile.mkdtemp(prefix="ppqa-diag-"))
|
||||||
|
try:
|
||||||
|
root = tmp / "repo"
|
||||||
|
build(root)
|
||||||
|
before = {p: p.read_bytes() for p in root.rglob("*")
|
||||||
|
if p.is_file()}
|
||||||
|
|
||||||
|
code, out = run(["--root", str(root)])
|
||||||
|
assert code == 1, f"有岔開應以結束碼 1,實得 {code}\n{out}"
|
||||||
|
for cat in ("hash-mismatch", "normalize", "stem", "unregistered", "absent"):
|
||||||
|
assert diag_refs.CAT_TITLE[cat].split("(")[0] in out, f"報告缺型別 {cat}\n{out}"
|
||||||
|
assert "對不上:5" in out, out
|
||||||
|
after = {p: p.read_bytes() for p in root.rglob("*") if p.is_file()}
|
||||||
|
assert before == after, "diag_refs 修改了檔案!"
|
||||||
|
print("PASS: 整合——五型別全報、結束碼 1、零寫檔")
|
||||||
|
|
||||||
|
# 全部解得開 → 結束碼 0
|
||||||
|
good = {"type": "entity", "title": "好頁", "description": "d", "tags": ["t"],
|
||||||
|
"timestamp": "2026-07-01", "sources": [f"{K_OK}#abcd1234"], "status": "draft"}
|
||||||
|
for p in (root / "wiki/entities").glob("*.md"):
|
||||||
|
p.unlink()
|
||||||
|
(root / "wiki/entities/ok.md").write_text(kb.dump_page(good, "內文。"), encoding="utf-8")
|
||||||
|
code, out = run(["--root", str(root)])
|
||||||
|
assert code == 0, f"全部解得開應以結束碼 0,實得 {code}\n{out}"
|
||||||
|
assert "全部解得開" in out, out
|
||||||
|
print("PASS: 全部解得開時結束碼 0")
|
||||||
|
print("ALL PASS")
|
||||||
|
finally:
|
||||||
|
shutil.rmtree(tmp, ignore_errors=True)
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
Reference in New Issue
Block a user