Ctrl K
ComunidadGuías…

scripts/_card_prompt.py

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

Archivo original ↗Preguntar a KiBot
"""
_card_prompt.py

Shared knowledge card generation — used by ALL ingest scripts:
  run_scan_worker.py, local_ingest.py, fetch_youtube_playlist.py, fetch_github_repos.py

One unified 9-section format, source_type adapts per script.
"""
from __future__ import annotations

import os
import re
import sys
from pathlib import Path
from typing import Any

# ── Unified LLM helper (config/llm.json → single model setting) ──────────────
sys.path.insert(0, str(Path(__file__).parent))
import xkb_text
from _llm import call as _llm_call

# ── XBrain integration (path resolved by xbrain_recall, never hardcoded) ────
try:
    from xbrain_recall import (
        GBRAIN_DIR as _GBRAIN_DIR_OR_NONE,
        GBRAIN_AVAILABLE as _GBRAIN_AVAILABLE,
        GEMINI_API_KEY as _GEMINI_API_KEY,
        _make_subprocess_env,
    )
    _GBRAIN_DIR = _GBRAIN_DIR_OR_NONE
    _GBRAIN_CLI = str(_GBRAIN_DIR / "src" / "cli.ts") if _GBRAIN_DIR else ""
    _GBRAIN_ENV = _make_subprocess_env(semantic=True)
except ImportError:
    _GBRAIN_DIR = None
    _GBRAIN_CLI = ""
    _GBRAIN_AVAILABLE = False
    _GBRAIN_ENV = {**os.environ}


def gbrain_put(card_path: Path, slug: str) -> str:
    """Checked publication; failures remain in the durable outbox and raise."""
    from gbrain_publish import publish
    return publish(card_path, slug)

# ── Shared system prompt ──────────────────────────────────────────────────────
SYSTEM_PROMPT = """\
You are a knowledge card generator for a personal learning base. \
Given content from any source — academic paper, social media bookmark, \
YouTube video, GitHub repo, or local document — output one structured \
knowledge card in Traditional Chinese.

Strict rules:
- Leave sections empty with "無" if uncertain — never hallucinate
- Use only information from the provided content
- Do NOT use the reader's personal name in any section

Quality principles: conservative > hallucination, understanding > summary, structured > verbose"""

# ── Unified 9-section card template ──────────────────────────────────────────
CARD_PROMPT = """\
以下是一份知識來源的內容,請生成一張知識卡片。

如果內容包含 `## 10. Media Evidence`、`### OCR` 或 `### Vision Notes`:
- 必須把圖片視為一級來源,不是裝飾。
- 若圖片內有 prompt、表格、截圖文字或前後對比,請在卡片中保留可複用 pattern。
- 不要捏造圖片文字;OCR/vision notes 標示不確定時,卡片也要保守標示。
- 圖片證據請整合到各 section,並在最後保留 Media Evidence 摘要。

來源類型: {source_type_label}
來源網址: {source_url}
分類: {category}

內容:
{content}

{related_section}
請輸出以下格式(YAML frontmatter + Markdown):

---
id: {card_id}
type: knowledge-card
source_type: {source_type}
source_url: {source_url}
category: {category}
tags: [tag1, tag2, tag3]
sensitivity: public
confidence: medium
---

# <標題>

## 1. 核心問題與結論
- **提問**:這份內容試圖解答什麼問題?(一句話)
- **結論**:核心答案或發現是什麼?(一句話)
- **可信度說明**:有數據/實驗/引用支撐,還是個人意見?

## 2. Claim 等級
- **等級**:[Attested | Scholarship | Inference]
  - Attested:直接引用、有具體數據或實驗結果
  - Scholarship:作者/領域分析觀點,有明確來源依據
  - Inference:LLM 推論、個人猜測、尚未驗證的假設
- **主要主張**:(一句話說明被標記的核心主張)
- **依據**:(為什麼是這個等級?)

## 3. 關鍵論點
- 論點一
- 論點二
- 論點三

## 4. False Friends(如有)
這份內容涉及哪些看起來像普通詞彙但有特定技術含義的術語?
- term: (術語名稱)
  common_misunderstanding: (多數人誤以為是...)
  actual_meaning: (在此領域/內容中實際指的是...)
如果沒有:無

## 5. 驚訝點
讀者讀完後,可能感到意外或需要重新思考的是什麼?
(如果沒有明顯驚訝點,填「無」)

## 6. 與現有知識的關係
{related_cards_placeholder}

## 7. 雙語摘要(搜尋索引用)
ZH: <20-40字繁體中文摘要,說明核心發現>
EN: <15-30 word English summary of the core finding>

## 8. 對使用者的價值
- 可追蹤的方向
- 可執行的應用場景
- 適合哪個專案或工作流程

## 9. 原始來源
- 來源: {source_url}
- Links: (列出內容中出現的其他連結,如有)

## 10. Media Evidence(如有)
如果原文提供圖片 / OCR / Vision Notes,請列出:
- 圖片角色:prompt screenshot / result image / comparison / diagram / product mockup / unknown
- 圖片中最重要的可讀文字或 prompt 摘要
- 圖像 pattern:可複用的版面、風格、構圖、prompt 寫法
- 不確定處:不可讀文字、疑似誤辨、需要人工複核的地方
如果沒有圖片證據:無
"""

