scripts/health_check_pipeline.py
Rama main · réplica de lectura del repositorio original.
#!/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())