scripts/xkb_ledger.py
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env python3
"""每一次管線執行做了什麼——一個重算不出來的紀錄。
XKB 原本能回答的是「現在有幾張卡、幾筆待轉、索引幾筆」。那些都是掃一次檔案
就能重算的量,而重算得出來的東西不會記得歷史。
2026-09-01 到 09-09,書籤轉卡每晚都跑、每晚都以 404 失敗、每晚都回報成功,
八天 0 產出。當時所有的量都還在:卡片 1,603 張、索引 1,632 筆,數字都「正常」。
沒有任何地方記著「昨晚這一階段收了 9 筆、產出 0 筆、失敗 9 筆、原因是找不到
模型」,所以那八天在系統裡不存在——只有佇列慢慢變長這一個間接影子。
這份帳本補的就是那件事。每個階段跑完寫一行:收了多少、產出多少、失敗多少、
還剩多少、失敗長什麼樣。它不取代 status_knowledge_pipeline.py(那是現況快照),
它記的是快照之間發生過什麼。
刻意不做的事:
- 不做輪替與壓縮。一天數行,一年幾千行,問題還沒大到要先解。
- 寫入失敗不會中斷管線,但也不會沉默——走 xkb_failures,stderr 上會說。
一個會安靜寫不進去的帳本,比沒有帳本更糟。
用法(shell 階段):
python3 scripts/xkb_ledger.py record --stage bookmark-batch \
--intake 9 --produced 0 --failed 9 --pending 57 --reason "404 model not found"
用法(Python):
import xkb_ledger
xkb_ledger.record("bookmark-batch", intake=9, produced=0, failed=9, pending=57)
"""
from __future__ import annotations
import argparse
import json
import sys
from datetime import datetime, timezone
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import xkb_failures
import xkb_paths
LEDGER_PATH = xkb_paths.PIPELINE_LEDGER
def record(
stage: str,
*,
intake: int | None = None,
produced: int | None = None,
failed: int | None = None,
pending: int | None = None,
reason: str = "",
ok: bool = True,
extra: dict | None = None,
path: Path | None = None,
) -> dict:
"""記一次執行。回傳寫下去的那筆,方便呼叫端印出來。"""
entry = {
"ts": datetime.now(timezone.utc).isoformat(),
"stage": stage,
"ok": bool(ok),
}
for name, value in [("intake", intake), ("produced", produced),
("failed", failed), ("pending", pending)]:
if value is not None:
entry[name] = int(value)
if reason:
# 失敗原因是給人看的一行,不是完整堆疊。堆疊在各階段自己的 log 裡。
entry["reason"] = reason.strip()[:300]
if extra:
entry.update(extra)
target = path or LEDGER_PATH
try:
target.parent.mkdir(parents=True, exist_ok=True)
with target.open("a", encoding="utf-8") as fh:
fh.write(json.dumps(entry, ensure_ascii=False) + "\n")
except Exception as err: # noqa: BLE001 — 帳本不能弄掛管線
xkb_failures.note("pipeline ledger", err, detail=str(target), repeat=True)
return entry
def read(stage: str | None = None, *, limit: int | None = None,
path: Path | None = None) -> list[dict]:
"""讀回紀錄,舊的在前。壞掉的行跳過,不要因為一行壞掉就整份讀不到。"""
target = path or LEDGER_PATH
if not target.exists():
return []
rows: list[dict] = []
try:
lines = target.read_text(encoding="utf-8", errors="ignore").splitlines()
except OSError as err:
xkb_failures.note("pipeline ledger", err, detail=str(target))
return []
for line in lines:
line = line.strip()
if not line:
continue
try:
row = json.loads(line)
except ValueError:
continue
if not isinstance(row, dict):
continue
if stage and row.get("stage") != stage:
continue
rows.append(row)
if limit is not None:
rows = rows[-limit:]
return rows
def stages(path: Path | None = None) -> list[str]:
"""帳本裡出現過的階段名稱。"""
return sorted({row.get("stage", "") for row in read(path=path) if row.get("stage")})
def _parse_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)
# 比這個更近的兩次執行,是同一段人工操作,不是排程的節奏。
# 這個值存在的理由見 typical_gap_seconds。
SAME_SESSION_SECONDS = 3600.0
def typical_gap_seconds(rows: list[dict]) -> float | None:
"""這個階段平常多久跑一次——用它自己的紀錄算,不要手調常數。
門檻寫死成「24 小時」的話,改排程就會變成誤報,而每天誤報的檢查等於沒有
檢查。所以取間隔的中位數。
但中位數只擋得住「一次」離群。2026-09-10 我為了驗證連續手動跑了六次書籤
批次,每次相隔幾分鐘,中位間隔就被壓成 0 小時——於是健檢說「平常每 0h 跑
一次,已經 11h 沒有紀錄」,把一個正常的階段報成排程死掉。我上一版的註解寫
著「一次補跑不會把它拉歪」,而我的測試也只測了一次。
所以先把「同一段人工操作」濾掉:靠得比 SAME_SESSION_SECONDS 還近的兩次
執行不算節奏。濾完不足三個間隔就回 None——沒有節奏可比的時候要保持沉默,
而不是拿一個算出來的假節奏去判斷。
"""
times = sorted(t for t in (_parse_ts(r) for r in rows) if t is not None)
if len(times) < 3:
return None
gaps = sorted(gap for gap in
((b - a).total_seconds() for a, b in zip(times, times[1:]))
if gap >= SAME_SESSION_SECONDS)
if len(gaps) < 2:
return None
mid = len(gaps) // 2
return gaps[mid] if len(gaps) % 2 else (gaps[mid - 1] + gaps[mid]) / 2
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="command", required=True)
rec = sub.add_parser("record", help="記一次執行")
rec.add_argument("--stage", required=True)
rec.add_argument("--intake", type=int)
rec.add_argument("--produced", type=int)
rec.add_argument("--failed", type=int)
rec.add_argument("--pending", type=int)
rec.add_argument("--reason", default="")
rec.add_argument("--not-ok", action="store_true", help="這次執行整體失敗")
show = sub.add_parser("show", help="印出紀錄")
show.add_argument("--stage")
show.add_argument("--limit", type=int, default=20)
show.add_argument("--json", action="store_true")
args = parser.parse_args()
if args.command == "record":
entry = record(
args.stage,
intake=args.intake,
produced=args.produced,
failed=args.failed,
pending=args.pending,
reason=args.reason,
ok=not args.not_ok,
)
print(json.dumps(entry, ensure_ascii=False))
return 0
rows = read(args.stage, limit=args.limit)
if args.json:
print(json.dumps(rows, ensure_ascii=False, indent=2))
return 0
if not rows:
print("(帳本是空的)")
return 0
for row in rows:
bits = [row.get("ts", "")[:19], f"{row.get('stage', ''):22}"]
for name, label in [("intake", "收"), ("produced", "產"),
("failed", "壞"), ("pending", "待")]:
if name in row:
bits.append(f"{label}={row[name]}")
if not row.get("ok", True):
bits.append("整批失敗")
if row.get("reason"):
bits.append(f"— {row['reason']}")
print(" ".join(bits))
return 0
import xkb_usage # noqa: E402 — 量測誰在跑,見 scripts/xkb_usage.py
if __name__ == "__main__":
xkb_usage.record(__file__)
raise SystemExit(main())