# Human-readable labels per source_type
SOURCE_LABELS: dict[str, str] = {
    "x-bookmark":   "X / Twitter 書籤",
    "youtube":      "YouTube 影片",
    "github-star":  "GitHub 倉庫(Star)",
    "github-fork":  "GitHub 倉庫(Fork)",
    "local-paper":  "學術論文 / 本地文件",
    "local":        "本地文件",
    "pubmed":       "PubMed / PMC 論文",
}


def source_label(source_type: str) -> str:
    return SOURCE_LABELS.get(source_type, source_type)


# ── LLM call ─────────────────────────────────────────────────────────────────

# 2000 是這個參數還會被丟掉的年代留下的預設值。現在它會一路傳到 API,
# 於是產卡的上限從 4096 悄悄變成 2000——沒有人改過產卡的需求。
def llm_call(prompt: str, api_key: str = "", max_tokens: int = 4096,
             system: str | None = SYSTEM_PROMPT) -> str:
    """api_key kept for backwards compatibility but is no longer used.
    Model is configured via config/llm.json.

    max_tokens 原本收下來就丟掉,現在會一路傳進 API payload。

    但要說清楚:**目前的供應商(tu-zi 的 gpt-5.6-luna)不理這個欄位**。
    2026-09-01 實測 max_tokens=16 與 =400 回傳長度完全相同,finish_reason
    都是 stop;換成 max_completion_tokens 也一樣。所以這個參數現在是「有
    傳,但不保證生效」——換供應商時它會自己開始起作用,不需要再改程式。

    留著它而不是拿掉,是因為長度控制真正靠的是提示詞裡那句「400 字以內」,
    而這個參數是第二道保險。一個被宣告、被刻意傳入、然後被默默丟棄的參數
    比不存在更糟,但一個傳到底、只是供應商不理的參數是誠實的。
    """
    return _llm_call(system or "", prompt, max_tokens=max_tokens)


# ── 長文 map-reduce 濃縮(TODOS 2026-07-13)─────────────────────────────────
# 卡片生成原本硬截 4000 字元,論文/長影片後半段直接丟失且不可逆。
# 超過門檻的內容改為:分段摘要(map)→ 合併(reduce),保留全文重點。

MAPREDUCE_THRESHOLD = int(os.getenv("XKB_MAPREDUCE_THRESHOLD", "4000"))
MAPREDUCE_CHUNK = int(os.getenv("XKB_MAPREDUCE_CHUNK", "6000"))
MAPREDUCE_MAX_CHUNKS = int(os.getenv("XKB_MAPREDUCE_MAX_CHUNKS", "12"))

_MAP_PROMPT = (
    "以下是一份長文件的第 {i}/{n} 段。請用繁體中文濃縮這一段的關鍵資訊"
    "(論點、數據、結論),400 字以內,保留專有名詞原文。只輸出濃縮內容:\n\n{chunk}"
)


def condense_long_content(content: str, verbose: bool = False) -> str:
    """長文 map-reduce:超過門檻的內容分段摘要後合併,取代硬截斷。
    LLM 失敗時退回舊行為(截斷),絕不阻斷 ingest 主流程。"""
    if len(content) <= MAPREDUCE_THRESHOLD:
        return content
    all_chunks = [content[i:i + MAPREDUCE_CHUNK]
                  for i in range(0, len(content), MAPREDUCE_CHUNK)]
    chunks = all_chunks[:MAPREDUCE_MAX_CHUNKS]
    # 超過上限的部分會被丟掉。原本丟得無聲無息,而產出的標頭還寫著「原文 N
    # 字元」——一份 20 萬字的文件少掉三分之二,卡片上看不出來。這正是這支
    # 函式要取代的那個行為,換一層又出現。
    dropped_chars = sum(len(c) for c in all_chunks[MAPREDUCE_MAX_CHUNKS:])
    if dropped_chars:
        print(f"  [map-reduce] 超出 {MAPREDUCE_MAX_CHUNKS} 段上限,"
              f"最後 {dropped_chars} 字元未納入濃縮", file=sys.stderr, flush=True)

    summaries: list[str] = []
    failed = 0
    for i, chunk in enumerate(chunks, 1):
        try:
            s = llm_call(_MAP_PROMPT.format(i=i, n=len(chunks), chunk=chunk),
                         max_tokens=600, system=None)
            summaries.append(s.strip())
            if verbose:
                print(f"  [map-reduce] chunk {i}/{len(chunks)} → {len(s)} chars")
        except Exception as e:
            # 一段失敗不該丟掉已經付過錢的其他段。原本直接退回截斷,
            # 等於把十一次成功的呼叫連同結果一起扔掉。
            failed += 1
            summaries.append(f"(第 {i} 段濃縮失敗,內容未納入)")
            if verbose:
                print(f"  [map-reduce] chunk {i} failed ({e})")
    if not any(not x.startswith("(第 ") for x in summaries):
        # 全部都失敗才退回截斷——那時候確實沒有東西可用。
        return content[:MAPREDUCE_THRESHOLD]

    notes = []
    if dropped_chars:
        notes.append(f"末尾 {dropped_chars} 字元超出分段上限未納入")
    if failed:
        notes.append(f"{failed}/{len(chunks)} 段濃縮失敗")
    caveat = f"({';'.join(notes)})" if notes else ""

    head = content[:800]
    reduced = "(以下為長文件分段濃縮,原文 %d 字元%s)\n\n%s\n\n--- 原文開頭 ---\n%s" % (
        len(content), caveat, "\n\n".join(summaries), head)
    return reduced


