Ctrl K
ComunidadGuías…

scripts/topic_guide_generator.py

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

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""
topic_guide_generator.py — XKB 領域導覽生成器

針對某個主題(tag 或 category),從現有 cards 自動生成:
- 領域基礎術語與 False Friends
- 建議閱讀順序(從基礎到進階)
- 主要作者與關鍵論文
- 目前的知識共識 vs 缺口

Usage:
  python3 topic_guide_generator.py --topic "醫療AI影像診斷"
  python3 topic_guide_generator.py --category research
  python3 topic_guide_generator.py --tag "影像診斷" --tag "人工智慧"
  python3 topic_guide_generator.py --topic "醫療AI" --out wiki/topics/medical-ai-guide.md
"""
from __future__ import annotations

import argparse
import json
import os
import re
import sys
import urllib.request
from datetime import datetime, timezone
from pathlib import Path

sys.path.insert(0, str(Path(__file__).resolve().parent))
import xkb_paths
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tools"))
from runtime_config import runtime_env

WORKSPACE = xkb_paths.WORKSPACE
_SKILL_DIR = Path(__file__).resolve().parent.parent
INDEX_PATH = WORKSPACE / "memory" / "bookmarks" / "search_index.json"
WIKI_DIR = xkb_paths.WIKI_DIR / "topics"
GEMINI_API_URL = "https://generativelanguage.googleapis.com/v1/models/gemini-2.5-flash:generateContent"


def _get_gemini_key() -> str:
    return runtime_env().get("GEMINI_API_KEY", "")


def _call_gemini(key: str, prompt: str, retries: int = 3) -> str:
    import time
    payload = json.dumps({"contents": [{"parts": [{"text": prompt}]}]}).encode()
    url = f"{GEMINI_API_URL}?key={key}"
    for attempt in range(retries):
        try:
            req = urllib.request.Request(url, data=payload, headers={"Content-Type": "application/json"})
            with urllib.request.urlopen(req, timeout=90) as r:
                data = json.loads(r.read())
            return data["candidates"][0]["content"]["parts"][0]["text"].strip()
        except Exception as e:
            if attempt < retries - 1:
                wait = (attempt + 1) * 5
                print(f"  retrying in {wait}s... ({e})")
                time.sleep(wait)
            else:
                raise


def load_cards(category: str | None, tags: list[str]) -> list[dict]:
    """Load relevant cards from the search index."""
    data = json.loads(INDEX_PATH.read_text(encoding="utf-8"))
    items = data.get("items", [])

    results = []
    for item in items:
        if category and item.get("category") != category:
            continue
        if tags:
            item_tags = [t.lower() for t in (item.get("tags") or [])]
            item_summary = (item.get("summary") or "").lower()
            if not any(t.lower() in " ".join(item_tags) + " " + item_summary for t in tags):
                continue
        results.append(item)

    return results


def build_card_digest(cards: list[dict]) -> str:
    """Build a condensed digest of cards for the LLM."""
    cards = cards[:15]  # Cap at 15 to keep prompt manageable
    parts = []
    for i, card in enumerate(cards, 1):
        title = card.get("title", "")[:100]
        summary = card.get("summary", "")[:200]
        card_tags = ", ".join((card.get("tags") or [])[:6])
        author = card.get("author", "")[:60]
        parts.append(
            f"[{i}] {title}\n"
            f"     Authors: {author}\n"
            f"     Tags: {card_tags}\n"
            f"     Summary: {summary}"
        )
    return "\n\n".join(parts)


def generate_topic_guide(topic: str, cards: list[dict], key: str) -> str:
    """Call Gemini to generate a structured topic guide."""
    digest = build_card_digest(cards)
    prompt = f"""You are a knowledge curator. Based on the following {len(cards)} knowledge cards about "{topic}", generate a comprehensive "Topic Guide" for someone new to this field.

The cards are:
{digest}

Generate the topic guide in Traditional Chinese with this exact structure:

# 領域導覽:{topic}

