#!/usr/bin/env python3 """對話主動召回:根據當前對話 query,從 X 書籤索引找出最相關的知識卡。""" from __future__ import annotations import argparse import json import math import re import os import sys from pathlib import Path from typing import List, Dict, Any sys.path.insert(0, str(Path(__file__).resolve().parent)) import xkb_paths import xkb_text from xkb_mmr import mmr_select from xkb_provenance import is_self_derived WORKSPACE_DIR = xkb_paths.WORKSPACE BOOKMARKS_DIR = xkb_paths.BOOKMARKS_DIR INDEX_FILE = xkb_paths.INDEX_FILE VECTOR_FILE = xkb_paths.VECTOR_FILE TOPIC_PROFILE_FILE = xkb_paths.TOPIC_PROFILE_FILE _SKILL_DIR = xkb_paths.SKILL_DIR WIKI_TOPICS_DIR = xkb_paths.WIKI_TOPICS_DIR GENERIC_CATEGORIES = {"general", "99-general", "other", "misc", "uncategorized"} LOW_SIGNAL_SUMMARIES = {"(待整理)", "待整理", "todo", "tbd", "n/a"} LOW_SIGNAL_TITLES = {"(untitled)", "untitled", "tweet"} LOW_SIGNAL_SOURCES = {"x", "twitter"} STOPWORDS = { "的", "了", "是", "我", "你", "他", "她", "它", "在", "有", "和", "與", "就", "也", "都", "很", "想", "要", "用", "讓", "把", "跟", "對", "中", "上", "下", "嗎", "呢", "啊", "吧", "這", "那", "this", "that", "with", "from", "have", "will", "about", "into", "your", "their", "they", "them", "what", "when", "where", "which", "how", "why", "for", "and", "the", "are", "was", "were", "been", } def load_index(index_file: Path) -> Dict[str, Any]: if not index_file.exists(): raise FileNotFoundError(f"search index not found: {index_file}") return json.loads(index_file.read_text(encoding="utf-8")) def tokenize(text: str) -> List[str]: return xkb_text.tokenize(text, STOPWORDS) def clean_summary(text: str) -> str: text = (text or "").strip() text = re.sub(r"^#+\s*", "", text) text = re.sub(r"^一句話摘要\s*", "", text) text = re.sub(r"^[-•]\s*", "", text) text = re.sub(r"\s+", " ", text) return text.strip() def load_topic_profile(topic_profile_file: Path = TOPIC_PROFILE_FILE) -> Dict[str, Any]: if not topic_profile_file.exists(): return {} try: return json.loads(topic_profile_file.read_text(encoding="utf-8")) except (json.JSONDecodeError, OSError): return {} def get_topic_profile_matches(query_tokens: List[str], topic_profile: Dict[str, Any]) -> Dict[str, Any]: categories = {item.get("name", ""): item.get("weight", 0.0) for item in topic_profile.get("top_categories", [])} tags = {item.get("name", ""): item.get("weight", 0.0) for item in topic_profile.get("top_tags", [])} matched_categories = [name for name in categories if name and any(token in name or name in token for token in query_tokens)] matched_tags = [name for name in tags if name and any(token == name or token in name or name in token for token in query_tokens)] cat_boost = max((categories[name] for name in matched_categories), default=0.0) tag_boost = max((tags[name] for name in matched_tags), default=0.0) combined = max(cat_boost, tag_boost) return { "matched_categories": matched_categories[:3], "matched_tags": matched_tags[:5], "topic_boost": round(combined, 4), } def _is_valid_source_url(url: str) -> bool: url = (url or "").strip() if not url.startswith(("http://", "https://")): return False x_status = re.search(r"https?://(?:x|twitter)\.com/[^\s/]+/status/(\d{15,20})(?:\b|/|\?)", url) x_i_status = re.search(r"https?://x\.com/i/status/(\d{15,20})(?:\b|/|\?)", url) if ("x.com" in url or "twitter.com" in url) and not (x_status or x_i_status): return False return True def _normalize_source_url(url: str) -> str: url = (url or "").strip().strip('"') if not _is_valid_source_url(url): return "" return url def extract_source_url(md_path: Path) -> str: if not md_path.exists(): return "" text = md_path.read_text(encoding="utf-8", errors="ignore") patterns = [ r"^source_url:\s*\"?([^\"\n]+)\"?\s*$", r"^original_url:\s*\"?([^\"\n]+)\"?\s*$", r"\*\*原始連結\*\*:\s*(\S+)", r"https://x\.com/\S+", r"https://twitter\.com/\S+", ] for pattern in patterns: m = re.search(pattern, text, re.MULTILINE) if m: url = m.group(1).strip() if m.groups() else m.group(0).strip() normalized = _normalize_source_url(url) if normalized: return normalized return "" def build_relevance_reason(item: Dict[str, Any], query_tokens: List[str], topic_matches: Dict[str, Any] | None = None) -> str: title = (item.get("title") or "").lower() category = (item.get("category") or "").lower() tags = " ".join(item.get("tags") or []).lower() summary = (item.get("summary") or "").lower() reasons = [] title_hits = [t for t in query_tokens if t in title] tag_hits = [t for t in query_tokens if t in tags] category_hits = [t for t in query_tokens if t in category] summary_hits = [t for t in query_tokens if t in summary] if title_hits: reasons.append(f"標題命中:{'、'.join(title_hits[:3])}") if tag_hits: reasons.append(f"標籤接近:{'、'.join(tag_hits[:3])}") if category_hits: reasons.append(f"分類相關:{'、'.join(category_hits[:3])}") if summary_hits and not reasons: reasons.append(f"摘要語意接近:{'、'.join(summary_hits[:3])}") if topic_matches: if topic_matches.get("matched_categories"): reasons.append(f"命中使用者高頻分類:{'、'.join(topic_matches['matched_categories'][:2])}") elif topic_matches.get("matched_tags"): reasons.append(f"命中使用者高頻標籤:{'、'.join(topic_matches['matched_tags'][:3])}") return ";".join(reasons[:2]) or "主題與當前對話高度相關" def score_item(item: Dict[str, Any], query_tokens: List[str], query_text: str) -> int: title = (item.get("title") or "").lower() category = (item.get("category") or "").lower() tags = " ".join(item.get("tags") or []).lower() summary = (item.get("summary") or "").lower() blob = (item.get("searchable") or "").lower() score = 0 for token in query_tokens: if token in title: score += 8 if token in tags: score += 6 if token in category: score += 4 if token in summary: score += 3 if token in blob: score += 1 if query_text and query_text in blob: score += 8 # 偏好有摘要、有 tags 的卡片,較適合直接對話回用 if item.get("summary"): score += 2 if item.get("tags"): score += 1 return score def _keyword_score(query: str, item: dict) -> float: """Fraction of query tokens found in title + tags + summary.""" tokens = tokenize(query) if not tokens: return 0.0 text = " ".join([ (item.get("title") or "").lower(), " ".join(item.get("tags") or []).lower(), (item.get("summary") or "").lower(), ]) return xkb_text.overlap_score(tokens, text) def _cosine_similarity(a: list, b: list) -> float: dot = sum(x * y for x, y in zip(a, b)) norm_a = math.sqrt(sum(x * x for x in a)) norm_b = math.sqrt(sum(y * y for y in b)) denom = norm_a * norm_b return dot / denom if denom > 1e-9 else 0.0 def _normalize_vector(vec: Any) -> list[float] | None: if not isinstance(vec, list) or not vec: return None normalized = [] for value in vec: if not isinstance(value, (int, float)) or isinstance(value, bool): return None normalized.append(float(value)) return normalized def _source_quality(item: Dict[str, Any]) -> float: source = (item.get("source") or "").strip().lower() source_url = (item.get("source_url") or "").strip().lower() title = (item.get("title") or "").strip().lower() score = 0.0 if source == "youtube": score += 0.05 if source_url.startswith("https://"): score += 0.05 if "github.com" in source_url: score += 0.05 if any(host in source_url for host in ["x.com/", "twitter.com/"]): score += 0.02 if re.match(r"^\d{4}-\d{2}-\d{2}-", title): score -= 0.08 if source in LOW_SIGNAL_SOURCES and not source_url: score -= 0.03 return score def _summary_quality(summary: str) -> float: summary = clean_summary(summary) if not summary: return -0.08 if summary in LOW_SIGNAL_SUMMARIES: return -0.12 if len(summary) < 12: return -0.05 if len(summary) >= 40: return 0.05 return 0.02 def _title_quality(title: str) -> float: title = (title or "").strip() lowered = title.lower() if not title: return -0.12 if lowered in LOW_SIGNAL_TITLES: return -0.15 if re.fullmatch(r"tweet\s+\d{15,20}", lowered): return -0.14 if re.fullmatch(r"\d{15,20}", title): return -0.16 if re.match(r"^\d{4}-\d{2}-\d{2}-", title) and len(title) < 42: return -0.08 if len(title) < 8: return -0.05 return 0.03 def _category_penalty(item: Dict[str, Any]) -> float: category = (item.get("category") or "").strip().lower() return -0.08 if category in GENERIC_CATEGORIES else 0.0 # 回音室防護(TODOS 2026-07-13):對話蒸餾而來的知識(self-derived)召回時降權, # 避免「收藏 → 召回 → 討論 → 再入庫」閉環讓既有觀點自我強化。 SELF_DERIVED_PENALTY = float(os.getenv("XKB_SELF_DERIVED_PENALTY", "-0.15")) # 索引項目實際有的欄位:category, enriched, mtime, path, relative_path, # searchable, size, source_type, source_url, summary, tags, title。 # 原本這裡讀的是 provenance / content / source_file / section_text——四個 # 都不存在,所以這道防護從第一天起就沒有觸發過一次。 PROVENANCE_FIELDS = ("summary", "searchable", "relative_path", "path", "title") def _provenance_only(item: Dict[str, Any]) -> Dict[str, float]: """只帶出處降權的調整包。gbrain 路徑沒有主題加權可算。""" penalty = _provenance_penalty(item) return {"provenance": penalty, "total_adjustment": penalty} def _provenance_penalty(item: Dict[str, Any]) -> float: if (item.get("provenance") or "").strip().lower() == "self-derived": return SELF_DERIVED_PENALTY # 路徑要一起看:self-derived 的標記常常在 memory/YYYY-MM-DD 這種路徑上, # 而不是在摘要文字裡。 text = " ".join(str(item.get(k) or "") for k in PROVENANCE_FIELDS) if is_self_derived(text): return SELF_DERIVED_PENALTY return 0.0 def _should_filter_result(item: Dict[str, Any], source_url: str) -> bool: title = (item.get("title") or "").strip() summary = clean_summary(item.get("summary") or "") category = (item.get("category") or "").strip().lower() if item.get("excluded"): return True if summary in LOW_SIGNAL_SUMMARIES and not source_url: return True if re.fullmatch(r"\d{15,20}", title): return True if re.fullmatch(r"tweet\s+\d{15,20}", title.lower()) and summary in LOW_SIGNAL_SUMMARIES: return True if category in GENERIC_CATEGORIES and summary in LOW_SIGNAL_SUMMARIES: return True return False def _ranking_adjustments(item: Dict[str, Any], topic_matches: Dict[str, Any]) -> Dict[str, float]: topic_boost = topic_matches.get("topic_boost", 0.0) matched_categories = set(topic_matches.get("matched_categories", [])) matched_tags = set(topic_matches.get("matched_tags", [])) item_category = (item.get("category") or "").lower() item_tags = {str(tag).lower() for tag in (item.get("tags") or [])} topic_bonus = 0.0 if topic_boost > 0: if item_category in matched_categories: topic_bonus += round(topic_boost * 0.12, 4) elif item_tags & matched_tags: topic_bonus += round(topic_boost * 0.08, 4) summary_bonus = _summary_quality(item.get("summary") or "") title_bonus = _title_quality(item.get("title") or "") source_bonus = _source_quality(item) category_penalty = _category_penalty(item) provenance_penalty = _provenance_penalty(item) return { "topic_bonus": topic_bonus, "summary_bonus": summary_bonus, "title_bonus": title_bonus, "source_bonus": source_bonus, "category_penalty": category_penalty, "provenance_penalty": provenance_penalty, "total_adjustment": round(topic_bonus + summary_bonus + title_bonus + source_bonus + category_penalty + provenance_penalty, 4), } def _display_title(item: dict) -> str: """Return a human-readable title. If title is just a tweet ID, use summary snippet.""" import re as _re title = (item.get("title") or "").strip() if title and not _re.match(r"^\d{10,}$", title): return title summary = (item.get("summary") or "").strip() if summary and summary not in ("待整理", "待補充") and not summary.startswith("###"): first = summary.split("。")[0].split(".")[0][:60].strip() if first: return first + "…" return title or "(untitled)" def gbrain_semantic_recall(query: str, limit: int = 5) -> List[Dict[str, Any]]: """ Recall via gbrain hybrid search (pgvector RRF + Gemini). Returns same format as semantic_recall() for drop-in replacement. Falls back silently if gbrain is not available. """ try: sys.path.insert(0, str(Path(__file__).resolve().parent)) from xbrain_recall import xbrain_query as gbrain_query except ImportError: return [] try: raw = gbrain_query(query, limit=limit) except RuntimeError: return [] results = [] for item in raw: chunk = item.get("chunk_text", "") # Extract title from first markdown heading title_m = re.search(r"^#\s+(.+)$", chunk, re.MULTILINE) title = title_m.group(1).strip() if title_m else item.get("title") or item.get("slug", "") # Extract summary from 雙語摘要 ZH line zh_m = re.search(r"ZH[:\s]+(.+?)(?:\n|EN:|$)", chunk, re.IGNORECASE) summary = zh_m.group(1).strip() if zh_m else chunk[:120].replace("\n", " ").strip() source_url = item.get("source_url") or "" score = item.get("score", 0.0) slug = item.get("slug", "") # 出處懲罰算一次就好。原本同一個 dict literal 建了兩次, # 一次進分數、一次進說明——同一個量兩個來源,正是這個專案的老毛病。 _adjustments = _provenance_only({ "summary": summary, "relative_path": f"cards/{slug}.md", "title": title, }) results.append({ "title": title, "summary": summary, "category": item.get("type", "knowledge-card"), "tags": [], "relative_path": f"cards/{slug}.md", "source_url": source_url, # 懲罰要進分數。原本只放進 ranking_adjustments 給人看,score # 還是原始 RRF——於是回音室防護在這台機器實際會走的路徑上, # 算得出來、顯示得出來、就是不會影響排序。 "score": round(score + _adjustments["total_adjustment"], 4), "topic_boost": 0.0, "matched_categories": [], "matched_tags": [], # gbrain 是這台機器上實際會走的模式,而它原本完全不算調整—— # 於是回音室防護在主要路徑上等於不存在。 # 要看組好的欄位(摘要、路徑),不是 gbrain 回傳的原始 item: # self-derived 的線索在 relative_path 與 summary 上。 "ranking_adjustments": _adjustments, "relevance_reason": f"gbrain 語意+關鍵字混合 RRF ({score:.4f})", }) # 另外兩條 semantic_recall 都是 sort 之後才 [:limit],只有這條直接回 # gbrain 的原始順序——而這條正是這台機器實際會走的。同一個函式契約、 # 三個實作、兩個排序,於是任何加減分在這裡都不會改變輸出。 results.sort(key=lambda r: r["score"], reverse=True) return results[:limit] def wiki_recall(query: str, limit: int = 2) -> List[Dict[str, Any]]: """第一層召回:從 wiki/topics/*.md 找合成知識段落。""" if not WIKI_TOPICS_DIR.exists(): return [] query_tokens = tokenize(query) if not query_tokens: return [] results = [] for path in WIKI_TOPICS_DIR.glob("*.md"): try: content = path.read_text(encoding="utf-8", errors="ignore") except OSError: continue # 從 frontmatter 取 title fm_match = re.match(r"^---\n(.*?)\n---\n", content, re.DOTALL) topic_title = path.stem if fm_match: title_m = re.search(r"^title:\s*(.+)$", fm_match.group(1), re.MULTILINE) if title_m: topic_title = title_m.group(1).strip().strip('"') # 移除 frontmatter,切成 ## 段落 body = re.sub(r"^---\n.*?\n---\n", "", content, flags=re.DOTALL).strip() sections = re.split(r"\n(?=##+ )", body) for section in sections: section = section.strip() if not section: continue first_line = section.split("\n")[0] section_title = re.sub(r"^#+\s*", "", first_line).strip() section_body = "\n".join(section.split("\n")[1:]).strip() if not section_body: continue text_lower = (section_title + " " + section_body).lower() score = 0 for token in query_tokens: if token in section_title.lower(): score += 6 elif token in text_lower: score += 2 if score < 4: continue # 擷取有意義的摘錄(跳過純分隔線) excerpt_lines = [l for l in section_body.split("\n") if l.strip() and not re.fullmatch(r"-{3,}", l.strip())] excerpt = " ".join(excerpt_lines)[:240].strip() if len(excerpt) >= 240: excerpt = excerpt.rsplit(" ", 1)[0] + "…" results.append({ "topic_title": topic_title, "section_title": section_title, "excerpt": excerpt, "path": f"wiki/topics/{path.name}", "score": score, }) results.sort(key=lambda x: x["score"], reverse=True) # 原本是「每個 topic 只留最高分的一段」。那條規則兩邊都鈍:同一頁的第二段 # 就算講的是完全不同的事也會被丟掉,而兩段幾乎一樣的內容只要分屬不同頁就 # 都會留下。治理開始讓每則升級的論點各自成段之後,一頁可以有好幾百段, # 這條規則等於讓一頁的內容在每次召回時互相淘汰,只出得來一段。 # # 改成 MMR:一段要「比已選的多說了些什麼」才留。分數接近時它會挑不重複的, # 分數差很多時不會為了多樣性硬塞——後者靠函式內的正規化保證。 return mmr_select( results, limit, text=lambda item: f"{item['section_title']} {item['excerpt']}", ) def semantic_recall(query: str, limit: int, vector_file: Path = VECTOR_FILE, index_file: Path = INDEX_FILE, topic_profile_file: Path = TOPIC_PROFILE_FILE) -> List[Dict[str, Any]]: """Semantic recall using vector similarity. Falls back to keyword if index missing.""" if not vector_file.exists(): print(f"⚠️ Vector index not found: {vector_file}", file=sys.stderr) print(" Falling back to keyword search. Run: python3 scripts/build_vector_index.py", file=sys.stderr) return [] # Load vector index import json as _json vdata = _json.loads(vector_file.read_text(encoding="utf-8")) vectors = vdata.get("vectors", {}) if not vectors: return [] normalized_vectors: dict[str, list[float]] = {} expected_dim = None for rel_path, raw_vec in vectors.items(): norm_vec = _normalize_vector(raw_vec) if norm_vec is None: print(f"⚠️ Invalid vector payload for: {rel_path}", file=sys.stderr) continue if expected_dim is None: expected_dim = len(norm_vec) elif len(norm_vec) != expected_dim: print( f"⚠️ Vector dimension mismatch for: {rel_path} " f"(expected {expected_dim}, got {len(norm_vec)})", file=sys.stderr, ) continue normalized_vectors[rel_path] = norm_vec if not normalized_vectors: print("⚠️ No valid vectors found in vector index. Falling back to keyword search.", file=sys.stderr) return [] # Embed the query try: import sys as _sys, os as _os _skill_dir = Path(__file__).resolve().parent.parent if str(_skill_dir) not in _sys.path: _sys.path.insert(0, str(_skill_dir)) from tools.embedding_providers import get_provider provider = get_provider() query_vec = provider.embed(query) query_vec = _normalize_vector(query_vec) if query_vec is None: print("⚠️ Query embedding payload is invalid. Falling back to keyword search.", file=sys.stderr) return [] if expected_dim is not None and len(query_vec) != expected_dim: print( f"⚠️ Query embedding dimension mismatch (expected {expected_dim}, got {len(query_vec)}). " "Falling back to keyword search.", file=sys.stderr, ) return [] except EnvironmentError as e: print(f"⚠️ {e}", file=sys.stderr) print(" Falling back to keyword search.", file=sys.stderr) return [] except Exception as e: print(f"⚠️ Embedding failed: {e}", file=sys.stderr) return [] # Compute cosine similarity, then aggregate to card level # (論點級向量鍵為 relpath#kpN,同卡取最高分 — TODOS 2026-07-13) best: dict[str, float] = {} for key, vec in normalized_vectors.items(): sim = _cosine_similarity(query_vec, vec) base = key.split("#", 1)[0] if sim > best.get(base, -1.0): best[base] = sim scored = sorted(best.items(), key=lambda x: x[1], reverse=True) top = scored[:limit * 2] # fetch extra to allow filtering # Load search index for metadata data = load_index(index_file) items = data.get("items", []) index_by_path = { (item.get("relative_path") or item.get("path") or ""): item for item in items } query_tokens = tokenize(query) topic_profile = load_topic_profile(topic_profile_file) topic_matches = get_topic_profile_matches(query_tokens, topic_profile) if topic_profile else {} results = [] for rel_path, sim in top: if sim < 0.25: break item = index_by_path.get(rel_path) if not item: continue source_url = _normalize_source_url(item.get("source_url") or "") if not source_url: md_path = BOOKMARKS_DIR / rel_path if not rel_path.startswith("/") else Path(rel_path) source_url = extract_source_url(md_path) kw = _keyword_score(query, item) hybrid = 0.65 * sim + 0.35 * kw adjustments = _ranking_adjustments(item, topic_matches) final_score = round(hybrid + adjustments["total_adjustment"], 4) if _should_filter_result(item, source_url): continue reason = [f"語意 {sim:.0%}", f"關鍵字 {kw:.0%}"] if adjustments["topic_bonus"] > 0: reason.append(f"主題加權 +{adjustments['topic_bonus']:.2f}") if adjustments["summary_bonus"] != 0: reason.append(f"摘要調整 {adjustments['summary_bonus']:+.2f}") if adjustments["title_bonus"] != 0: reason.append(f"標題調整 {adjustments['title_bonus']:+.2f}") if adjustments["source_bonus"] != 0: reason.append(f"來源調整 {adjustments['source_bonus']:+.2f}") if adjustments["category_penalty"] != 0: reason.append(f"泛分類調整 {adjustments['category_penalty']:+.2f}") results.append({ "title": _display_title(item), "summary": clean_summary(item.get("summary") or ""), "category": item.get("category") or "general", "tags": item.get("tags") or [], "relative_path": rel_path, "source_url": source_url, "score": final_score, "topic_boost": topic_matches.get("topic_boost", 0.0), "matched_categories": topic_matches.get("matched_categories", []), "matched_tags": topic_matches.get("matched_tags", []), "ranking_adjustments": adjustments, "relevance_reason": " + ".join(reason), }) # Re-sort by hybrid score and trim to limit results.sort(key=lambda x: x["score"], reverse=True) return results[:limit] def recall(query: str, limit: int, min_score: int, index_file: Path = INDEX_FILE, topic_profile_file: Path = TOPIC_PROFILE_FILE) -> List[Dict[str, Any]]: data = load_index(index_file) items = data.get("items", []) query = query.strip() query_tokens = tokenize(query) if not query_tokens and not query: return [] topic_profile = load_topic_profile(topic_profile_file) topic_matches = get_topic_profile_matches(query_tokens, topic_profile) if topic_profile else {} topic_boost = topic_matches.get("topic_boost", 0.0) results = [] for item in items: base_score = float(score_item(item, query_tokens, query.lower())) adjustments = _ranking_adjustments(item, topic_matches) score = base_score + adjustments["total_adjustment"] * 10 if score < min_score: continue rel_path = item.get("relative_path") or item.get("path") or "" # Use pre-indexed source_url first; fall back to file scan only if missing source_url = _normalize_source_url(item.get("source_url") or "") if not source_url: md_path = BOOKMARKS_DIR / rel_path if rel_path and not rel_path.startswith("/") else Path(rel_path) source_url = extract_source_url(md_path) if _should_filter_result(item, source_url): continue results.append({ "title": _display_title(item), "summary": clean_summary(item.get("summary") or ""), "category": item.get("category") or "general", "tags": item.get("tags") or [], "relative_path": rel_path, "source_url": source_url, "score": round(score, 4), "topic_boost": topic_boost, "matched_categories": topic_matches.get("matched_categories", []), "matched_tags": topic_matches.get("matched_tags", []), "ranking_adjustments": adjustments, "relevance_reason": build_relevance_reason(item, query_tokens, topic_matches), }) results.sort(key=lambda x: x["score"], reverse=True) return results[:limit] def print_markdown(wiki_hits: List[Dict[str, Any]], results: List[Dict[str, Any]], query: str) -> None: if not wiki_hits and not results: print("沒有找到適合主動召回的知識。") return print(f"# 對話主動召回結果\n") print(f"查詢:{query}\n") if wiki_hits: print("## Wiki 知識層\n") for hit in wiki_hits: print(f"### {hit['topic_title']} — {hit['section_title']}") print(f"{hit['excerpt']}") print(f"- 來源:{hit['path']}\n") if results: print("## 原始卡片層\n") for idx, item in enumerate(results, start=1): print(f"### {idx}. {item['title']}") print(f"- 分類:{item['category']}") if item.get("tags"): print(f"- 標籤:{', '.join(item['tags'][:8])}") print(f"- 相關原因:{item['relevance_reason']}") print(f"- 分數:{item['score']}") if item.get("summary"): print(f"- 一句話摘要:{item['summary'][:180]}") if item.get("source_url"): print(f"- 原文連結:{item['source_url']}") print(f"- 檔案:{item['relative_path']}\n") def print_prompt(wiki_hits: List[Dict[str, Any]], results: List[Dict[str, Any]], query: str) -> None: if not wiki_hits and not results: print("NO_RECALL") return if wiki_hits: print("根據你的知識庫,這個主題有以下整理:") for hit in wiki_hits: print(f"- [{hit['topic_title']}] {hit['section_title']}:{hit['excerpt'][:120]}") if results: print("相關原始書籤:") for item in results: print(f"- {item['title']}") if item.get("summary"): print(f" 摘要:{item['summary'][:120]}") print(f" 為什麼相關:{item['relevance_reason']}") if item.get("source_url"): print(f" 原文:{item['source_url']}") else: print(f" 檔案:{item['relative_path']}") def print_chat(wiki_hits: List[Dict[str, Any]], results: List[Dict[str, Any]], query: str) -> None: if not wiki_hits and not results: print("NO_RECALL") return if wiki_hits: hit = wiki_hits[0] print(f"[知識庫] {hit['topic_title']}:{hit['excerpt'][:120]}") if results: top = results[:2] print("相關書籤:") for item in top: summary = item.get("summary") or "這篇和你現在聊的主題接近。" reason = item.get("relevance_reason") or "主題接近" print(f"- {item['title']}:{summary[:90]}") print(f" 為什麼相關:{reason}") if item.get("source_url"): print(f" 原文:{item['source_url']}") MODE_LABELS = { "gbrain": "gbrain 混合搜尋(RRF + Gemini)", "semantic": "語意向量搜尋", "keyword": "關鍵字搜尋", "keyword_fallback": "關鍵字搜尋(語意降級)", } def _fts_leg(query: str, limit: int) -> List[Dict[str, Any]]: """BM25 腿。fts_recall 載不進來或索引沒建就回空——不是「查無資料」的訊號, 但也不該讓整條召回中止。""" try: from fts_recall import fts_search except ImportError: return [] try: return fts_search(query, limit) except Exception as e: # noqa: BLE001 try: import xkb_failures xkb_failures.note("bm25 leg", e) except Exception: # noqa: BLE001 pass return [] def search( query: str, limit: int = 3, min_score: int = 6, *, index_file: Path | None = None, vector_file: Path | None = None, topic_profile_file: Path | None = None, no_wiki: bool = False, no_semantic: bool = False, force_semantic: bool = False, force_gbrain: bool = False, fts_only: bool = False, with_bm25: bool = False, # 概念查詢會被 n-gram 撞到,呼叫端明確開 ) -> dict: """兩層召回:先查合成過的 wiki 知識,再查卡片細節。 這段原本長在 main() 裡,所以想用它的人只能另外開一個 Python 行程—— 父行程已經為了語意召回載入過向量索引,子行程再載入一次,一次 hard 召回 因此多付 1.3 秒,而 hook 只給六秒。搬出來之後兩邊呼叫同一份程式, main() 回歸它本來的角色:命令列的殼。 """ index_path = Path(index_file) if index_file else INDEX_FILE vector_path = Path(vector_file) if vector_file else VECTOR_FILE profile_path = Path(topic_profile_file) if topic_profile_file else TOPIC_PROFILE_FILE # 第一層:wiki 知識層(先查合成知識)。fts_only 是 light 掃描的純 BM25 # 模式,呼叫端已經自己查過 wiki,這裡再查一次只是丟掉——跳過。 wiki_hits: List[Dict[str, Any]] = ([] if (no_wiki or fts_only) else wiki_recall(query, limit=2)) # 第二層:cards 細節層(gbrain > semantic > keyword) try: sys.path.insert(0, str(Path(__file__).resolve().parent)) from xbrain_recall import GBRAIN_AVAILABLE as _gbrain_available, GEMINI_API_KEY as _gemini_key except ImportError: _gbrain_available = False _gemini_key = "" use_gbrain = force_gbrain or (not no_semantic and _gbrain_available and bool(_gemini_key)) search_mode = "keyword" if fts_only: # light 掃描專用:跳過所有語意後端,只跑 BM25 腿——一次 SQLite MATCH, # 不打 embedding,light 幾乎每句都跑得起。 results = _fts_leg(query, limit) search_mode = "bm25" if results else "bm25_empty" elif use_gbrain: results = gbrain_semantic_recall(query, limit) if results: search_mode = "gbrain" else: results = recall(query, limit, min_score, index_path, profile_path) search_mode = "keyword_fallback" elif (not no_semantic) and (force_semantic or vector_path.exists()): results = semantic_recall(query, limit, vector_path, index_path, profile_path) if results: search_mode = "semantic" else: results = recall(query, limit, min_score, index_path, profile_path) search_mode = "keyword_fallback" else: results = recall(query, limit, min_score, index_path, profile_path) # BM25 腿:任何語意後端跑完後都併一次(fts_only 模式上面已經跑過,跳過)。 # 這條腿跟語意腿對等——語意查詢語意腿扛,字面查詢 BM25 扛,xkb_score.rank() # 用 RRF 融合。BM25 結果帶 score_scale=card_bm25 / wiki_bm25,自成一條腿。 if not fts_only and with_bm25: # 不在這裡去重。同一張卡被語意腿和 BM25 都撈到是最強訊號——兩筆都要 # 進 xkb_score.rank(),它按 _key 合併、把兩條腿的 RRF 貢獻都算進去。 # with_bm25 由呼叫端按查詢形狀決定:概念查詢不跑 BM25(n-gram 會撞到 # 只共用一個 2-gram 的無關卡片,而下游的餘弦門檻對 *_bm25 是放行的)。 results = results + _fts_leg(query, limit) return { "query": query, "wiki_hits": wiki_hits, "results": results, "search_mode": search_mode, } def main() -> int: parser = argparse.ArgumentParser(description="Recall relevant X bookmarks for conversation use") parser.add_argument("query", nargs="?", help="當前對話查詢") parser.add_argument("--query-file", help="從檔案讀取 query") parser.add_argument("--limit", type=int, default=3) parser.add_argument("--min-score", type=int, default=6) parser.add_argument("--json", action="store_true") parser.add_argument("--format", choices=["markdown", "prompt", "chat"], default="markdown") parser.add_argument("--index-file", default=str(INDEX_FILE)) parser.add_argument("--semantic", action="store_true", help="Force semantic search (default: auto-detect)") parser.add_argument("--no-semantic", action="store_true", help="Force keyword search even if vector index exists") parser.add_argument("--gbrain", action="store_true", help="Use gbrain hybrid search backend (pgvector + RRF + Gemini)") parser.add_argument("--vector-file", default=str(VECTOR_FILE)) parser.add_argument("--topic-profile-file", default=str(TOPIC_PROFILE_FILE)) parser.add_argument("--no-wiki", action="store_true", help="Skip wiki layer, search cards only") args = parser.parse_args() query = args.query or "" if args.query_file: query = Path(args.query_file).read_text(encoding="utf-8").strip() if not query.strip(): print("請提供 query", file=sys.stderr) return 1 # 檢索本身在 search() 裡。原本這裡有一份一模一樣的副本,於是 CLI 與 # hook 走的是兩份會各自漂移的程式——而 commit message 宣稱這裡已經只是 # 命令列的殼了。 found = search( query, limit=args.limit, min_score=args.min_score, index_file=Path(args.index_file), vector_file=Path(args.vector_file), topic_profile_file=Path(args.topic_profile_file), no_wiki=args.no_wiki, no_semantic=args.no_semantic, force_semantic=args.semantic, force_gbrain=args.gbrain, with_bm25=True, # CLI:全部都秀出來 ) wiki_hits = found["wiki_hits"] results = found["results"] search_mode = found["search_mode"] if not args.json: wiki_label = "" if args.no_wiki else f" + Wiki({'有' if wiki_hits else '無'}命中)" print(f"[搜尋模式:{MODE_LABELS.get(search_mode, search_mode)}{wiki_label}]") if args.json: print(json.dumps({ "query": query, "wiki_hits": wiki_hits, "results": results, "search_mode": search_mode, }, ensure_ascii=False, indent=2)) elif args.format == "prompt": print_prompt(wiki_hits, results, query) elif args.format == "chat": print_chat(wiki_hits, results, query) else: print_markdown(wiki_hits, results, query) return 0 import xkb_usage # noqa: E402 — 量測誰在跑,見 scripts/xkb_usage.py if __name__ == "__main__": xkb_usage.record(__file__) raise SystemExit(main())