# ── Summary extraction ────────────────────────────────────────────────────────

def extract_summary(card: str) -> str:
    """Extract ZH + EN summary from section 7, with legacy fallback."""
    # New format: ## 7. 雙語摘要 with ZH:/EN: lines
    bilingual = re.search(
        r"##\s+7\.\s*雙語摘要[^\n]*\n(.+?)(?=\n##|\Z)", card, re.DOTALL
    )
    if bilingual:
        block = bilingual.group(1)
        zh_m = re.search(r"^ZH:\s*(.+)$", block, re.MULTILINE)
        en_m = re.search(r"^EN:\s*(.+)$", block, re.MULTILINE)
        parts = [m.group(1).strip() for m in [zh_m, en_m] if m and m.group(1).strip()]
        if parts:
            return " | ".join(parts)
    # Legacy
    zh = re.search(r"##\s*📝 一句話摘要\s*\n+(.+?)(\n##|\Z)", card, re.DOTALL)
    en = re.search(r"##\s*📝 English Summary\s*\n+(.+?)(\n##|\Z)", card, re.DOTALL)
    parts = [x.group(1).strip() for x in [zh, en] if x]
    if parts:
        return " | ".join(parts)
    lines = [l.strip() for l in card.splitlines()
             if l.strip() and not l.startswith("#") and not l.startswith("---")]
    return lines[0] if lines else ""


# ── Related context search ────────────────────────────────────────────────────

def find_related_context(content: str, existing_items: list[dict], top_k: int = 3) -> str:
    """Find related cards for context injection.
    Uses gbrain hybrid search if available, falls back to keyword search against existing_items."""
    if _GBRAIN_AVAILABLE:
        try:
            from xbrain_recall import xbrain_query as gbrain_query
            query = content[:300].replace("\n", " ").strip()
            results = gbrain_query(query, limit=top_k, no_expand=True)
            if results:
                lines = []
                for r in results:
                    title = r.get("title", r.get("slug", ""))[:60]
                    chunk = r.get("chunk_text", "")[:100].replace("\n", " ")
                    lines.append(f"- **{title}**:{chunk}")
                return "\n".join(lines)
        except Exception:
            pass

    # Keyword fallback
    # 斷詞只有一個定義:xkb_text。這裡原本各自帶一份只切 2-gram、
    # 而且把整串中文吃成一個 token 的正則。
    tokens = set(xkb_text.tokenize(content[:1000], xkb_text.STOPWORDS))
    if not tokens:
        return "(無相關既有卡片)"

    scored = []
    for item in existing_items:
        combined = " ".join([
            (item.get("title") or "").lower(),
            (item.get("summary") or "").lower(),
            " ".join(item.get("tags") or []).lower(),
        ])
        score = sum(1 for t in tokens if t in combined)
        if score > 0:
            scored.append((item, score))
    scored.sort(key=lambda x: x[1], reverse=True)
    if not scored:
        return "(無相關既有卡片)"

    lines = []
    for item, _ in scored[:top_k]:
        lines.append(
            f"- **{item.get('title', '(untitled)')}**:"
            f"{(item.get('summary') or '')[:80]}"
        )
    return "\n".join(lines)


def build_prompt(
    content: str,
    card_id: str,
    source_type: str,
    source_url: str,
    category: str,
    related_context: str = "(無相關既有卡片)",
) -> str:
    """Fill in the unified CARD_PROMPT template."""
    return CARD_PROMPT.format(
        source_type_label=source_label(source_type),
        source_url=source_url,
        category=category,
        content=content,
        card_id=card_id,
        source_type=source_type,
        related_section=(
            f"相關既有卡片(供 Section 6 參考):\n{related_context}\n"
            if related_context else ""
        ),
        related_cards_placeholder=related_context,
    )