#!/usr/bin/env python3 """ suggest_topic_map.py 自動分析 search_index.json 的 category 分布,對尚未映射的 category 使用 LLM 生成 topic slug 建議,輸出可審閱的 topic-map patch。 Usage: python3 scripts/suggest_topic_map.py --review # 顯示建議(不修改) python3 scripts/suggest_topic_map.py --apply # 自動寫入 topic-map.json python3 scripts/suggest_topic_map.py --review --min-cards 5 """ import argparse import json import os import re import sys from collections import Counter from pathlib import Path # ── Paths ────────────────────────────────────────────────────────────────────── sys.path.insert(0, str(Path(__file__).resolve().parent)) import xkb_paths WORKSPACE_DIR = Path(os.getenv("OPENCLAW_WORKSPACE", os.getenv("WORKSPACE_DIR", str(Path.home() / ".openclaw" / "workspace")))) BOOKMARKS_DIR = xkb_paths.BOOKMARKS_DIR INDEX_FILE = xkb_paths.INDEX_FILE _SKILL_DIR = Path(__file__).resolve().parent.parent WIKI_DIR = xkb_paths.WIKI_DIR TOPIC_MAP_FILE = WIKI_DIR / "topic-map.json" TOPICS_DIR = WIKI_DIR / "topics" # ── Unified LLM helper ──────────────────────────────────────────────────────── sys.path.insert(0, str(Path(__file__).parent)) from _llm import call as _llm_backend def llm_call(prompt: str) -> str: return _llm_backend("", prompt) def load_index() -> list: if not INDEX_FILE.exists(): print(f"[ERROR] search_index.json not found: {INDEX_FILE}", file=sys.stderr) sys.exit(1) return json.loads(INDEX_FILE.read_text(encoding="utf-8")).get("items", []) def load_topic_map() -> dict: if not TOPIC_MAP_FILE.exists(): return {"mapping": {}} return json.loads(TOPIC_MAP_FILE.read_text(encoding="utf-8")) def existing_topic_slugs() -> list: if not TOPICS_DIR.exists(): return [] return [f.stem for f in TOPICS_DIR.glob("*.md")] def sample_titles(items: list, category: str, n: int = 8) -> list: return [ (i.get("title") or "").strip() for i in items if i.get("category") == category and i.get("title") ][:n] SUGGEST_PROMPT = """\ 你是一個知識庫架構師。我有一批知識卡片,屬於同一個 category,請你為這批卡片建議合適的 wiki topic slug。 Category: {category} 卡片數量: {count} 範例標題: {titles} 現有的 wiki topic slugs(已存在,避免重複): {existing} 請輸出 JSON,格式如下: {{"topics": ["slug-1"], "reason": "一句話說明為何這樣映射", "confidence": "high/medium/low"}} 規則: - slug 用英文小寫 kebab-case,如 "ai-agent-memory" - 若卡片太雜或太少(< 3 張),建議設為 null 並說明原因 - 若與現有 topic 重疊,直接映射過去(不新建) - 只輸出 JSON,不要其他文字 """ def suggest_topics_for_category(category: str, items: list, existing_slugs: list) -> dict: count = sum(1 for i in items if i.get("category") == category) titles_str = "\n".join(f" - {t}" for t in sample_titles(items, category)) or " (無標題)" existing_str = "\n".join(f" - {s}" for s in existing_slugs) or " (無)" prompt = SUGGEST_PROMPT.format( category=category, count=count, titles=titles_str, existing=existing_str, ) raw = llm_call(prompt).strip() # Strip markdown code fences if raw.startswith("```"): parts = raw.split("```") raw = parts[1] if raw.startswith("json"): raw = raw[4:] raw = raw.strip() # Try direct parse first try: return json.loads(raw) except json.JSONDecodeError: pass # Try to extract JSON object with regex m = re.search(r'\{[^{}]*"topics"[^{}]*\}', raw, re.DOTALL) if m: try: return json.loads(m.group(0)) except json.JSONDecodeError: pass return {"topics": None, "reason": f"LLM 輸出無法解析:{raw[:120]}", "confidence": "low"} def main(): parser = argparse.ArgumentParser(description="Auto-suggest XKB topic-map from search index") parser.add_argument("--review", action="store_true", help="顯示建議(不修改檔案)") parser.add_argument("--apply", action="store_true", help="將建議寫入 topic-map.json") parser.add_argument("--min-cards", type=int, default=3, help="只建議 card 數 >= N 的 category(預設 3)") parser.add_argument("--category", help="只處理指定 category") args = parser.parse_args() if not args.review and not args.apply: parser.error("請指定 --review 或 --apply") items = load_index() topic_map = load_topic_map() current_mapping = topic_map.get("mapping", {}) existing_slugs = existing_topic_slugs() cat_counts = Counter(i.get("category", "other") for i in items) print(f"📊 共 {len(items)} 張卡,{len(cat_counts)} 個 category") if args.category: count = cat_counts.get(args.category, 0) candidates = [(args.category, count)] else: candidates = [ (cat, count) for cat, count in cat_counts.most_common() if cat not in current_mapping and count >= args.min_cards ] if not candidates: print("✅ 所有 category 已有映射,或數量不足 --min-cards 閾值。") return print(f"\n🔍 發現 {len(candidates)} 個待映射 category:") for cat, count in candidates: status = "(已映射)" if cat in current_mapping else "" print(f" {cat}: {count} 張 {status}") print(f"\n🤖 使用 LLM 生成建議...\n") suggestions = {} for cat, count in candidates: print(f" ▶ {cat} ({count} 張)...", end=" ", flush=True) suggestion = suggest_topics_for_category(cat, items, existing_slugs) suggestions[cat] = {"count": count, **suggestion} topics_str = str(suggestion.get("topics")) if suggestion.get("topics") else "null" print(f"{topics_str} [{suggestion.get('confidence', '?')}]") print("\n" + "─" * 60) print("📋 建議 topic-map patch:\n") patch = {} for cat, data in suggestions.items(): topics = data.get("topics") reason = data.get("reason", "") conf = data.get("confidence", "?") print(f" {cat} ({data['count']} 張) → {topics}") print(f" 原因:{reason}") print(f" 信心:{conf}") print() if topics is not None: patch[cat] = {"topics": topics, "reason": reason, "confidence": conf} else: patch[cat] = {"topics": None, "reason": reason, "status": "pending"} if args.apply: current_mapping.update(patch) topic_map["mapping"] = current_mapping TOPIC_MAP_FILE.write_text( json.dumps(topic_map, ensure_ascii=False, indent=2), encoding="utf-8" ) print(f"✅ 已寫入 {TOPIC_MAP_FILE}") print(f" 新增/更新 {len(patch)} 個 category 映射") else: print("💡 使用 --apply 將以上建議寫入 topic-map.json") new_slugs = set() for data in patch.values(): for slug in (data.get("topics") or []): if slug not in existing_slugs: new_slugs.add(slug) if new_slugs: print(f"\n📌 以下 topic 需要建立新的 wiki 頁面:") for slug in sorted(new_slugs): print(f" wiki/topics/{slug}.md") print(" 執行 sync_cards_to_wiki.py --apply 後會自動建立") import xkb_usage # noqa: E402 — 量測誰在跑,見 scripts/xkb_usage.py if __name__ == "__main__": xkb_usage.record(__file__) main()