#!/usr/bin/env python3 """ XKB Pipeline Health Check 檢查五件事: 1. wiki 單一真實來源:兩個路徑是否指向同一位置(symlink 正確) 2. recall 命中的 wiki 來源路徑是否正確 3. recall 端到端是否真的跑得起來(不是只看檔案在不在) 4. recall telemetry 是否還在寫入(太久沒紀錄 = 靜默故障) 5. cron 執行後 summary / vector 是否真的有增量更新 Usage: python3 scripts/health_check_pipeline.py python3 scripts/health_check_pipeline.py --json """ from __future__ import annotations import argparse import json import os import re import shutil import sys from pathlib import Path from datetime import datetime, timedelta, timezone sys.path.insert(0, str(Path(__file__).resolve().parent)) import xkb_paths WORKSPACE = xkb_paths.WORKSPACE _SKILL_DIR = xkb_paths.SKILL_DIR WIKI_DIR = xkb_paths.WIKI_DIR WIKI_TOPICS_DIR = xkb_paths.WIKI_TOPICS_DIR BOOKMARKS_DIR = xkb_paths.BOOKMARKS_DIR INDEX_FILE = xkb_paths.INDEX_FILE VECTOR_FILE = xkb_paths.VECTOR_FILE CARDS_DIR = xkb_paths.CARDS_DIR WORKSPACE_WIKI = WORKSPACE / "wiki" # 若存在,必須指向 WIKI_DIR OK = "✅" WARN = "⚠️ " FAIL = "❌" def check_wiki_canonical() -> dict: """檢查 workspace/wiki 是否是指向 skill wiki 的 symlink。""" result = {"name": "wiki_canonical", "checks": []} # 1. skill wiki 是否存在 if WIKI_DIR.exists(): result["checks"].append({"ok": True, "msg": f"Skill wiki exists: {WIKI_DIR}"}) else: result["checks"].append({"ok": False, "msg": f"Skill wiki MISSING: {WIKI_DIR}"}) return result # 2. workspace/wiki 是否是 symlink if WORKSPACE_WIKI.is_symlink(): target = Path(os.readlink(WORKSPACE_WIKI)) if not target.is_absolute(): target = (WORKSPACE_WIKI.parent / target).resolve() canonical = WIKI_DIR.resolve() if target.resolve() == canonical: result["checks"].append({"ok": True, "msg": f"workspace/wiki → {WIKI_DIR} (symlink correct)"}) else: result["checks"].append({"ok": False, "msg": f"workspace/wiki symlink points to {target}, expected {canonical}"}) elif WORKSPACE_WIKI.exists(): # Windows junction / bind mount 不會被 is_symlink() 認出來,但解析後仍是同一個地方 if WORKSPACE_WIKI.resolve() == WIKI_DIR.resolve(): result["checks"].append({"ok": True, "msg": f"workspace/wiki → {WIKI_DIR} (link correct)"}) else: result["checks"].append({"ok": False, "msg": "workspace/wiki is a real directory (not symlink) — dual-wiki risk!"}) else: # 這個 symlink 是 VPS 上 OpenClaw 的擺法,不是 XKB 的必要條件。 # 沒有它就沒有第二份 wiki,也就沒有這一項要防的風險。 result["checks"].append({"ok": True, "msg": "workspace/wiki absent — no dual-wiki risk"}) # 3. topic 數量 topics = list(WIKI_TOPICS_DIR.glob("*.md")) if WIKI_TOPICS_DIR.exists() else [] result["checks"].append({"ok": len(topics) > 0, "msg": f"Wiki topics: {len(topics)} pages"}) return result def check_recall_wiki_source() -> dict: """確認 WIKI_TOPICS_DIR 路徑是 skill wiki,而非舊的 workspace wiki。""" result = {"name": "recall_wiki_source", "checks": []} canonical = WIKI_DIR.resolve() actual = WIKI_TOPICS_DIR.parent.resolve() if actual == canonical: result["checks"].append({"ok": True, "msg": f"Recall reads from canonical wiki: {canonical}"}) else: result["checks"].append({"ok": False, "msg": f"Recall reads from {actual}, expected {canonical}"}) # 測試一個 sample 查詢能否命中 wiki if WIKI_TOPICS_DIR.exists(): topics = list(WIKI_TOPICS_DIR.glob("*.md")) result["checks"].append({"ok": True, "msg": f"Wiki topics accessible: {[t.stem for t in topics]}"}) else: result["checks"].append({"ok": False, "msg": "WIKI_TOPICS_DIR not found — recall wiki layer will return empty"}) return result def check_recall_live() -> dict: """真的跑一次 recall,而不是只確認檔案在不在。 2026-05-04 的 script 清理移除了 recall_router 依賴的模組,recall 從此每次都 崩潰;但當時的檢查只看目錄存不存在,所以整整 12 週沒有人發現。這個檢查是那次 事故的直接產物:唯一能證明 recall 還活著的方法,就是把它叫起來一次。 """ result = {"name": "recall_live", "checks": []} sys.path.insert(0, str(Path(__file__).resolve().parent)) try: import xkb_recall_server as server except Exception as e: result["checks"].append({"ok": False, "msg": f"cannot import xkb_recall_server: {e}"}) return result if not server.ROUTER_SCRIPT.exists(): result["checks"].append({"ok": False, "msg": f"router missing: {server.ROUTER_SCRIPT}"}) return result probe = server._run_recall_structured("XKB 的知識管線是怎麼設計的") status = probe.get("status", "ok") if status == "failed": result["checks"].append({"ok": False, "msg": f"recall FAILED end-to-end: {probe.get('error')}"}) else: result["checks"].append({ "ok": True, "msg": f"recall ran end-to-end: {len(probe.get('results') or [])} result(s), " f"confidence {probe.get('confidence')}" }) return result def check_recall_telemetry() -> dict: """Dead man's switch:太久沒有任何一筆 recall 紀錄,本身就是故障訊號。""" result = {"name": "recall_telemetry", "checks": []} stale_days = float(os.getenv("XKB_RECALL_STALE_DAYS", "7")) path = WORKSPACE / "memory" / "x-knowledge-base" / "recall-telemetry.jsonl" if not path.exists(): result["checks"].append({"ok": False, "msg": f"recall telemetry not found: {path}"}) return result age_days = (datetime.now().timestamp() - path.stat().st_mtime) / 86400 result["checks"].append({ "ok": age_days <= stale_days, "msg": f"last recall telemetry: {age_days:.1f} days ago (threshold {stale_days:.0f}d)" }) # 最後一筆是不是失敗的,以及有沒有哪一層一直在退回 fallback。 try: rows = [] with path.open(encoding="utf-8") as fh: for line in fh: line = line.strip() if line: try: rows.append(json.loads(line)) except ValueError: continue if rows: entry = rows[-1] result["checks"].append({ "ok": True, "msg": f"last entry: recalled={entry.get('recalled')}, " f"results={entry.get('result_count')}, ts={entry.get('ts', '')[:19]}" }) result["checks"].extend(_degraded_recall_checks(rows)) except Exception as e: result["checks"].append({"ok": False, "msg": f"telemetry unreadable: {e}"}) return result def _degraded_recall_checks(rows: list[dict]) -> list[dict]: """有哪一層壞著,而不是「這個主題沒有東西」。 召回回空手有兩種原因,而它們原本在紀錄裡完全相同:recalled=false, result_count=0。2026-05-04 之後召回每次都崩潰,整整十二週沒有人發現—— 它一直有禮貌地回答「我不知道」。recall_router 現在會把這一次退回 fallback 的層記進 degraded 欄位,這裡讀它。 判斷用的是「持續多久」而不是「佔幾成」。語意後端偶爾逾時一次是抖動,天天 逾時是壞了,而比率會被使用頻率影響:一天只查三次的話,一次逾時就是 33%。 所以看的是同一層有沒有從幾小時前一直壞到最新這一次。 """ checks: list[dict] = [] persist_hours = float(os.getenv("XKB_RECALL_DEGRADED_HOURS", "6")) # 舊紀錄沒有這個欄位。沒有欄位不等於沒壞掉,只是那時候還沒在記—— # 對這些列保持沉默,不要假裝它們是綠的。 known = [r for r in rows if "degraded" in r] if not known: checks.append({ "ok": True, "msg": "recall telemetry 還沒有 degraded 欄位——下一次召回之後才判斷得出", }) return checks latest = known[-1] now_broken = set(latest.get("degraded") or []) if not now_broken: blind = sum(1 for r in known if r.get("empty_because_broken")) checks.append({ "ok": True, "msg": f"最近一次召回沒有任何一層退回 fallback" f"(歷史上有 {blind} 次空手是因為壞掉,不是因為沒資料)", }) return checks def _ts(row: dict) -> datetime | None: try: value = datetime.fromisoformat(str(row.get("ts", ""))) except ValueError: return None return value if value.tzinfo else value.replace(tzinfo=timezone.utc) latest_ts = _ts(latest) for layer in sorted(now_broken): # 從最新那一筆往回走,走到第一筆「這一層是好的」為止——那才是這一段 # 連續壞掉的起點。 # # 原本是從頭往後找第一次出現就 break,抓到的是「史上第一次」。於是 # 三個月前抖一次、中間一千小時全好、剛剛又抖一次,會算出「已經連續壞 # 了 2000h」——正是這個檢查的註解說要避免的那種誤報。而我為它寫的 # 測試沒抓到,因為 fixture 裡沒有放更早的那一次。 streak_start = None for row in reversed(known): if layer in (row.get("degraded") or []): streak_start = _ts(row) else: break if latest_ts and streak_start: persisted_hours = (latest_ts - streak_start).total_seconds() / 3600 else: persisted_hours = 0.0 checks.append({ "ok": persisted_hours < persist_hours, "msg": (f"{layer}:最近一次退回 fallback(單次抖動,還不算故障)" if persisted_hours < persist_hours else f"{layer}:已經連續壞了 {persisted_hours:.0f}h ——" f"召回這段期間回的「查無資料」不能當成真的沒資料"), }) return checks def check_semantic_index() -> dict: """語意索引存不存在、跟得上不跟得上內容。 向量是離線建的:wiki 或記憶檔改了但沒重建索引,召回會用舊向量算相似度, 不會報錯,只會撈回過時的段落。二進位格式又沒有自我描述能力, keys 與 .bin 不同步時切出來的向量是錯位的——同樣不會拋錯。 """ result = {"name": "semantic_index", "checks": []} meta_path = Path(os.getenv("XKB_SEMANTIC_INDEX", str(BOOKMARKS_DIR / "semantic_index.json"))) bin_path = meta_path.with_suffix(".bin") if not (meta_path.exists() and bin_path.exists()): result["checks"].append({ "ok": False, "msg": "semantic index missing — 召回會退回字串比對(中文命中率大幅下降)", }) return result try: with meta_path.open(encoding="utf-8") as fh: meta = json.load(fh) indexed = int(meta.get("count", 0)) expected_bytes = int(meta.get("bytes", 0)) except (OSError, ValueError): result["checks"].append({"ok": False, "msg": f"semantic index meta unreadable: {meta_path}"}) return result actual_bytes = bin_path.stat().st_size result["checks"].append({ "ok": actual_bytes == expected_bytes, "msg": f"index integrity: {indexed} vectors, {actual_bytes} bytes" + ("" if actual_bytes == expected_bytes else f" — 應為 {expected_bytes},keys 與 .bin 不同步"), }) # 內容比索引新 = 索引過時 newest = 0.0 for directory in (WIKI_TOPICS_DIR, WORKSPACE / "memory"): if not directory.exists(): continue for path in list(directory.glob("*.md"))[:400]: newest = max(newest, path.stat().st_mtime) if newest: lag_hours = (newest - bin_path.stat().st_mtime) / 3600 max_lag = float(os.getenv("XKB_SEMANTIC_MAX_LAG_HOURS", "26")) result["checks"].append({ "ok": lag_hours <= max_lag, "msg": f"index freshness: 內容比索引新 {max(lag_hours, 0):.1f}h (threshold {max_lag:.0f}h)" + ("" if lag_hours <= max_lag else " — 重跑 build_vector_index.py --incremental"), }) return result def check_topic_map() -> dict: """卡片→wiki 的對應表是不是真的在對應東西。 2026-05-04 的 productization cleanup 把真實對應表換成了 repo 裡的範本 (your-category-slug → your-wiki-topic-slug)。之後 sync_cards_to_wiki 每次都回報「No mapped cards found」就結束,離開碼 0,看起來完全正常—— 卡片進 wiki 這條路空轉了三個月沒有人發現。 範本值是可以直接認出來的,認出來就該報錯。 """ result = {"name": "topic_map", "checks": []} path = WIKI_DIR / "topic-map.json" if not path.exists(): result["checks"].append({"ok": False, "msg": f"topic-map.json not found: {path}"}) return result try: with path.open(encoding="utf-8") as fh: mapping = (json.load(fh) or {}).get("mapping", {}) except (OSError, ValueError) as exc: result["checks"].append({"ok": False, "msg": f"topic-map unreadable: {exc}"}) return result placeholders = {"your-category-slug", "another-category", "topic-a", "topic-b", "your-wiki-topic-slug"} found = [k for k in mapping if k in placeholders] if found: result["checks"].append({ "ok": False, "msg": f"topic-map 仍是範本({', '.join(found)})— 卡片無法進 wiki,這條路等於停擺", }) return result # 對應到的主題必須真的存在,否則吸收時會寫到不存在的頁面 topics = {p.stem for p in WIKI_TOPICS_DIR.glob("*.md")} if WIKI_TOPICS_DIR.exists() else set() dangling = sorted({t for v in mapping.values() for t in (v.get("topics", []) if isinstance(v, dict) else []) if t not in topics}) result["checks"].append({ "ok": bool(mapping) and not dangling, "msg": f"topic-map: {len(mapping)} 個分類對應" + ("" if not dangling else f" — 指向不存在的主題: {', '.join(dangling)}"), }) return result def check_staging_backlog() -> dict: """待審候選有沒有在無聲累積。 2026-04-07 到 07-16 累積了 146 個檔案、470 條候選,被勾選過的只有 1 條—— 對話裡談出來的結論三個半月都沒進知識庫,而且沒有任何地方會提到這件事。 擷取層有健檢、召回層有健檢,消化層原本什麼都沒有。 """ result = {"name": "staging_backlog", "checks": []} staging_dir = WIKI_DIR / "_staging" if not staging_dir.exists(): result["checks"].append({"ok": True, "msg": "no staging dir — nothing pending"}) return result max_pending = int(os.getenv("XKB_STAGING_MAX_PENDING", "60")) max_age_days = int(os.getenv("XKB_STAGING_MAX_AGE_DAYS", "30")) # Read through xkb_review rather than parsing staging again here. The two # parsers had already drifted apart on which files they looked at, and a # second definition of "pending" is how this repository ends up with two # numbers for one question. try: import xkb_review promoted = xkb_review._promoted_ids( xkb_review.GOVERNANCE_DIR / "candidate-registry.jsonl") outstanding = [ c for c in xkb_review.load_candidates(classify=False) # Keep this check's long-standing scope: top-level staging only, # not the pre-2026-07 archive. if "/" not in c.source_file and c.status == "pending" and c.candidate_id not in promoted ] except Exception as exc: result["checks"].append({"ok": False, "msg": f"staging counts unavailable: {exc}"}) return result pending = len(outstanding) dates = [c.source_date for c in outstanding if c.source_date != "unknown"] oldest: str | None = min(dates) if dates else None # 問的是「追不追得上」,不是「積了幾筆」。 # # 原本這一項在 pending > 60 時紅燈,訊息寫「用 xkb_review.py 審核」,而它被 # 歸在「等你決定」底下。2026-09-12 實測:180 筆待審裡 106 筆是 safe_promotion # ——治理會自動吸收,一筆都不需要人。積起來的原因是治理每晚上限 20 筆,而候選 # 進來得比 20 快,連續三天都打在上限上。 # # 那是吞吐量設定,不是判斷。而「積了幾筆」這個問法在任何補跑期間都會紅燈, # 於是它每天叫人去審核一件系統自己會做的事——這則訊息會被停止閱讀就是這樣來的。 # # 現在拿帳本裡的實際吸收速度跟候選進來的速度比。追得上就不是故障,不管積了幾筆; # 追不上才是,而且訊息會說「追不上」而不是「請你審核」。 absorbed_per_day, arrived_per_day = _governance_rates() if absorbed_per_day is None or arrived_per_day is None: # 帳本還沒有足夠紀錄算速度(要三次治理執行)。 # # 這種時候不要紅燈。「積了幾筆」這個問法正是我們要淘汰的那一個——它在 # 補跑期間必定紅燈,而現在恰好就是補跑期間。拿一個已知會誤報的判準去填 # 空窗,等於把噪音留在門口。 # # 說出狀態、但不當故障:真正的判斷等帳本有資料再做。 result["checks"].append({ "ok": True, "msg": f"待審候選 {pending} 筆——還要 {3 - _governance_runs()} 次治理執行" f"才算得出吸收速度,在那之前不判斷追不追得上", }) else: keeping_up = absorbed_per_day >= arrived_per_day result["checks"].append({ "ok": keeping_up, "msg": (f"待審候選 {pending} 筆;治理每天吸收 {absorbed_per_day:.0f} 筆、" f"進來 {arrived_per_day:.0f} 筆" + ("——追得上" if keeping_up else "——追不上,調高 run_candidate_governance.sh 的 LIMIT")), }) if oldest: age_days = (datetime.now(timezone.utc).date() - datetime.strptime(oldest, "%Y-%m-%d").date()).days # 年齡只在追不上的時候才有意義:追得上的話,舊的那批本來就排在前面, # 會自己被吸收掉。單看年齡會讓一個正在消化的佇列每天紅燈。 stalled = (absorbed_per_day is not None and arrived_per_day is not None and absorbed_per_day < arrived_per_day) result["checks"].append({ "ok": not (stalled and age_days > max_age_days), "msg": f"最舊的待審候選:{oldest}({age_days} 天前)" + ("" if not stalled else f",而且追不上——它不會自己消失"), }) return result def _governance_runs() -> int: """帳本裡有幾次治理紀錄。用來說「還要幾次才算得出速度」。""" try: import xkb_ledger return len([r for r in xkb_ledger.read("candidate-governance") if isinstance(r.get("produced"), int)]) except Exception: # noqa: BLE001 return 0 def _governance_rates() -> tuple[float | None, float | None]: """(每天吸收幾筆, 每天進來幾筆)。算不出來時回 (None, None)。 吸收速度來自管線帳本——那是實際發生過的事,不是設定值。進來的速度用待審 候選的日期分佈算:最舊到今天的天數除以總筆數,是平均到達率。 兩邊都算不出來時不要猜。猜一個速度然後拿它判斷,會得到一個看起來精確的錯誤。 """ try: import xkb_ledger rows = xkb_ledger.read("candidate-governance") except Exception: # noqa: BLE001 return None, None absorbed = [r.get("produced") for r in rows if isinstance(r.get("produced"), int)] if len(absorbed) < 3: return None, None recent = absorbed[-7:] per_day = sum(recent) / len(recent) try: import xkb_review promoted = xkb_review._promoted_ids( xkb_review.GOVERNANCE_DIR / "candidate-registry.jsonl") outstanding = [c for c in xkb_review.load_candidates(classify=False) if "/" not in c.source_file and c.status == "pending" and c.candidate_id not in promoted] except Exception: # noqa: BLE001 return per_day, None dates = sorted(c.source_date for c in outstanding if c.source_date != "unknown") if len(dates) < 2: return per_day, 0.0 span = (datetime.strptime(dates[-1], "%Y-%m-%d").date() - datetime.strptime(dates[0], "%Y-%m-%d").date()).days return per_day, len(outstanding) / max(span, 1) def check_governance_actionable() -> dict: """Warn only when bounded governance backlog exceeds threshold or is overdue.""" result = {"name": "governance_actionable", "checks": []} try: import xkb_review counts = xkb_review.governance_health_counts(int(os.getenv("XKB_GOVERNANCE_TTL_DAYS", "30"))) except Exception as exc: result["checks"].append({"ok": False, "msg": f"governance counts unavailable: {exc}"}) return result threshold = int(os.getenv("XKB_GOVERNANCE_MAX_PENDING", os.getenv("XKB_STAGING_MAX_PENDING", "60"))) actionable = {key: counts.get(key, 0) for key in ("pending", "medium", "low", "proposal", "quarantine", "overdue", "safe_promotion")} actionable["ttl"] = actionable["quarantine"] result["actionable_counts"] = actionable warning = counts["pending"] > threshold or counts["overdue"] > 0 result["checks"].append({"ok": not warning, "msg": "governance actionable counts: " + json.dumps(actionable, ensure_ascii=False) + (f" — threshold {threshold}" if warning else "")}) return result def check_external_dependencies() -> dict: """The two things XKB needs and does not ship. gbrain holds the card side of retrieval; ten scripts query it. openclaw copies the wiki into the memory vault for other agents. Both are meant to be here, and both fail quietly — gbrain by returning an empty result that is indistinguishable from "nothing on that subject", openclaw by a mirror that silently stops being written. """ result = {"name": "external_dependencies", "checks": []} try: sys.path.insert(0, str(Path(__file__).resolve().parent)) from xbrain_recall import GBRAIN_AVAILABLE, xbrain_query except Exception as err: # noqa: BLE001 result["checks"].append( {"ok": False, "msg": f"gbrain 橋接載不進來:{type(err).__name__}: {err}"}) return result if not GBRAIN_AVAILABLE: result["checks"].append( {"ok": False, "msg": "找不到 gbrain — 卡片那一半的召回會靜靜回空"}) else: # 一次真的查詢。「裝在那裡」跟「回答得出來」是兩件事。 # # xbrain_query 不會拋例外——它把 timeout、找不到執行檔、非零離開碼 # 全部接住並回 []。所以這裡不能寫成 try/except:那個分支永遠不會執行, # 而所有失敗都會被說成「索引可能是空的」,在一個專門為了「不要說錯 # 原因」而寫的檢查裡說錯原因。空結果本身只能說明「查不到」。 hits = xbrain_query("知識", limit=1) if hits: result["checks"].append({"ok": True, "msg": "gbrain 查得到東西"}) else: result["checks"].append( {"ok": False, "msg": "gbrain 一個常見詞查不到東西 — 可能是索引空的,也可能是它根本沒回應" "(xbrain_query 兩種情況都回空,分不出來)"}) # which() 只看呼叫者的 PATH,而健檢從純 cron 跑,PATH 通常只有 # /usr/bin:/bin。openclaw 是使用者裝的,所以光靠 which 會每天早上 # 通報一個正常運作的鏡像壞了。 openclaw = shutil.which("openclaw") or next( (p for p in (Path("/usr/bin/openclaw"), Path("/usr/local/bin/openclaw"), Path.home() / ".local" / "bin" / "openclaw") if p.exists()), None, ) if openclaw: result["checks"].append({"ok": True, "msg": f"openclaw 在(wiki 鏡像需要它)"}) else: result["checks"].append( {"ok": False, "msg": "找不到 openclaw — wiki 不會再被複製到記憶庫,agent 搜不到它。" "XKB 自己的召回不受影響"}) return result def check_conversation_capture(days: int = 3) -> dict: """Conversations must actually reach the store, not just open a session. The hook opens a session and then records a turn. Between 2026-08-24 and 08-29 it opened sessions and recorded nothing: stdin was decoded with the Windows codepage, the surrogates that produced raised UnicodeEncodeError inside turn_id, and that is a ValueError, which the fail-open handler is written to swallow. Exit code 0, no error anywhere, five days of Chinese conversations lost. Failing open is right — XKB must never block a conversation — but failing open is not the same as failing silently. A day whose sessions all hold zero turns is the signature, and it is visible from the store alone. """ result = {"name": "conversation_capture", "checks": []} db_path = xkb_paths.SERVICE_DB if not db_path.exists(): result["checks"].append({"ok": True, "msg": "no knowledge service store on this host"}) return result since = (datetime.now(timezone.utc) - timedelta(days=days)).isoformat() try: import sqlite3 from contextlib import closing with closing(sqlite3.connect(f"file:{db_path}?mode=ro", uri=True)) as db: total, empty = db.execute( """SELECT COUNT(*), SUM(CASE WHEN t.n IS NULL THEN 1 ELSE 0 END) FROM sessions s LEFT JOIN (SELECT session_id, COUNT(*) n FROM turns GROUP BY session_id) t ON t.session_id = s.session_id WHERE s.created_at >= ?""", (since,), ).fetchone() except Exception as exc: result["checks"].append({"ok": False, "msg": f"capture counts unavailable: {exc}"}) return result total = total or 0 empty = empty or 0 if total == 0: result["checks"].append({"ok": True, "msg": f"no conversations in the last {days}d — nothing to capture"}) return result # Every session empty means capture is broken, not that the conversations # were unremarkable. A mix is normal: a session can open and go unused. broken = empty == total result["checks"].append({ "ok": not broken, "msg": (f"conversation capture: {total - empty}/{total} sessions recorded turns " f"(last {days}d)" + ("" if not broken else " — hook 開了 session 卻沒有記下任何 turn,召回與候選池都收不到東西")), }) return result def check_provenance_markers() -> dict: """Knowledge distilled from Pan's own notes must be marked as such. Recall down-weights self-derived knowledge so that collecting an idea, recalling it, discussing it and distilling it again cannot turn one opinion into a consensus. The penalty is applied by looking for a marker in the text, so a writer that invents its own annotation silently opts out of it — which is how 913 promoted claims ended up competing at full weight against the external sources they were reasoned from. """ result = {"name": "provenance_markers", "checks": []} topics_dir = WIKI_DIR / "topics" if not topics_dir.exists(): result["checks"].append({"ok": True, "msg": "no topics dir"}) return result try: sys.path.insert(0, str(Path(__file__).resolve().parent)) from xkb_provenance import MARKER, is_self_derived except Exception as exc: result["checks"].append({"ok": False, "msg": f"provenance module unavailable: {exc}"}) return result # Three tools write knowledge into these pages and each annotates a line # its own way — one names a memory file, one a URL, one a staging entry. # That variety is fine; the annotations carry different facts. What must # hold for every one of them is that a reader can tell where the line came # from, because recall down-weights our own conclusions and cannot do that # for a line it fails to classify. A fourth writer inventing a fourth # format shows up here rather than in the ranking six months later. annotated = re.compile(r"^\s*-\s.*\*\(") external = re.compile(r"https?://") unclassified = 0 files = set() for path in sorted(topics_dir.glob("*.md")): try: content = path.read_text(encoding="utf-8") except OSError: continue for line in content.splitlines(): if not annotated.match(line): continue # Asked with the reader's own function, not a copy of its rule: # a check that decides for itself what counts is checking # something other than what happens at recall. if is_self_derived(line) or external.search(line): continue unclassified += 1 files.add(path.name) result["checks"].append({ "ok": unclassified == 0, "msg": ("every knowledge line names an external source or is marked self-derived" if unclassified == 0 else f"{unclassified} knowledge lines in {len(files)} page(s) name neither a " f"source URL nor '{MARKER}', so recall cannot weigh them — " f"跑 scripts/backfill_self_derived_marker.py --apply"), }) return result def check_pipeline_ledger() -> dict: """每個階段上一次執行做了什麼——照它自己的節奏判斷,不用手調門檻。 這一項問的是別的檢查問不到的事。其他檢查看的都是現況(有幾張卡、索引裡 有幾筆),而現況是可以重算的,重算不出歷史。2026-09-01 到 09-09 書籤轉卡 每晚失敗、八天 0 產出時,所有現況的數字都還在、都「正常」,沒有任何一項 檢查看得到那八個晚上。 門檻用各階段自己的紀錄算:跑得多密由中位間隔決定,改排程不會變成誤報。 寫死成 24 小時的話,一改排程就天天紅燈,而天天紅燈等於沒有檢查。 """ result = {"name": "pipeline_ledger", "checks": []} try: import xkb_ledger except Exception as err: # noqa: BLE001 result["checks"].append({"ok": False, "msg": f"帳本模組載入失敗:{err}"}) return result rows = xkb_ledger.read() if not rows: # 帳本是新的,或這台機器還沒跑過任何階段。沒有紀錄不等於故障。 result["checks"].append({ "ok": True, "msg": f"帳本還沒有紀錄({xkb_ledger.LEDGER_PATH.name})——下一次排程執行後才會有", }) return result now = datetime.now(timezone.utc) quiet_runs = int(os.getenv("XKB_LEDGER_QUIET_RUNS", "3")) for stage in sorted({r.get("stage", "") for r in rows if r.get("stage")}): stage_rows = [r for r in rows if r.get("stage") == stage] last = stage_rows[-1] last_ts = xkb_ledger._parse_ts(last) # 1. 上一次整批失敗 if not last.get("ok", True): result["checks"].append({ "ok": False, "msg": f"{stage}:上一次執行整批失敗——{last.get('reason') or '沒有記下原因'}", }) continue # 2. 這個階段停了。快慢由它自己的紀錄決定。 gap = xkb_ledger.typical_gap_seconds(stage_rows) if gap and last_ts: silent = (now - last_ts).total_seconds() if silent > gap * 3: result["checks"].append({ "ok": False, "msg": (f"{stage}:平常每 {gap / 3600:.0f}h 跑一次," f"已經 {silent / 3600:.0f}h 沒有紀錄——排程可能沒在跑"), }) continue # 3. 連續幾次都沒有產出,而且還有東西在排隊。 recent = stage_rows[-quiet_runs:] produced_recent = [r.get("produced") for r in recent if "produced" in r] waiting = any((r.get("pending") or 0) > 0 or (r.get("failed") or 0) > 0 for r in recent) if (len(produced_recent) == quiet_runs and all(p == 0 for p in produced_recent) and waiting): reason = next((r.get("reason") for r in reversed(recent) if r.get("reason")), "") result["checks"].append({ "ok": False, "msg": (f"{stage}:連續 {quiet_runs} 次執行都沒有產出,但還有東西在排隊" + (f"——{reason}" if reason else "")), }) continue bits = [f"{k}={last[k]}" for k in ("intake", "produced", "failed", "pending") if k in last] age = f"{(now - last_ts).total_seconds() / 3600:.0f}h 前" if last_ts else "時間不明" result["checks"].append({ "ok": True, "msg": f"{stage}:{age} " + " ".join(bits) if bits else f"{stage}:{age}", }) return result def _newest_card() -> tuple[float | None, str]: """最新一張卡片的 mtime 與檔名;沒有卡片時回 (None, "")。""" newest_mtime: float | None = None newest_name = "" for card in xkb_paths.card_files(): try: mtime = card.stat().st_mtime except OSError: continue if newest_mtime is None or mtime > newest_mtime: newest_mtime, newest_name = mtime, card.name return newest_mtime, newest_name def check_card_production() -> dict: """書籤有沒有真的在變成知識卡。 2026-09-01 到 09-09,排程每晚都跑、每晚都回報成功,但每一筆都以 404 失敗: 八天、25 筆失敗、0 張卡。整條擷取管線是死的,而這份健檢一個字都沒提—— 它只看得到「索引檔很久沒被寫過」,因為沒有新卡片本來就不會寫索引。 所以這裡問的是最上游那件事:東西進來了,有沒有變成知識。佇列本身變長不算 故障(那是消化速度,limit 的事);佇列不是空的、卻連一張新卡都生不出來, 才是故障。 """ result = {"name": "card_production", "checks": []} try: from xkb_pending_work import pending_breakdown counts = pending_breakdown(BOOKMARKS_DIR, CARDS_DIR) except Exception as err: # noqa: BLE001 — 健檢自己不能把整份報告弄掛 result["checks"].append({ "ok": False, "msg": f"讀不出待轉書籤數量,無法判斷擷取管線是否還活著:{err}", }) return result # 卡住的項目不是故障,是一件等人決定的事:沒有任何排程會再碰 failed, # 所以它們既不會好轉也不會惡化,只會一直在那裡。說出來,但不要算成紅燈—— # 每天亮著的紅燈會把真正的紅燈一起淹掉。 if counts["stuck"]: result["checks"].append({ "ok": True, "msg": f"{counts['stuck']} 筆卡在失敗狀態,不會自動重試(要重跑得手動重設佇列)", }) # 「還會被處理的」才是判斷管線活著與否的依據。拿全部待轉去判斷的話, # 佇列裡只剩卡住的項目時,這一項會永遠紅燈。 pending = counts["actionable"] stall_hours = int(os.getenv("XKB_CARD_STALL_HOURS", "48")) newest_mtime, newest_name = _newest_card() if newest_mtime is None: result["checks"].append({ "ok": pending == 0, "msg": f"memory/cards/ 是空的,還有 {pending} 筆書籤排隊中", }) return result idle_hours = (datetime.now().timestamp() - newest_mtime) / 3600 # 佇列空的時候,多久沒產出新卡片都是正常的——那只代表你沒有新書籤。 if pending == 0: result["checks"].append({ "ok": True, "msg": f"書籤佇列已清空;最後一張卡 {newest_name}({idle_hours:.0f}h 前)", }) return result stalled = idle_hours > stall_hours result["checks"].append({ "ok": not stalled, "msg": (f"{pending} 筆書籤排隊中,最後一張卡 {idle_hours:.0f}h 前產出" if not stalled else f"{pending} 筆書籤排隊中,但已經 {idle_hours:.0f}h 沒有任何新卡片" f"(超過 {stall_hours}h)——書籤轉卡可能每晚都在失敗," f"看 /tmp/xkb-bookmark-batch.log"), }) return result def check_index_freshness() -> dict: """檢查 search_index 和 vector_index 的 summary 覆蓋率,以及有沒有漏掉卡片。""" result = {"name": "index_freshness", "checks": []} # search_index if not INDEX_FILE.exists(): result["checks"].append({"ok": False, "msg": f"search_index.json not found: {INDEX_FILE}"}) return result raw = json.loads(INDEX_FILE.read_text(encoding="utf-8")) items = raw.get("items", []) total = len(items) has_summary = sum(1 for i in items if (i.get("summary") or "").strip()) enriched = sum(1 for i in items if i.get("enriched")) coverage = round(has_summary / total * 100) if total else 0 result["checks"].append({ "ok": coverage >= 70, "msg": f"search_index summary coverage: {has_summary}/{total} ({coverage}%) | enriched: {enriched}" }) # 索引有沒有落後於卡片。 # # 這裡原本問的是「索引檔 26 小時內有沒有被寫過」,而 sync_enriched_index # 只在真的有東西要寫時才寫檔。於是這一項實際上問的是「今天有沒有新知識 # 進來」——安靜的一天會報壞,而 2026-09-01 到 09-09 書籤轉卡整個停擺時, # 它也只報得出「索引沒有跟上」,沒有一個字提到一張卡都沒產出。症狀的症狀。 # # 改成比時間戳也不行:卡片會被去重、鏡像、回填之類的維護動到 mtime,那會 # 讓「最新的卡比索引新」每天亮紅燈,換一種噪音而已。落後與否是名詞的問題 # 不是時鐘的問題——哪幾張卡不在索引裡。剛產出的卡還在路上,所以只算那些 # 已經過了一輪同步時間、卻仍然沒進索引的。 mtime = INDEX_FILE.stat().st_mtime age_hours = (datetime.now().timestamp() - mtime) / 3600 result["checks"].append({ "ok": True, "msg": f"search_index last written {age_hours:.1f}h ago", }) # 比對要用「來源」不是「檔名」。同一個來源在 memory/cards/ 可能有兩個 # 檔名(github-x 與 github_star-x、卡片與書籤各一份),索引去重之後只留 # 一列,留下的那一列指向另一個檔名。純比檔名會把這種重複檔報成「漏了、 # 關鍵字查不到」——內容其實查得到,那是紅燈但不是故障,跟它要取代的 # 誤報一樣沒用。 grace_hours = int(os.getenv("XKB_INDEX_MISS_GRACE_HOURS", "26")) indexed_stems = set() indexed_urls = set() for item in items: rel = item.get("relative_path") or item.get("path") or "" if rel: indexed_stems.add(Path(rel).stem) url = (item.get("source_url") or "").strip() if url: indexed_urls.add(url) cutoff = datetime.now().timestamp() - grace_hours * 3600 missed = [] for card in xkb_paths.card_files(): if card.stem in indexed_stems: continue try: if card.stat().st_mtime >= cutoff: continue # 剛產出,還沒輪到同步 text = card.read_text(encoding="utf-8", errors="ignore") except OSError: continue source = re.search(r'^source_url:\s*"?([^"\n]+)"?\s*$', text, re.MULTILINE) if source and source.group(1).strip() in indexed_urls: continue # 同一個來源已經以另一個檔名進了索引 missed.append(card.name) result["checks"].append({ "ok": not missed, "msg": ("every card older than the sync window is in the search index" if not missed else f"{len(missed)} 張卡片不在 search_index 裡({grace_hours}h 以上)" f",關鍵字查不到:{', '.join(sorted(missed)[:3])}" f"{' …' if len(missed) > 3 else ''} — 跑 scripts/build_search_index.sh --incremental"), }) # vector_index:檢查新鮮度就好,不要把 62MB 讀進來數個數。 # 健檢是每天自動跑的,成本要跟「確認狀態」相稱,不是重做一次工作。 # 原本 json.loads 整份檔案在 VPS 上要 7 秒(冷快取時更久), # 而它換來的只是一個向量數字——那個數字在 semantic_index.json 的 meta 裡就有。 if not VECTOR_FILE.exists(): # 語意召回讀的是 semantic_index.bin,不是這一份。 # 這份只是重建索引時的中間產物,缺了不影響召回。 result["checks"].append({ "ok": True, "msg": "vector_index.json absent — 召回讀 semantic_index,此檔僅為重建用中間產物", }) else: v_mtime = VECTOR_FILE.stat().st_mtime size_mb = VECTOR_FILE.stat().st_size / 1e6 v_age_hours = (datetime.now().timestamp() - v_mtime) / 3600 # 同樣不看時鐘:這份也是有東西才寫。語意層真正的落後由 semantic_index # 那一節比對內容判斷,這裡只確認檔案不是空的。 result["checks"].append({ "ok": size_mb > 0.1, "msg": f"vector_index: {size_mb:.0f}MB, last written {v_age_hours:.1f}h ago", }) # cards vs index coverage if CARDS_DIR.exists(): card_count = len(xkb_paths.card_files()) result["checks"].append({ "ok": enriched >= card_count * 0.9, "msg": f"Cards in memory/cards/: {card_count} | enriched in index: {enriched}" }) return result def fmt_checks(section: dict) -> str: lines = [f"\n── {section['name']} ──"] for c in section["checks"]: icon = OK if c["ok"] else FAIL lines.append(f" {icon} {c['msg']}") return "\n".join(lines) # 健檢包含哪些項目——唯一的定義。 # # 原本這份清單有兩份:這支腳本一份,health_check_notify(排程每天跑的那支) # 自己再寫一份。2026-09-11 的後果是:我在這裡加了 card_production 與 # pipeline_ledger,寫了測試確認「有註冊」,然後那兩項在每日訊息裡一次都沒出現 # 過——因為每天跑的是另一份清單。反過來 external_dependencies 只在 notify 那份 # 裡,所以手動跑這支時它不會被檢查。 # # 兩份清單就是兩個會各自漂走的答案。新增檢查時只改這裡。 CHECKS = ( check_wiki_canonical, check_recall_wiki_source, check_recall_live, check_recall_telemetry, check_semantic_index, check_topic_map, check_staging_backlog, check_governance_actionable, check_provenance_markers, check_conversation_capture, check_external_dependencies, check_card_production, check_pipeline_ledger, check_index_freshness, ) def run_all() -> list[dict]: """跑完整份健檢。呼叫端不要自己列清單。""" return [check() for check in CHECKS] def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--json", action="store_true") args = parser.parse_args() sections = run_all() if args.json: print(json.dumps(sections, ensure_ascii=False, indent=2)) # 離開碼要反映健康狀態,--json 也一樣:排程是靠離開碼判斷要不要告警的 return 0 if all(c["ok"] for s in sections for c in s["checks"]) else 1 print("🔍 XKB Pipeline Health Check") print(f" {datetime.now().strftime('%Y-%m-%d %H:%M')}") all_ok = True for s in sections: print(fmt_checks(s)) if any(not c["ok"] for c in s["checks"]): all_ok = False print() if all_ok: print(f"{OK} All checks passed.") else: print(f"{FAIL} Some checks failed — review above.") return 0 if all_ok else 1 import xkb_usage # noqa: E402 — 量測誰在跑,見 scripts/xkb_usage.py if __name__ == "__main__": xkb_usage.record(__file__) raise SystemExit(main())