scripts/xbrain_recall.py
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env python3
"""
xbrain_recall.py — XKB hybrid search bridge (XBrain layer)
Provides semantic + keyword hybrid search over the XKB knowledge base
using an embedded vector store (pgvector + RRF + Gemini embedding).
Usage:
python3 scripts/xbrain_recall.py "agent memory workflow"
python3 scripts/xbrain_recall.py "SEO 策略" --limit 5 --json
python3 scripts/xbrain_recall.py "query" --no-semantic # keyword-only
Environment / config (in priority order):
GBRAIN_DIR path to vector store runtime (overrides all)
GEMINI_API_KEY required for semantic (vector) search
--env-file explicit dotenv file for runtime credential injection
XKB_ENV_FILE fallback dotenv file when --env-file is omitted
Process environment variables take precedence over either dotenv file. No
host-specific config or private credential fallback is consulted.
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
from typing import Any
sys.path.insert(0, str(Path(__file__).resolve().parent))
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "tools"))
import xkb_failures
from runtime_config import runtime_env
import xkb_provenance
# ── Config ─────────────────────────────────────────────────────────────────────
def _safe_runtime_env() -> dict[str, str]:
"""讀執行期設定;讀不到就當作空的。
這一行在 import 時就會執行,而 load_env_file 對不存在的路徑會拋
FileNotFoundError。呼叫端只接 ImportError,所以一個過期的 XKB_ENV_FILE
會讓「模組載不進來」變成從 search() 裡拋出的 FileNotFoundError,
而不是安靜地退回關鍵字召回。設定不見是要說的,但不是用讓模組無法載入
的方式說。
"""
try:
return runtime_env()
except Exception as err: # noqa: BLE001
print(f"[xkb] xbrain 讀不到執行期設定,改用行程環境:{type(err).__name__}: {err}",
file=sys.stderr, flush=True)
return {}
_RUNTIME_ENV = _safe_runtime_env()
def _resolve_gbrain_dir(settings: dict[str, str] | None = None) -> Path | None:
"""Find the gbrain runtime directory, in priority order."""
settings = _RUNTIME_ENV if settings is None else settings
candidates = [
os.getenv("GBRAIN_DIR"),
settings.get("GBRAIN_DIR"),
str(Path.home() / "Desktop" / "gbrain"),
str(Path.home() / "gbrain"),
"/opt/gbrain",
]
for c in candidates:
if c and Path(c).joinpath("src", "cli.ts").exists():
return Path(c)
return None
GBRAIN_DIR = _resolve_gbrain_dir()
GBRAIN_AVAILABLE = GBRAIN_DIR is not None
def _resolve_bun() -> str:
"""Find bun executable — checks PATH first, then common install locations."""
import shutil
if shutil.which("bun"):
return "bun"
for candidate in [
str(Path.home() / ".bun" / "bin" / "bun"),
"/usr/local/bin/bun",
"/opt/homebrew/bin/bun",
]:
if Path(candidate).exists():
return candidate
return "bun" # let subprocess give the real error
BUN = _resolve_bun()
GEMINI_API_KEY = _RUNTIME_ENV.get("GEMINI_API_KEY", "")
def _make_subprocess_env(
semantic: bool,
settings: dict[str, str] | None = None,
) -> dict[str, str]:
"""Build subprocess env from the portable runtime contract."""
settings = _RUNTIME_ENV if settings is None else settings
env = {**settings}
gemini_api_key = settings.get("GEMINI_API_KEY", "")
if semantic and gemini_api_key:
env["GEMINI_API_KEY"] = gemini_api_key
# gbrain v0.42+ gateway 的 google recipe 讀這個變數名
env["GOOGLE_GENERATIVE_AI_API_KEY"] = gemini_api_key
elif not semantic:
env.pop("GEMINI_API_KEY", None)
env.pop("GOOGLE_GENERATIVE_AI_API_KEY", None)
env.pop("OPENAI_API_KEY", None)
return env
# ── Core query ─────────────────────────────────────────────────────────────────
def xbrain_query(
query: str,
*,
limit: int = 10,
no_expand: bool = True,
semantic: bool = True,
env_file: str | Path | None = None,
diagnostics: dict | None = None,
) -> list[dict[str, Any]]:
"""
Run hybrid search and return structured results.
Returns [] if XBrain is unavailable or query fails (never raises).
Result keys: slug, title, type, chunk_text, score, source_url, stale
"""
state = diagnostics if diagnostics is not None else {}
state.update(available=False, attempted=False, used=False, status="unavailable")
settings = runtime_env(env_file) if env_file is not None else _RUNTIME_ENV
gbrain_dir = _resolve_gbrain_dir(settings)
if gbrain_dir is None:
return []
state.update(available=True, attempted=True)
cmd = [BUN, "run", str(gbrain_dir / "src" / "cli.ts")]
# The human CLI ignores --json and truncates evidence to 100 characters.
# The operation surface returns the same search as lossless JSON.
cmd += ["call", "query", json.dumps({"query": query, "limit": limit,
"expand": not no_expand})]
try:
result = subprocess.run(
cmd,
capture_output=True,
text=True,
encoding="utf-8",
env=_make_subprocess_env(semantic, settings),
cwd=str(gbrain_dir),
# 召回是互動路徑;連不上後端時,這個秒數就是使用者要等的時間。
timeout=int(os.getenv("XKB_XBRAIN_TIMEOUT", "30")),
)
except subprocess.TimeoutExpired as err:
state["status"] = "timeout"
# 逾時、找不到 bun、非零離開碼——三種都回空陣列,而空陣列的意思是
# 「這個主題我們沒有知識」。它們其實是「問不到」。這正是下面那段
# 註解在講的同一件事,只是原本只有最後一種會留下記錄。
xkb_failures.note("semantic recall (xbrain) 逾時", err)
return []
except FileNotFoundError as err:
state["status"] = "unavailable"
xkb_failures.note("semantic recall (xbrain):PATH 上沒有 bun", err)
return []
except Exception as err:
state["status"] = "error"
# 語意搜尋的後端掛掉,跟「這個主題我們沒有知識」是兩件事。
# 2026-05-04 的十二週靜默故障,就是因為這兩者長得一模一樣。
xkb_failures.note("semantic recall (xbrain)", err)
return []
if result.returncode != 0:
state["status"] = "error"
# 只留離開碼,不要把 stderr 貼進來:後端的錯誤訊息會回音請求內容,
# 而請求裡有憑證。tests/test_xbrain_recall_runtime.py 的
# test_failed_backend_does_not_echo_credential 就是釘這件事的。
xkb_failures.note("semantic recall (xbrain)", RuntimeError(f"exit {result.returncode}"))
return []
raw = result.stdout.strip()
state["status"] = "empty"
if not raw or raw == "No results.":
return []
truncated_display = False
try:
items = json.loads(raw)
except json.JSONDecodeError:
# gbrain v0.42+ 移除了 query --json,輸出為「[分數] slug -- 內文」行格式
items = _parse_line_format(raw)
truncated_display = True
if not items:
state["status"] = "invalid_response"
return []
if not isinstance(items, list) or not all(isinstance(item, dict) for item in items):
state["status"] = "invalid_response"
return []
results = []
for item in items:
chunk = item.get("chunk_text", "")
slug = item.get("slug", "")
source_url = _source_url(item)
results.append({
"slug": slug,
"title": item.get("title", ""),
"type": item.get("type", ""),
"chunk_text": chunk,
"score": item.get("score", 0.0),
"score_scale": "card_hybrid",
"source_url": source_url,
"stale": item.get("stale", False),
**({"excerpt_truncated": True} if truncated_display else {}),
})
state.update(used=bool(results), status="used" if results else "empty")
return results
def _parse_line_format(raw: str) -> list[dict[str, Any]]:
"""解析 gbrain v0.42+ 的行格式輸出:`[0.9102] slug -- chunk text`。
非結果行(gateway 警告等)自動略過。"""
import re
# gbrain 在最後一筆結果之後還會印東西(警告、計時、棄用提示)。原本的
# 續行判斷是「不是結果、而且已經看過至少一筆」,所以那些行全部被接到
# 最後一張卡的內文裡,然後當成知識顯示給使用者看。
RESULT = re.compile(r"^\[(\d+\.\d+)\]\s+(\S+)\s+--\s?(.*)$")
NOISE = re.compile(
r"^\s*(?:\[|warn|warning|error|deprecat|note:|info:|took\b|elapsed\b|\d+\s*ms\b)",
re.I,
)
MAX_CONTINUATION_CHARS = 4000
items: list[dict[str, Any]] = []
for line in raw.splitlines():
stripped = line.strip()
match = RESULT.match(stripped)
if match:
items.append({
"slug": match.group(2),
"title": "",
"type": "",
"chunk_text": match.group(3),
"score": float(match.group(1)),
"stale": False,
})
continue
if not items or not stripped or NOISE.match(stripped):
continue
# 續行。原本用未 strip 的行判斷開頭,所以縮排過的結果會被當成內文。
last = items[-1]
if len(last["chunk_text"]) < MAX_CONTINUATION_CHARS:
last["chunk_text"] += "\n" + stripped
return items
def _source_url(item: dict) -> str:
"""Prefer exact metadata, then canonical frontmatter; never guess GitHub."""
for metadata in (item, item.get("frontmatter") or {}):
if metadata.get("source_url"):
return str(metadata["source_url"])
slug = item.get("slug", "")
if slug.startswith("xkb-case-"):
try:
slug = bytes.fromhex(slug.removeprefix("xkb-case-")).decode("utf-8")
except (ValueError, UnicodeError):
pass
import xkb_paths
import xkb_frontmatter
roots = (xkb_paths.CARDS_DIR, xkb_paths.BOOKMARKS_DIR / "youtube")
for root in roots:
leaf = slug.removeprefix("youtube-") if root.name == "youtube" else slug
candidate = root / (leaf + ".md")
try:
if candidate.is_file() and candidate.resolve().is_relative_to(root.resolve()):
value = xkb_frontmatter.parse(candidate.read_text(encoding="utf-8")).get("source_url")
if value:
return str(value).strip("\"'")
except (OSError, UnicodeError):
# Local provenance is optional; a damaged local copy must not erase
# otherwise valid remote search hits.
continue
return _extract_source_url(item.get("chunk_text", "")) or _url_from_slug(slug)
def _extract_source_url(text: str) -> str:
"""Extract source URL from card content (multiple patterns)."""
import re
patterns = [
r"Source URL:\s*(https?://\S+)",
r"來源:\s*(https?://\S+)",
r"source_url:\s*(https?://\S+)",
r"- (?:Tweet|原始來源|Source):\s*(https?://\S+)",
]
for pat in patterns:
m = re.search(pat, text)
if m:
return m.group(1).rstrip(".,)")
return ""
def _url_from_slug(slug: str) -> str:
"""Reconstruct source URL from slug naming conventions."""
import re
# Tweet IDs: 15-20 digit slugs → x.com URL
if re.fullmatch(r"\d{15,20}", slug):
return f"https://x.com/i/status/{slug}"
# legacy-* slugs derived from tweet files: extract numeric suffix
m = re.search(r"(\d{15,20})$", slug)
if m:
return f"https://x.com/i/status/{m.group(1)}"
# YouTube slugs: youtube-{video_id}
m = re.match(r"youtube-([A-Za-z0-9_\-]{11})$", slug)
if m:
return f"https://www.youtube.com/watch?v={m.group(1)}"
# GitHub owner/repo both allow hyphens: this encoding is irreversible.
# Never manufacture a provenance URL from it.
return ""
# ── Formatted output ───────────────────────────────────────────────────────────
def format_for_conversation(results: list[dict], query: str) -> str:
"""Format recall results for injecting into conversation context."""
if not results:
return ""
lines = [f"◆ 相關既有知識(query: {query!r})\n"]
for i, r in enumerate(results[:5], 1):
title = xkb_provenance.strip_markers(r["title"] or r["slug"])
url = r["source_url"]
snippet = xkb_provenance.strip_markers(
r["chunk_text"][:200].replace("\n", " ").strip())
score_pct = f"{r['score'] * 100:.1f}%"
lines.append(f"{i}. **{title}** ({score_pct})")
if snippet:
lines.append(f" {snippet}…")
if url:
lines.append(f" {url}")
lines.append("")
return "\n".join(lines)
# ── Backward compatibility alias ──────────────────────────────────────────────
# Old scripts that import gbrain_recall can import from here instead.
gbrain_query = xbrain_query
# ── CLI ────────────────────────────────────────────────────────────────────────
def main() -> None:
parser = argparse.ArgumentParser(description="XBrain hybrid search")
parser.add_argument("query", help="Search query")
parser.add_argument("--limit", type=int, default=10)
parser.add_argument("--json", action="store_true", dest="output_json")
parser.add_argument("--no-semantic", action="store_true")
parser.add_argument(
"--env-file",
help="Explicit XKB dotenv file (process environment takes precedence)",
)
args = parser.parse_args()
try:
settings = runtime_env(args.env_file) if args.env_file else _RUNTIME_ENV
except (FileNotFoundError, OSError, ValueError) as exc:
print(f"Unable to load XKB runtime configuration: {exc}", file=sys.stderr)
sys.exit(1)
if _resolve_gbrain_dir(settings) is None:
print("XBrain not available. Set GBRAIN_DIR or clone to ~/Desktop/gbrain.",
file=sys.stderr)
sys.exit(1)
if not args.no_semantic and not settings.get("GEMINI_API_KEY"):
print(
"Semantic recall requires GEMINI_API_KEY via the process environment "
"or the selected XKB env file (process environment takes precedence).",
file=sys.stderr,
)
sys.exit(1)
results = xbrain_query(
args.query,
limit=args.limit,
semantic=not args.no_semantic,
env_file=args.env_file,
)
if args.output_json:
print(json.dumps(results, ensure_ascii=False, indent=2))
else:
print(format_for_conversation(results, args.query))
import xkb_usage # noqa: E402 — 量測誰在跑,見 scripts/xkb_usage.py
if __name__ == "__main__":
xkb_usage.record(__file__)
main()