#!/usr/bin/env python3 """ Sync x-knowledge-base cards into wiki topic pages. v3: LLM absorb gate + persistent decision log + per-run report. v4: P2 absorb gate explainability — --review-rejects, --explain, --force-absorb. Each candidate card is evaluated against the existing wiki page content using the Policy 6 quality gate: "What new dimension does this card add?" Decisions are persisted to review-decisions.json["decisions"] for status tracking. Usage: python3 sync_cards_to_wiki.py --review [--topic SLUG] [--no-llm] python3 sync_cards_to_wiki.py --apply [--topic SLUG] [--no-llm] [--limit N] python3 sync_cards_to_wiki.py --apply --topic ai-seo-and-geo # P2: explainability python3 sync_cards_to_wiki.py --review-rejects [--topic SLUG] [--since YYYY-MM-DD] python3 sync_cards_to_wiki.py --explain URL python3 sync_cards_to_wiki.py --force-absorb URL [--topic SLUG] """ from __future__ import annotations import argparse import json import os import re import sys from collections import defaultdict from dataclasses import dataclass from datetime import datetime, timezone from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent)) import xkb_paths WORKSPACE = xkb_paths.WORKSPACE _SKILL_DIR = Path(__file__).resolve().parent.parent WIKI_DIR = xkb_paths.WIKI_DIR # ── Unified LLM helper ──────────────────────────────────────────────────────── sys.path.insert(0, str(_SKILL_DIR / "scripts")) from _llm import call as _llm_backend TOPICS_DIR = WIKI_DIR / "topics" INDEX_PATH = WIKI_DIR / "index.md" LOG_PATH = WIKI_DIR / "log.md" TOPIC_MAP_PATH = WIKI_DIR / "topic-map.json" # 可以單獨導開。測試需要隔離的只有這個檔,導開整個 WIKI_DIR 會連 # topic-map.json 一起弄丟,於是腳本根本跑不起來——而讓測試跑在正式 # 決策檔上,代表它失敗的那一次會破壞 Pan 累積的判斷。 REVIEW_DECISIONS_PATH = Path(os.getenv("XKB_REVIEW_DECISIONS", str(WIKI_DIR / "review-decisions.json"))) SEARCH_INDEX_PATH = WORKSPACE / "memory" / "bookmarks" / "search_index.json" _llm_cache: dict[tuple[str, str], tuple[bool, str, str]] = {} # 閘門判斷失敗的次數。這個數字必須在跑完時被看見—— # 2026-04 的紀錄顯示 123 筆決策有 120 筆是 LLM 失敗後直接放行, # 而當時沒有任何輸出提到這件事。 _llm_failures = 0 @dataclass class Card: title: str url: str category: str tags: list date: str path: str = "" # 向量索引是用 relative_path 當鍵的(build_vector_index.py),而 path # 是絕對路徑。兩者在這個庫裡從來不相等,所以拿 path 去查只能靠檔名 # 後備碰運氣命中——而檔名後備會撞到別張同名的卡。 relative_path: str = "" summary: str = "" FRONTMATTER_RE = re.compile(r"^---\n(.*?)\n---\n", re.DOTALL) SOURCES_SECTION_RE = re.compile(r"\n## 來源\n(.*)$", re.DOTALL) # --------------------------------------------------------------------------- # LLM helpers # --------------------------------------------------------------------------- def _call_configured_llm(api_key: str, system: str, user: str) -> str: return _llm_backend(system, user) def llm_absorb_judgment( card: Card, topic: str, wiki_content: str, card_full_content: str, api_key: str ) -> tuple[bool, str, str]: """ Ask LLM: does this card add a new dimension to the wiki page? Returns (should_include, dimension, reason). dimension: new_case | new_concept | contradiction | none """ cache_key = (card.url, topic) if cache_key in _llm_cache: return _llm_cache[cache_key] wiki_excerpt = wiki_content[:2000] if card_full_content: card_excerpt = card_full_content[:800] else: tag_str = ", ".join(str(t) for t in card.tags) card_excerpt = f"{card.title}\nTags: {tag_str}\nSummary: {card.summary}" system = ( "You are a wiki quality gate. You must output ONLY a JSON object. " "No reasoning, no explanation, no markdown. Just the JSON." ) user = ( f"Wiki topic: {topic}\n" f"Wiki excerpt: {wiki_excerpt[:1200]}\n\n" f"Card title: {card.title}\n" f"Card tags: {', '.join(str(t) for t in card.tags[:6])}\n" f"Card content: {card_excerpt[:500]}\n\n" "Does this card add new value to the wiki?\n" "Gates: (1) new dimension not in wiki (2) actionable/multi-source (3) relevant 6mo+\n" "Dimension: new_case|new_concept|contradiction|none\n\n" 'Output JSON only: {"include": true/false, "dimension": "...", "reason": "one sentence"}' ) try: response = _call_configured_llm(api_key, system, user) # Strip ... blocks if present response = re.sub(r'.*?', '', response, flags=re.DOTALL).strip() # Extract JSON with greedy match (handles full object) json_match = re.search(r'\{.*\}', response, re.DOTALL) if json_match: parsed = json.loads(json_match.group()) else: raise ValueError("absorb gate did not return JSON") if not isinstance(parsed, dict) or type(parsed.get("include")) is not bool: raise ValueError("absorb gate include must be boolean") include = parsed["include"] dimension = parsed.get("dimension") reason = parsed.get("reason", "") if dimension not in {"new_case", "new_concept", "contradiction", "none"} or not isinstance(reason, str): raise ValueError("absorb gate returned invalid fields") result = (include, dimension, reason) except Exception as e: # 判斷不了就不放行。 # # 原本這裡是 `(True, "new_case", "fallback include")`——閘門遇到錯誤 # 選擇放行。實際後果:review-decisions.json 裡 123 筆決策有 120 筆是 # `[llm_error: HTTP Error 401: Unauthorized] fallback include`, # 也就是 120 張卡片沒經過任何判斷就進了 wiki。而真的判斷過的那 3 筆 # 全部判 skip——閘門是有效的,只是它幾乎沒被執行過。 # # 品質閘門在故障時放行,等於閘門不存在,而且沒有人會發現。 # 改成不放行,並把失敗計數往上報,讓這件事在跑完時看得見。 global _llm_failures _llm_failures += 1 # 第二個欄位是分類,下游拿它顯示、也拿它寫進決策檔。原本用 "none", # 跟「判定過了,價值不高」長得一模一樣:印出來是 low-value、計入 # skipped_llm、而且以 decision: "skip" 永久寫進決策檔,之後的執行會 # 把它當成人做過的判斷。故障時不放行是對的,把故障記成判決不是。 result = (False, "gate_unavailable", f"[llm_error: {e}] gate unavailable, not absorbed") _llm_cache[cache_key] = result return result # --------------------------------------------------------------------------- # Data loading # --------------------------------------------------------------------------- def load_json(path: Path) -> object: return json.loads(path.read_text(encoding="utf-8")) def load_search_items() -> list[dict]: raw = load_json(SEARCH_INDEX_PATH) if isinstance(raw, dict): return raw.get("items", []) return raw # type: ignore def load_topic_map() -> dict: """回傳分類對應,並把標籤規則掛在 `__tags__` 這個保留鍵下。 標籤規則不能放進 mapping 本身——那個 dict 是用分類名稱查的, 混進去會讓某個叫 "seedance" 的分類意外命中標籤規則。 """ raw = load_json(TOPIC_MAP_PATH) mapping = dict(raw.get("mapping", {})) # type: ignore tag_rules = raw.get("tag_mapping", {}) # type: ignore if tag_rules: mapping["__tags__"] = { str(k).strip().lower(): (v.get("topics", []) if isinstance(v, dict) else list(v)) for k, v in tag_rules.items() } return mapping def load_review_file() -> dict: """Load the full review-decisions.json, initializing missing keys.""" if not REVIEW_DECISIONS_PATH.exists(): return {"decisions": {}, "topics": {}} data = load_json(REVIEW_DECISIONS_PATH) if not isinstance(data, dict): raise ValueError("review decisions must be an object; original file preserved") for key in ("decisions", "topics"): if key not in data: data[key] = {} elif not isinstance(data[key], dict): raise ValueError(f"review decisions {key} must be an object; original file preserved") return data def load_review_decisions() -> dict: return load_review_file().get("topics", {}) def save_absorb_decisions(records: list[dict]) -> None: """Persist LLM absorb decisions to review-decisions.json["decisions"].""" if not records: return data = load_review_file() today = datetime.now(timezone.utc).date().isoformat() for rec in records: key = rec["url"] data["decisions"][key] = { "decision": rec["decision"], # approve | skip | manual-allow | manual-skip | duplicate "dimension": rec.get("dimension", ""), "reason": rec.get("reason", ""), "topic": rec.get("topic", ""), "evaluated_at": today, } REVIEW_DECISIONS_PATH.write_text( json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8" ) def load_card_content(path: str) -> str: if not path: return "" p = Path(path) if not p.exists(): return "" try: return p.read_text(encoding="utf-8") except Exception: return "" def normalize_date(item: dict) -> str: for key in ("created_at", "date", "published_at"): value = item.get(key) if isinstance(value, str) and value: return value[:10] return datetime.now(timezone.utc).date().isoformat() def _index_key(item: dict) -> str: """這張卡在向量索引裡的鍵。 規則不是我定的,是 build_vector_index 定的:`relative_path or path`。 讀取端照抄同一條,不要自己推——我上一版看到有些 ingester 不寫 relative_path,就從絕對路徑推一個相對路徑出來,結果推出來的東西 **不是任何一個鍵**:對那些項目,索引本來就是拿絕對路徑當鍵的, 而 card.path 原本剛好命中。自己推等於把唯一會命中的那個鍵換掉。 """ return (item.get("relative_path") or item.get("path") or "").strip() def make_card(item: dict) -> Card | None: if item.get("excluded"): return None url = (item.get("source_url") or item.get("url") or "").strip() title = (item.get("title") or item.get("tweet_title") or "").strip() category = (item.get("category") or "").strip() if not url or not title or not category: return None return Card( title=title, url=url, category=category, tags=[str(t) for t in (item.get("tags") or [])], date=normalize_date(item), path=item.get("path", ""), relative_path=_index_key(item), summary=item.get("summary", ""), ) # --------------------------------------------------------------------------- # Manual review decision gate (hard gate: overrides LLM) # --------------------------------------------------------------------------- def check_manual_decision( card: Card, topic: str, review_decisions: dict ) -> tuple[str, str | None]: decision = review_decisions.get(topic, {}) allow = set(decision.get("allow") or []) skip = set(decision.get("skip") or []) move = decision.get("move") or {} if card.url in move: return "move", move[card.url] if card.url in skip: return "skip", None if card.url in allow: return "allow", None return "auto", None # --------------------------------------------------------------------------- # Card grouping # --------------------------------------------------------------------------- def collect_topic_existing_urls() -> dict[str, set[str]]: result: dict[str, set[str]] = {} for path in TOPICS_DIR.glob("*.md"): result[path.stem] = parse_existing_sources(path.read_text(encoding="utf-8")) return result def topics_from_tags(card: Card, topic_map: dict) -> list[str]: """用標籤補分類的漏。回傳對應到的主題(去重、保持順序)。 只認語意明確的標籤。像 `visual-ai`(408 張)、`prompt`(391 張)這種 幾乎每張卡都有的標籤刻意不對應——那不是主題,是整個知識庫的底色, 對應下去等於把幾百張卡片一次倒進同一頁。 """ rules = topic_map.get("__tags__", {}) if not rules: return [] topics: list[str] = [] for tag in card.tags: for topic in rules.get(str(tag).strip().lower(), []): if topic not in topics: topics.append(topic) return topics def iter_mapped_cards( items: list[dict], topic_map: dict, topic_filter: str | None, existing_urls_by_topic: dict[str, set[str]], review_decisions: dict, ) -> dict[str, list[Card]]: grouped: dict[str, list[Card]] = defaultdict(list) for item in items: card = make_card(item) if not card: continue mapping = topic_map.get(card.category) topics = mapping.get("topics") if mapping else None if not topics: # 分類欄位不可靠:99-general 的 125 張裡有 123 張其實可分類, # inbox 的 372 張全部可分類(2026-07-29 盤點)。近 500 張卡片 # 因為分類是「未分類」而被擋在 wiki 之外,但它們的標籤很明確。 # 分類漏掉的用標籤補。 topics = topics_from_tags(card, topic_map) if not topics: continue for topic in topics: if topic_filter and topic != topic_filter: continue if card.url in existing_urls_by_topic.get(topic, set()): continue decision, moved_topic = check_manual_decision(card, topic, review_decisions) if decision == "skip": continue if decision == "move" and moved_topic: if not topic_filter or moved_topic == topic_filter: if card.url not in existing_urls_by_topic.get(moved_topic, set()): grouped[moved_topic].append(card) continue grouped[topic].append(card) for topic, cards in list(grouped.items()): seen: set[str] = set() deduped: list[Card] = [] cards.sort(key=lambda c: (c.date, c.title), reverse=True) for card in cards: if card.url in seen: continue seen.add(card.url) deduped.append(card) grouped[topic] = deduped return grouped # --------------------------------------------------------------------------- # Wiki file manipulation # --------------------------------------------------------------------------- def parse_existing_sources(content: str) -> set[str]: section = SOURCES_SECTION_RE.search(content) if not section: return set() return set(re.findall(r"\((https?://[^)]+)\)", section.group(1))) def parse_frontmatter(content: str) -> tuple[dict[str, str], str]: match = FRONTMATTER_RE.match(content) if not match: raise ValueError("missing frontmatter") data: dict[str, str] = {} for line in match.group(1).splitlines(): if ":" not in line: continue key, value = line.split(":", 1) data[key.strip()] = value.strip() return data, content[match.end():] def render_frontmatter(data: dict[str, str]) -> str: ordered_keys = ["title", "tags", "sources", "last_updated", "status"] lines = ["---"] for key in ordered_keys: if key in data: lines.append(f"{key}: {data[key]}") for key, value in data.items(): if key not in ordered_keys: lines.append(f"{key}: {value}") lines.append("---") return "\n".join(lines) def format_source_line(card: Card) -> str: return f"- [{card.title}]({card.url}) — {card.date},xkb" def update_topic_file( topic: str, approved_cards: list[Card], apply: bool ) -> tuple[bool, str]: path = TOPICS_DIR / f"{topic}.md" if not path.exists(): return False, f"skip {topic}: not seeded yet" content = path.read_text(encoding="utf-8") try: frontmatter, body = parse_frontmatter(content) except ValueError: # 一個頁面沒有 frontmatter,不該讓整輪吸收全部中斷。 # 實際遇到的是 redirect 頁(gpt-image-2-private-taxonomy、 # visual-ai-workflow-node-catalog 都只寫著「Redirect: 視覺 AI 私有軍火庫」), # 它們不是吸收目標,往裡面寫等於寫進一個沒人會讀的殼。 return False, f"skip {topic}: 缺 frontmatter(redirect 或未初始化的頁面)" if "Redirect:" in content[:200]: return False, f"skip {topic}: 是 redirect 頁,內容應寫進它指向的主題" existing_urls = parse_existing_sources(content) new_cards = [c for c in approved_cards if c.url not in existing_urls] if not new_cards: return False, f"no new sources for {topic}" source_match = SOURCES_SECTION_RE.search(content) if not source_match: return False, f"skip {topic}: missing sources section" source_block = source_match.group(1).rstrip() appended = "\n".join(format_source_line(c) for c in new_cards) new_source_block = source_block + "\n" + appended + "\n" frontmatter["sources"] = str(len(existing_urls) + len(new_cards)) frontmatter["last_updated"] = datetime.now(timezone.utc).date().isoformat() rebuilt = render_frontmatter(frontmatter) + "\n\n" + body.lstrip("\n") rebuilt = SOURCES_SECTION_RE.sub( lambda _: "\n## 來源\n" + new_source_block.rstrip() + "\n", rebuilt ) if apply: path.write_text(rebuilt, encoding="utf-8") label = "updated" if apply else "[dry-run] would update" return True, f"{label} {topic}: +{len(new_cards)} sources" def update_index(apply: bool) -> None: lines = [ "# Wiki Index", "", "> Topic registry for the derived knowledge layer.", "", "## Status legend", "- `draft` — 已註冊,但尚未形成穩定頁面", "- `seeded` — 已建立第一版內容,但仍在驗證", "- `active` — 持續更新中的主題頁", "- `stale` — 長時間未更新,或內容需要檢查", "", "## Topics", ] for path in sorted(TOPICS_DIR.glob("*.md")): content = path.read_text(encoding="utf-8") try: fm, _ = parse_frontmatter(content) except ValueError: continue title = fm.get("title", path.stem) status = fm.get("status", "draft") sources = fm.get("sources", "0") updated = fm.get("last_updated", "unknown") lines.append( f"- [{title}](topics/{path.name}) — | status: {status} | sources: {sources} | last_updated: {updated}" ) lines += ["", "## Meta", "- [Wiki Schema](WIKI-SCHEMA.md)", "- [Wiki Log](log.md)", ""] if apply: INDEX_PATH.write_text("\n".join(lines), encoding="utf-8") def append_log(messages: list[str], apply: bool) -> None: if not messages: return timestamp = datetime.now(timezone.utc).strftime("%Y-%m-%d %H:%M") block = "\n".join( f"## [{timestamp}] ingest-xkb | {msg}" for msg in messages ) + "\n" if apply: existing = LOG_PATH.read_text(encoding="utf-8") with LOG_PATH.open("a", encoding="utf-8") as fh: if not existing.endswith("\n"): fh.write("\n") fh.write(block) # --------------------------------------------------------------------------- # P2: Absorb gate explainability helpers # --------------------------------------------------------------------------- def _url_title_map() -> dict[str, str]: """Build url→title lookup from search index (for reject display).""" try: items = load_search_items() return { (item.get("source_url") or item.get("url") or ""): (item.get("title") or item.get("tweet_title") or "") for item in items } except Exception: return {} def cmd_review_rejects(topic_filter: str | None, since: str | None) -> int: """Show all rejected cards from review-decisions.json with reasons.""" data = load_review_file() decisions = data.get("decisions", {}) url_titles = _url_title_map() skipped = [ (url, rec) for url, rec in decisions.items() if rec.get("decision") == "skip" ] if topic_filter: skipped = [(u, r) for u, r in skipped if r.get("topic") == topic_filter] if since: skipped = [(u, r) for u, r in skipped if r.get("evaluated_at", "") >= since] if not skipped: print("No rejected cards found" + (f" for topic '{topic_filter}'" if topic_filter else "") + ".") return 0 # Group by topic by_topic: dict[str, list[tuple[str, dict]]] = defaultdict(list) for url, rec in skipped: by_topic[rec.get("topic", "(unknown)")].append((url, rec)) total = len(skipped) print(f"\n{'─' * 55}") print(f" Reject Log — {total} rejected card(s)") if topic_filter: print(f" Topic filter: {topic_filter}") if since: print(f" Since: {since}") print(f"{'─' * 55}") for topic in sorted(by_topic): entries = by_topic[topic] print(f"\n [{topic}] — {len(entries)} rejected") for url, rec in sorted(entries, key=lambda x: x[1].get("evaluated_at", ""), reverse=True): title = url_titles.get(url, "") display_title = (title[:55] + "…") if len(title) > 55 else title dim = rec.get("dimension", "none") reason = rec.get("reason", "") evaluated = rec.get("evaluated_at", "") print(f" • {display_title or url[:55]}") print(f" dim: {dim} | {evaluated}") print(f" reason: {reason}") print(f" url: {url}") print(f"\n{'─' * 55}") print(" To override a rejection:") print(" python3 sync_cards_to_wiki.py --force-absorb ") print(f"{'─' * 55}\n") return 0 def cmd_explain(url: str) -> int: """Show full absorb gate decision for a specific URL.""" data = load_review_file() decisions = data.get("decisions", {}) rec = decisions.get(url) if not rec: # Try prefix match matches = [(u, r) for u, r in decisions.items() if url in u] if len(matches) == 1: url, rec = matches[0] elif len(matches) > 1: print(f"Multiple matches for '{url}':") for u, _ in matches: print(f" {u}") return 1 else: print(f"No decision record found for: {url}") return 1 url_titles = _url_title_map() title = url_titles.get(url, "") print(f"\n{'─' * 55}") print(" Absorb Gate Decision") print(f"{'─' * 55}") if title: print(f" Title : {title}") print(f" URL : {url}") print(f" Topic : {rec.get('topic', '(unknown)')}") print(f" Decision: {rec.get('decision', '?')}") print(f" Dimension: {rec.get('dimension', 'none')}") print(f" Reason : {rec.get('reason', '(none)')}") print(f" Date : {rec.get('evaluated_at', '?')}") print(f"{'─' * 55}") if rec.get("decision") == "skip": print(f"\n To override this rejection:") print(f" python3 sync_cards_to_wiki.py --force-absorb '{url}'") if rec.get("topic"): print(f" (will add to manual allow list for topic: {rec['topic']})") print() return 0 def cmd_force_absorb(url: str, topic_override: str | None) -> int: """Add a URL to the manual allow list, overriding the gate rejection.""" data = load_review_file() decisions = data.get("decisions", {}) # Resolve topic topic = topic_override if not topic: rec = decisions.get(url) if rec: topic = rec.get("topic", "") if not topic: # Try prefix match matches = [(u, r) for u, r in decisions.items() if url in u] if len(matches) == 1: url, rec = matches[0] topic = rec.get("topic", "") if not topic: print(f"Cannot determine topic for '{url}'.") print("Use --topic SLUG to specify the target topic.") return 1 # Add to topics[topic]["allow"] topics = data.setdefault("topics", {}) topic_entry = topics.setdefault(topic, {}) allow_list: list[str] = topic_entry.setdefault("allow", []) if url in allow_list: print(f"Already in allow list for topic '{topic}': {url}") return 0 allow_list.append(url) # Update decision record to reflect manual override today = datetime.now(timezone.utc).date().isoformat() if url in decisions: decisions[url]["decision"] = "manual-allow" decisions[url]["evaluated_at"] = today decisions[url]["reason"] = decisions[url].get("reason", "") + " [human override]" else: decisions[url] = { "decision": "manual-allow", "dimension": "manual", "reason": "forced by --force-absorb", "topic": topic, "evaluated_at": today, } data["decisions"] = decisions data["topics"] = topics REVIEW_DECISIONS_PATH.write_text( json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8" ) url_titles = _url_title_map() title = url_titles.get(url, url[:60]) print(f"✅ Force-absorb registered:") print(f" Title : {title}") print(f" Topic : {topic}") print(f" URL : {url}") print(f"\nNext run of --apply will include this card.") return 0 # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- def main() -> int: global _llm_failures _llm_failures = 0 _llm_cache.clear() parser = argparse.ArgumentParser( description="Sync x-knowledge-base cards into wiki (v4: LLM absorb gate + explainability)" ) parser.add_argument("--topic", help="only sync one topic slug") parser.add_argument("--limit", type=int, default=15, help="max cards per topic per run") parser.add_argument("--review", action="store_true", help="show LLM judgment without writing") parser.add_argument("--apply", action="store_true", help="write changes (default: dry-run)") parser.add_argument("--no-llm", action="store_true", help="skip LLM, list all candidates") # P2: explainability flags parser.add_argument("--review-rejects", action="store_true", help="show all rejected cards with reasons (from review-decisions.json)") parser.add_argument("--since", metavar="YYYY-MM-DD", help="filter --review-rejects to decisions on or after this date") parser.add_argument("--explain", metavar="URL", help="show full absorb gate decision for a specific URL") parser.add_argument("--force-absorb", metavar="URL", help="override gate rejection: add URL to manual allow list") args = parser.parse_args() # ── P2 commands (early exit) ────────────────────────────────────────────── if args.review_rejects: return cmd_review_rejects(args.topic, args.since) if args.explain: return cmd_explain(args.explain) if args.force_absorb: return cmd_force_absorb(args.force_absorb, args.topic) # ── Standard sync flow ──────────────────────────────────────────────────── api_key = "" # auth handled by _llm.py via openclaw CLI use_llm = not args.no_llm topic_map = load_topic_map() review_decisions = load_review_decisions() items = load_search_items() existing_urls_by_topic = collect_topic_existing_urls() grouped = iter_mapped_cards( items, topic_map, args.topic, existing_urls_by_topic, review_decisions ) if not grouped: print("No mapped cards found for requested scope.") return 0 messages: list[str] = [] decision_records: list[dict] = [] # Stats for absorb report stats = { "candidates": 0, "approved": 0, "skipped_llm": 0, "unavailable": 0, "skipped_manual": 0, "manual_allow": 0, "no_llm_passthrough": 0, } for topic, cards in sorted(grouped.items()): topic_path = TOPICS_DIR / f"{topic}.md" if not topic_path.exists(): print(f"skip {topic}: not seeded yet") continue wiki_content = topic_path.read_text(encoding="utf-8") print(f"\n--- {topic}: {len(cards)} candidates ---") if args.review: for card in cards[:args.limit + 5]: if use_llm: card_content = load_card_content(card.path) include, dimension, reason = llm_absorb_judgment( card, topic, wiki_content, card_content, api_key ) verdict = ("UNAVAILABLE" if dimension == "gate_unavailable" else "INCLUDE" if (include and dimension != "none") else "SKIP") print(f" [{verdict}] {card.title[:60]}") print(f" dim: {dimension} | {reason}") print(f" url: {card.url}") else: print(f" [?] {card.title[:60]}") print(f" tags: {', '.join(card.tags[:5])}") print(f" url: {card.url}") continue # Apply / dry-run: run LLM absorb filter approved: list[Card] = [] hard_allow = set((review_decisions.get(topic) or {}).get("allow") or []) for card in cards[:args.limit]: stats["candidates"] += 1 if card.url in hard_allow: approved.append(card) stats["manual_allow"] += 1 print(f" ALLOW (manual) {card.title[:60]}") decision_records.append({ "url": card.url, "topic": topic, "decision": "manual-allow", "dimension": "", "reason": "manual allow list", }) continue if use_llm: card_content = load_card_content(card.path) include, dimension, reason = llm_absorb_judgment( card, topic, wiki_content, card_content, api_key ) if include and dimension != "none": print(f" PASS [{dimension}] {card.title[:55]} — {reason}") approved.append(card) stats["approved"] += 1 decision_records.append({ "url": card.url, "topic": topic, "decision": "approve", "dimension": dimension, "reason": reason, }) else: lbl = "low-value" if dimension == "none" else "skip" print(f" {lbl} [{dimension}] {card.title[:55]}") stats["unavailable" if dimension == "gate_unavailable" else "skipped_llm"] += 1 decision_records.append({ "url": card.url, "topic": topic, "decision": "unavailable" if dimension == "gate_unavailable" else "skip", "dimension": dimension, "reason": reason, }) else: approved.append(card) stats["no_llm_passthrough"] += 1 if not approved: print(f" no cards passed absorb gate") continue changed, message = update_topic_file(topic, approved, apply=args.apply) print(f" {message}") if changed: messages.append(message) # --- Absorb Report --- print("\n" + "─" * 50) print(" Absorb Report") print("─" * 50) print(f" Candidates evaluated : {stats['candidates']}") print(f" Approved (LLM) : {stats['approved']}") print(f" Manual allow : {stats['manual_allow']}") print(f" Skipped (LLM gate) : {stats['skipped_llm']}") print(f" Gate unavailable : {stats['unavailable']}") if stats["no_llm_passthrough"]: print(f" Pass-through (no-LLM): {stats['no_llm_passthrough']}") total_approved = stats["approved"] + stats["manual_allow"] + stats["no_llm_passthrough"] if stats["candidates"]: rate = 100 * total_approved // stats["candidates"] print(f" Approval rate : {rate}%") if decision_records: skip_reasons: dict[str, int] = {} for r in decision_records: if r["decision"] == "skip": dim = r.get("dimension") or "none" skip_reasons[dim] = skip_reasons.get(dim, 0) + 1 if skip_reasons: print(" Skip breakdown:") for dim, cnt in sorted(skip_reasons.items(), key=lambda x: -x[1]): print(f" {cnt:3d} {dim}") print("─" * 50) if messages and args.apply: update_index(apply=True) append_log(messages, apply=True) save_absorb_decisions(decision_records) print(f"\nApplied: {len(messages)} topic(s) updated. Decisions saved.") elif messages: print(f"\n[dry-run] would update {len(messages)} topic(s). Use --apply to write.") else: if args.apply and decision_records: save_absorb_decisions(decision_records) print("\nNo topics needed updates.") if _llm_failures: # 大聲說出來,並用離開碼讓排程也知道。 # 這些卡片本次沒有被吸收,但它們還在——LLM 恢復後重跑會再評一次。 print(f"\n⚠️ 吸收閘門有 {_llm_failures} 次判斷失敗(LLM 不可用)。" f"\n 這些卡片本次未吸收,等 LLM 恢復後重跑即可。") return 2 return 0 import xkb_usage # noqa: E402 — 量測誰在跑,見 scripts/xkb_usage.py if __name__ == "__main__": xkb_usage.record(__file__) raise SystemExit(main())