scripts/status_knowledge_pipeline.py
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env python3
"""
XKB + Wiki pipeline status summary.
Shows the current health and activity of the entire knowledge pipeline:
XKB bookmarks → cards → wiki sync → staging → topics
Usage:
python3 status_knowledge_pipeline.py
python3 status_knowledge_pipeline.py --json
python3 status_knowledge_pipeline.py --days 7
"""
from __future__ import annotations
import argparse
import json
import os
import re
from collections import Counter, defaultdict
from datetime import datetime, timedelta, timezone
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
import xkb_failures
import xkb_paths
WORKSPACE = xkb_paths.WORKSPACE
_SKILL_DIR = Path(__file__).resolve().parent.parent
WIKI_DIR = xkb_paths.WIKI_DIR
TOPICS_DIR = WIKI_DIR / "topics"
STAGING_DIR = WIKI_DIR / "_staging"
MEMORY_DIR = WORKSPACE / "memory"
BOOKMARKS_DIR = xkb_paths.BOOKMARKS_DIR
CARDS_DIR = xkb_paths.CARDS_DIR
SEARCH_INDEX_PATH = BOOKMARKS_DIR / "search_index.json"
# 跟 sync_cards_to_wiki 用同一個來源。兩邊各寫一次的話,導開檔案的那一方
# 會跟這支報告器看到不同的決策——同一個量兩個來源。
REVIEW_DECISIONS_PATH = Path(os.getenv("XKB_REVIEW_DECISIONS",
str(WIKI_DIR / "review-decisions.json")))
LOG_PATH = WIKI_DIR / "log.md"
INDEX_PATH = WIKI_DIR / "index.md"
TOPIC_MAP_PATH = WIKI_DIR / "topic-map.json"
def load_search_index() -> list[dict]:
if not SEARCH_INDEX_PATH.exists():
return []
data = json.loads(SEARCH_INDEX_PATH.read_text(encoding="utf-8"))
return data.get("items", data) if isinstance(data, dict) else data
def load_review_decisions() -> dict:
if not REVIEW_DECISIONS_PATH.exists():
return {}
try:
return json.loads(REVIEW_DECISIONS_PATH.read_text(encoding="utf-8")).get("decisions", {})
except Exception as err:
xkb_failures.note("review decisions", err, detail=str(REVIEW_DECISIONS_PATH))
return {}
def load_topic_map() -> dict:
if not TOPIC_MAP_PATH.exists():
return {}
return json.loads(TOPIC_MAP_PATH.read_text(encoding="utf-8"))
def parse_frontmatter(path: Path) -> dict:
content = path.read_text(encoding="utf-8")
fm = {}
m = re.match(r"^---\n(.*?)\n---", content, re.DOTALL)
if m:
for line in m.group(1).splitlines():
kv = line.split(":", 1)
if len(kv) == 2:
fm[kv[0].strip()] = kv[1].strip()
return fm
def get_recent_log_entries(days: int = 7) -> list[str]:
if not LOG_PATH.exists():
return []
cutoff = datetime.now(timezone.utc) - timedelta(days=days)
entries = []
for line in LOG_PATH.read_text(encoding="utf-8").splitlines():
m = re.match(r"## \[(\d{4}-\d{2}-\d{2})", line)
if m:
try:
d = datetime.strptime(m.group(1), "%Y-%m-%d").replace(tzinfo=timezone.utc)
if d >= cutoff:
entries.append(line)
except ValueError:
pass
return entries
def status_bookmarks(items: list[dict], days: int) -> dict:
cutoff = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
active = [i for i in items if not i.get("excluded")]
recent = [i for i in active if (i.get("date") or "") >= cutoff]
cats = Counter(i.get("category", "?") for i in active)
return {
"total": len(items),
"active": len(active),
"excluded": len(items) - len(active),
"recent_added": len(recent),
"by_category": dict(cats.most_common()),
}
def status_cards() -> dict:
if not CARDS_DIR.exists():
return {"total": 0}
cards = xkb_paths.card_files()
return {"total": len(cards)}
def status_wiki_topics() -> list[dict]:
if not TOPICS_DIR.exists():
return []
today = datetime.now(timezone.utc).date()
topics = []
for f in sorted(TOPICS_DIR.glob("*.md")):
fm = parse_frontmatter(f)
lu = fm.get("last_updated", "")
try:
days_ago = (today - datetime.strptime(lu, "%Y-%m-%d").date()).days
except Exception:
days_ago = None
# Count sources mentions in file
content = f.read_text(encoding="utf-8")
source_count = len(re.findall(r"^\s*- \[", content, re.MULTILINE))
topics.append({
"slug": f.stem,
"status": fm.get("status", "?"),
"sources": fm.get("sources", "?"),
"last_updated": lu,
"days_since_update": days_ago,
"source_lines": source_count,
})
return topics
def status_absorb(decisions: dict) -> dict:
counts = Counter(v.get("decision", "?") for v in decisions.values())
return {
"total_evaluated": len(decisions),
"approved": counts.get("approve", 0),
"skipped": counts.get("skip", 0),
"by_reason": Counter(
v.get("reason", "no-reason")
for v in decisions.values()
if v.get("decision") == "skip"
),
}
def status_staging() -> dict:
if not STAGING_DIR.exists():
return {"files": 0, "total_candidates": 0, "pending_review": 0}
files = list(STAGING_DIR.glob("*-candidates.md"))
total_candidates = 0
pending = 0
for f in files:
content = f.read_text(encoding="utf-8")
candidates = len(re.findall(r"^## Candidate \d+", content, re.MULTILINE))
approved = len(re.findall(r"\[x\] approve", content, re.IGNORECASE))
total_candidates += candidates
pending += candidates - approved
return {
"files": len(files),
"total_candidates": total_candidates,
"pending_review": pending,
"latest": files[-1].name if files else None,
}
def status_gaps(items: list[dict], topic_files: list[Path]) -> list[dict]:
topic_map = load_topic_map()
existing_slugs = {f.stem for f in topic_files}
cat_counts = Counter(i.get("category", "?") for i in items if not i.get("excluded"))
gaps = []
for cat, count in cat_counts.most_common():
if count < 5:
continue
mapping = topic_map.get("mapping", {}).get(cat, {})
mapped = mapping.get("topics") or []
if mapped and any(t in existing_slugs for t in mapped):
continue
gaps.append({"category": cat, "cards": count,
"mapped_to": mapped or None,
"map_status": mapping.get("status", "unmapped")})
return gaps
def fmt_section(title: str) -> str:
return f"\n{'─' * 50}\n {title}\n{'─' * 50}"
def main() -> None:
parser = argparse.ArgumentParser(description="XKB + Wiki pipeline status")
parser.add_argument("--days", type=int, default=7, help="Lookback window in days")
parser.add_argument("--json", action="store_true", help="Output raw JSON")
args = parser.parse_args()
items = load_search_index()
decisions = load_review_decisions()
topic_files = list(TOPICS_DIR.glob("*.md")) if TOPICS_DIR.exists() else []
log_entries = get_recent_log_entries(args.days)
report = {
"generated_at": datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M UTC"),
"lookback_days": args.days,
"bookmarks": status_bookmarks(items, args.days),
"cards": status_cards(),
"wiki_topics": status_wiki_topics(),
"absorb": status_absorb(decisions),
"staging": status_staging(),
"gap_topics": status_gaps(items, topic_files),
"recent_log": log_entries,
}
if args.json:
print(json.dumps(report, indent=2, ensure_ascii=False))
return
now = report["generated_at"]
days = args.days
bk = report["bookmarks"]
cards = report["cards"]
topics = report["wiki_topics"]
absorb = report["absorb"]
staging = report["staging"]
gaps = report["gap_topics"]
print(f"\n🔭 XKB + Wiki Pipeline Status ({now})")
print(f" Lookback: last {days} days")
# Bookmarks
print(fmt_section("📚 Bookmarks (XKB)"))
print(f" Total: {bk['total']} | Active: {bk['active']} | Excluded: {bk['excluded']}")
print(f" Added in last {days}d: {bk['recent_added']}")
print(" By category:")
for cat, n in list(bk["by_category"].items())[:6]:
print(f" {n:4d} {cat}")
# Cards
print(fmt_section("🃏 Knowledge Cards"))
print(f" Total cards in memory/cards/: {cards['total']}")
# Wiki topics
print(fmt_section("📖 Wiki Topics"))
if topics:
for t in topics:
age = f"{t['days_since_update']}d ago" if t['days_since_update'] is not None else "unknown"
print(f" [{t['status']:8s}] {t['slug']}")
print(f" sources: {t['sources']} | updated: {t['last_updated']} ({age})")
else:
print(" No topic pages yet.")
# Absorb gate
print(fmt_section("🚦 Absorb Gate (sync_cards_to_wiki)"))
print(f" Total evaluated: {absorb['total_evaluated']}")
print(f" Approved: {absorb['approved']} | Skipped: {absorb['skipped']}")
if absorb["by_reason"]:
print(" Skip reasons:")
for reason, count in absorb["by_reason"].most_common():
print(f" {count:3d} {reason}")
# Staging
print(fmt_section("📋 Staging (_staging/)"))
print(f" Files: {staging['files']} | Total candidates: {staging['total_candidates']}")
print(f" Pending review: {staging['pending_review']}")
if staging["latest"]:
print(f" Latest: {staging['latest']}")
# Gap topics
print(fmt_section("⚠️ Gap Topics (5+ cards, no wiki page)"))
if gaps:
for g in gaps:
mapped = f"→ {g['mapped_to']}" if g["mapped_to"] else "(unmapped)"
print(f" {g['cards']:3d} cards {g['category']} {mapped} [{g['map_status']}]")
else:
print(" No gaps detected.")
# Recent log
print(fmt_section(f"📝 Wiki Log (last {days}d)"))
if log_entries:
for entry in log_entries[-8:]:
print(f" {entry}")
else:
print(" No log entries in this period.")
print()
import xkb_usage # noqa: E402 — 量測誰在跑,見 scripts/xkb_usage.py
if __name__ == "__main__":
xkb_usage.record(__file__)
main()