scripts/recall_for_conversation.py
Rama main · réplica de lectura del repositorio original.
#!/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())