""" _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, )