Ctrl K
ComunidadGuías…

scripts/suggest_topic_map.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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()