> 基於 {len(cards)} 篇相關文獻自動生成 | {datetime.now().strftime('%Y-%m-%d')}

## 一、這個領域在解決什麼問題?
(2-3 句話說明核心問題意識,為什麼這個領域值得研究)

## 二、必知基礎術語
列出 5-8 個關鍵術語,每個包含:
- **術語**:正確含義 + 常見誤解(如有)

## 三、建議閱讀順序
根據概念難度和依賴關係,列出建議的閱讀路徑:
### 第一步:建立基礎概念
- [論文編號] 標題 ——理由:為什麼先讀這篇?

### 第二步:深入方法論
- [論文編號] 標題 ——理由

### 第三步:進階應用與挑戰
- [論文編號] 標題 ——理由

(如有需要可增加步驟,每步驟1-3篇)

## 四、目前的知識共識
這個領域中,哪些觀點已有較多證據支持?(3-5點)

## 五、知識缺口與爭議
根據現有資料,哪些問題仍未解決或有爭議?(3-5點)

## 六、主要研究者與機構
列出收錄文獻中出現的重要作者或機構

## 七、延伸學習建議
根據目前收錄資料的缺口,建議下一步應該補充哪些方向的文獻?

---
*此導覽由 XKB 自動生成,基於已收錄的知識卡片。如需更新,重新執行 topic_guide_generator.py*"""

    return _call_gemini(key, prompt)


def main() -> int:
    parser = argparse.ArgumentParser(description="XKB 領域導覽生成器")
    parser.add_argument("--topic", help="主題名稱(用於標題和 LLM prompt)")
    parser.add_argument("--category", help="按 category 篩選 cards")
    parser.add_argument("--tag", action="append", dest="tags", default=[], help="按 tag 篩選(可重複)")
    parser.add_argument("--out", help="輸出檔案路徑(預設自動生成)")
    parser.add_argument("--min-cards", type=int, default=3, help="最少需要幾張 card 才生成(預設 3)")
    parser.add_argument("--dry-run", action="store_true", help="只顯示符合的 cards,不呼叫 LLM")
    args = parser.parse_args()

    if not args.topic and not args.category and not args.tags:
        print("Error: 需要至少一個篩選條件:--topic, --category, 或 --tag")
        return 1

    topic = args.topic or args.category or "+".join(args.tags)

    print(f"📚 篩選 cards:topic={topic}, category={args.category}, tags={args.tags}")
    cards = load_cards(args.category, args.tags)

    if not cards:
        print("❌ 沒有找到符合條件的 cards")
        return 1

    print(f"   找到 {len(cards)} 張 cards")
    for card in cards:
        print(f"   - {card.get('title','')[:70]}")

    if len(cards) < args.min_cards:
        print(f"⚠ cards 數量不足 {args.min_cards},跳過生成(用 --min-cards 1 強制執行)")
        return 0

    if args.dry_run:
        print("\n[dry-run] 不呼叫 LLM")
        return 0

    key = _get_gemini_key()
    if not key:
        print("❌ GEMINI_API_KEY not found")
        return 1

    print(f"\n🤖 呼叫 Gemini 生成領域導覽({len(cards)} 張 cards)...")
    guide = generate_topic_guide(topic, cards, key)

    # Determine output path
    if args.out:
        out_path = WORKSPACE / args.out if not Path(args.out).is_absolute() else Path(args.out)
    else:
        slug = re.sub(r"[^\w\-]", "-", topic.lower())[:40].strip("-")
        out_path = WIKI_DIR / f"{slug}-guide.md"

    out_path.parent.mkdir(parents=True, exist_ok=True)
    out_path.write_text(guide, encoding="utf-8")
    print(f"\n✅ 領域導覽已儲存:{out_path}")
    print(f"\n--- 預覽(前 500 字)---")
    print(guide[:500])

    return 0


import xkb_usage  # noqa: E402  — 量測誰在跑,見 scripts/xkb_usage.py

if __name__ == "__main__":
    xkb_usage.record(__file__)
    raise SystemExit(main())