#!/usr/bin/env python3 """ Recall Router — Active Recall Layer Phase 1.1 CLI 預設呼叫共用 Knowledge Service;--legacy-router 或 route() 保留舊的分類與特殊提示。 輸出 schema (JSON): { "trigger_class": "hard|soft|suppress", "state": "continuity|brainstorming|strategy|execution|suppress", "delivery_mode": "inline_injection|side_hint|expandable_hint|none", "results": [ { "source_type": "memory|wiki|card|bookmark", "source_file": "...", "section": "...", "excerpt": "...", "score": 0.0 } ], "confidence": 0.0, "formatted_text": "...", "query": "..." } Usage: python3 recall_router.py "XKB 下一步是什麼" python3 recall_router.py "AI SEO 值不值得做" --legacy-router --format full python3 recall_router.py "query" --json python3 recall_router.py "query" --dry-run """ from __future__ import annotations import argparse import json import os import subprocess import sys from concurrent.futures import ThreadPoolExecutor # 卡片層的上限。原本由 subprocess 的 timeout=20 提供,改成同行程後要自己帶。 ASSOCIATIVE_TIMEOUT_S = 20 # side_hint 的門檻。兩個尺度,兩個值——用同一個數字比對餘弦相似度與關鍵字 # 分數,是這個專案反覆犯的那一類錯。 SIDE_HINT_KEYWORD = 2.0 # 2026-09-01 實測 wiki 語意分數:檢索本身有 0.65 的下限,實際命中落在 # 0.666–0.843。所以 0.5 跟原本的 2.0 一樣是個常數分支,只是常數翻了面—— # 我上次「修好」的是哪一邊永遠成立,不是這個判斷有沒有在判斷。 # 0.75 把「直接命中」(0.84)跟「沾到邊」(0.67–0.72)分開。 SIDE_HINT_SEMANTIC = 0.75 # 關鍵字分數的實際範圍:下限 6(min_score),實測上界約 20。換算成 0–1 時 # 用固定的上界而不是這一批的最大值——用批內最大值的話,一批爛結果裡最好的 # 那一個會被算成滿分,這正是 MMR 正規化當初犯過的錯。 KEYWORD_SCORE_FLOOR = 6.0 KEYWORD_SCORE_CEILING = 20.0 def _as_unit_scale(score: float) -> float: """把關鍵字分數換算到 0–1,好跟餘弦相似度放在一起比。""" try: value = float(score) except (TypeError, ValueError): return 0.0 span = KEYWORD_SCORE_CEILING - KEYWORD_SCORE_FLOOR return max(0.0, min(1.0, (value - KEYWORD_SCORE_FLOOR) / span)) from datetime import datetime, timezone from pathlib import Path from typing import Any # Add scripts dir to path for sibling imports SCRIPTS_DIR = Path(__file__).parent sys.path.insert(0, str(SCRIPTS_DIR)) from conversation_state_parser import parse as parse_state, ParseResult from continuity_recall import (recall as continuity_recall, recall_from_wiki, format_chat as format_continuity_chat, card_similarities, CARD_MIN_SIMILARITY) from contrarian_recall import recall as contrarian_recall, format_hint as format_contrarian_hint from action_recall import recall as action_recall, format_hint as format_action_hint # Imported like any other sibling. It used to be wrapped in a try/except that # fell back to a no-op, and on 2026-05-04 the module was archived rather than # deleted — so the import started failing, the fallback swallowed it, and the # "do not show the same knowledge twice in one conversation" filter was off # for four months without a single error. A guard that can silently become a # no-op is not a guard. from _session_dedup import filter_new as _dedup_filter_new, mark_shown as _dedup_mark_shown import xkb_paths import re import xkb_failures import xkb_provenance import xkb_relevance import xkb_score WORKSPACE = xkb_paths.WORKSPACE # 隔壁的腳本,不是「workspace 底下某個猜出來的位置」 SCRIPTS = xkb_paths.SCRIPTS_DIR TELEMETRY_PATH = xkb_paths.TELEMETRY_PATH # ── Thresholds ───────────────────────────────────────────────────────────────── MIN_SCORE_HARD = 0.3 MIN_SCORE_SOFT = 0.4 MIN_EXCERPT_LEN = 15 # ── Telemetry ────────────────────────────────────────────────────────────────── def _write_telemetry(record: dict) -> None: """Append a single telemetry record to JSONL log (fire-and-forget).""" try: TELEMETRY_PATH.parent.mkdir(parents=True, exist_ok=True) with TELEMETRY_PATH.open("a", encoding="utf-8") as fh: fh.write(json.dumps(record, ensure_ascii=False) + "\n") except Exception: pass # Telemetry never breaks the main flow def _build_telemetry( message: str, parsed: ParseResult, result_count: int, delivery_mode: str, duration_ms: int, ) -> dict: # 這一次有沒有哪一層退回了 fallback。 # # 沒有這個欄位的時候,「這個主題確實沒有東西」和「語意後端逾時」在紀錄裡 # 是同一行:recalled=false, result_count=0。召回壞掉十二週沒被發現,靠的 # 就是這種等價——它一直有禮貌地回答「我不知道」。 # # 各層的失敗本來就都經過 xkb_failures.note(),只是那些話寫在 stderr,而 # 召回跑在 MCP 伺服器裡,stderr 沒有人讀。這裡把它收進紀錄。 degraded = xkb_failures.taken() return { "ts": datetime.now(timezone.utc).isoformat(), "message_preview": message[:80], "trigger_class": parsed.trigger_class, "state": parsed.state, "confidence": round(parsed.confidence, 3), "query": parsed.suggested_query, "recalled": result_count > 0, "result_count": result_count, "delivery_mode": delivery_mode, "duration_ms": duration_ms, "matched_rules": parsed.matched_rules[:2], "degraded": degraded, # 空手而回又有東西壞掉——這才是「我不知道」與「我壞了」的分界線, # 而它要是一個可以直接統計的欄位,不是要人去比對兩個欄位才看得出來。 "empty_because_broken": bool(degraded) and result_count == 0, } # ── Value filter ─────────────────────────────────────────────────────────────── def _filter_results(results: list, min_score: float) -> list: return [ r for r in results if r.score >= min_score and len(getattr(r, "excerpt", "") or "") >= MIN_EXCERPT_LEN ] def _results_to_dicts(results: list) -> list[dict]: out = [] for r in results: out.append({ "source_type": getattr(r, "source_type", "unknown"), "source_file": getattr(r, "source_file", ""), "section": getattr(r, "section", ""), "excerpt": getattr(r, "excerpt", "")[:200], "score": getattr(r, "score", 0.0), "url": getattr(r, "url", ""), }) return out # ── Associative recall via recall_for_conversation.py ───────────────────────── # 過濾要跟檢索用同一個字串。原本檢索用 query(suggested_query),過濾卻用 # 原始 message:英文輸入時 suggested_query 是關鍵字包,於是拿一整句閒聊去 # 評判關鍵字包撈回來的結果,好卡片會被推到 0.55 門檻以下。而且這保證了 # query-vector 快取一定失效——那個快取存在的理由就是「一次召回不要把同一 # 句話送去轉兩次向量」。 def _drop_irrelevant_cards(query: str, results: list[dict]) -> list[dict]: """用真實相似度濾掉不相關的卡片。判斷邏輯在 xkb_relevance,不要在這裡另寫一份。""" cards = [r for r in results if r.get("source_type") in ("card", "bookmark")] if not cards: return results kept_cards, _, _ = xkb_relevance.filter_irrelevant( query, cards, key_of=lambda r: str(r.get("source_file", ""))) keep = {id(r) for r in kept_cards} return [r for r in results if r.get("source_type") not in ("card", "bookmark") or id(r) in keep] def _format_assoc_chat(results: list[dict]) -> str: if not results: return "" lines = ["相關卡片/書籤:"] for r in results: title = r.get("section") or r.get("source_file", "") lines.append(f"- {title}:{r.get('excerpt', '')}") if r.get("url"): lines.append(f" 原文:{r['url']}") return "\n".join(lines) def _assoc_dict(item: dict, *, keyword_scale: bool = False) -> dict: """把 recall() / fts_recall() 形狀的項目映成聯想召回的結果 dict。 _format_assoc_chat、xkb_score.rank、_session_dedup 都吃這個形狀。以前 三個地方各寫一份,其中兩份連 source_type 的判斷都不一樣(`"cards/" in` vs `.startswith`)——同一條巢狀路徑會在一處算 card、另一處算 bookmark。 集中在這裡。 """ rel = str(item.get("relative_path") or item.get("path") or "") # 只認 ingester 真的會產生的兩種前綴。"cards/" in rel 會誤中絕對路徑裡 # 任何一層叫 cards 的目錄(relative_path 缺時 rel 退回絕對 path)。 is_card = rel.startswith(("cards/", "memory/cards/")) score = item.get("score", 0.0) if not item.get("score_scale") and keyword_scale: # 沒有自帶 score_scale 的舊關鍵字結果:base + 調整×10、下限 6、沒有上界, # 換算到 0–1,不然 rank() 會把一張 15 分的卡片算成壓過所有語意結果。 # BM25 結果自帶 score_scale=card_bm25/wiki_bm25,分數是 -bm25(),不要碰。 score = _as_unit_scale(score) scale = item.get("score_scale") or "" if scale == "wiki_bm25": # BM25 的 wiki/memory 段落命中不是書籤。標成 bookmark 會讓 # xkb_score._key 用「一個檔一筆」的卡片邏輯,把同一頁的不同段併掉。 src_type = "wiki" else: src_type = "card" if is_card else "bookmark" return { "source_type": src_type, "source_file": item.get("relative_path") or item.get("path", ""), "section": xkb_provenance.strip_markers(item.get("title", "")), "excerpt": xkb_provenance.strip_markers((item.get("summary") or "")[:200]), "score": score, "score_scale": item.get("score_scale"), "url": item.get("source_url") or item.get("url", ""), } # 一個「看起來是在找特定東西」的 token:含數字(tweet ID、錯誤碼、gpt-5.6)、 # 含底線(ERR_CONNECTION_RESET、nash_su)、或帶連字號/點的 ASCII slug(repo 名、模型名)。純英文詞不算。 _SPECIFIC_TOKEN = re.compile( r"\d{5,}" # 長數字串(tweet ID、timestamp)——排除年份 r"|_" # 底線=識別碼慣例(ERR_CONNECTION_RESET、nash_su) r"|[A-Za-z0-9]/[A-Za-z0-9]" # 斜線分段(owner/repo、api/v2) r"|[A-Za-z][A-Za-z0-9]*-[A-Za-z0-9]" # 字母開頭的連字號 token(open-source、 # infiniflow-ragflow、Open-Magiviz、gpt-5)。 # recall 優先:接受 open-source/trade-off 偽陽, # 換沒帶 URL 的 repo slug 也能觸發 BM25。 r"|[A-Za-z][A-Za-z0-9]*[.][A-Za-z0-9]*\d" # 字母 + 點 + 數字(v2.3.1;node.js 無數字不算) r"|[A-Za-z][A-Za-z0-9]*\d[A-Za-z0-9]*\d" # 字母開頭且含 2+ 數字(sha256、h264;mp4 不算) # 純數字帶分隔(2-3、1.5、2024-01-15)不含字母訊號,一律不算——不然 # 「這方法有 2-3 個步驟」會被當成識別碼查詢、可能強制 side_hint。 ) def _looks_specific(query: str) -> bool: """查詢裡有沒有「在找特定東西」的 token。 light 掃描跑在幾乎每一句話上。BM25 對任何可斷詞的查詢都會命中——閒聊 「今天天氣真好」也會 n-gram 撞到某張卡的內文。所以 light 路徑只在查詢 看起來像在找識別碼(錯誤碼、型號、repo slug、ID)時才跑 BM25。 非 light 路徑不受這個閘門限制——那是真的在召回,值得跑整條腿。 """ return bool(_SPECIFIC_TOKEN.search(query or "")) def run_associative_recall(query: str, limit: int = 2, *, fts_only: bool = False) -> tuple[str, list[dict]]: """Returns (formatted_text, results_list). Calls recall_for_conversation.search() directly. It used to spawn the same file as a child process, which cost an interpreter start and turned every failure into an unparseable stdout — indistinguishable from "found nothing". gbrain's own 1.2 s is paid either way; what this removes is the part that was never doing any work. """ if fts_only and not _looks_specific(query): return "", [] try: from recall_for_conversation import search as _associative_search except ImportError as err: # 隔壁模組載不進來是安裝壞掉,不是「查無資料」——要出聲,不要靜靜回空 msg = f"associative recall unavailable: {err}" print(msg, file=sys.stderr) return f"({msg})", [] try: # subprocess 版本有 timeout=20,改成同行程之後那個界線消失了,而 # recall_for_conversation 的嵌入路徑本身沒有任何逾時。hook 只給六秒, # 所以一個卡住的端點會無限期擋住整條召回。界線要留著。 # 不能用 with。Executor.__exit__ 會 shutdown(wait=True),所以逾時 # 之後那個區塊還是會等到工作結束——實測宣稱 0.5 秒、實際 3.00 秒。 # 這個界線存在的理由就是不要被卡住的端點拖住六秒的 hook 預算, # 而它原本一秒都沒擋到。 if fts_only: # BM25 腿是一次 SQLite MATCH,不打 embedding、不阻塞,不需要 executor。 found = _associative_search(query, limit=limit, fts_only=True) else: bounded = ThreadPoolExecutor(max_workers=1, thread_name_prefix="xkb-assoc") _bm25_ok = _looks_specific(query) # 概念查詢不掛 BM25 腿(見 search()) try: found = bounded.submit( lambda: _associative_search(query, limit=limit, with_bm25=_bm25_ok) ).result(timeout=ASSOCIATIVE_TIMEOUT_S) finally: bounded.shutdown(wait=False) items, mode = found["results"], found["search_mode"] # 關鍵字分數是 base + 調整×10、下限 6、沒有上界;RRF 是 0–1。 # 下游的 rank() 用 0.88 當錨點,對 RRF 是對的,對關鍵字分數會讓一張 # 得 15 分的卡片算出 0.94,壓過所有語意結果(最好也才 0.55)。 # 分數要帶著自己的尺度,不要讓下游用猜的——這一類錯誤這個專案犯了四次。 keyword_scale = mode in ("keyword", "keyword_fallback") results = [_assoc_dict(item, keyword_scale=keyword_scale) for item in items] return _format_assoc_chat(results), results except Exception as e: # 後端壞掉不能長得像「查無資料」。這裡原本還套了一層 except, # 把例外變成空陣列,於是下面這行報告從來沒有機會執行。 xkb_failures.note("associative recall", e) return f"(associative recall error: {e})", [] # ── Delivery formatters ──────────────────────────────────────────────────────── def _format_inline(continuity_text: str) -> str: return continuity_text def _format_side_hint(assoc_text: str) -> str: return assoc_text def _format_expandable(query: str, count: int) -> str: return f"(你知識庫裡有 {count} 個相關片段,主題:{query[:30]},要我拉進來嗎?)" # ── Router ───────────────────────────────────────────────────────────────────── def route(message: str, dry_run: bool = False) -> dict[str, Any]: """ Main routing logic. Returns structured dict with full schema. """ import time t0 = time.monotonic() # Step 1: Parse parsed: ParseResult = parse_state(message) if parsed.trigger_class == "suppress": duration_ms = int((time.monotonic() - t0) * 1000) _write_telemetry(_build_telemetry(message, parsed, 0, "none", duration_ms)) return { "trigger_class": "suppress", "state": parsed.state, "delivery_mode": "none", "results": [], "confidence": parsed.confidence, "formatted_text": "", "query": "", } query = parsed.suggested_query or message[:60] if dry_run: return { "trigger_class": parsed.trigger_class, "state": parsed.state, "delivery_mode": "TBD (dry-run)", "results": [], "confidence": parsed.confidence, "formatted_text": "[dry-run: recall not executed]", "query": query, "debug": {"matched_rules": parsed.matched_rules}, } # Step 2: Execute recall if parsed.trigger_class == "hard": # 兩個貴的層平行跑。continuity 花 2.7 秒在本機向量運算,卡片層花 1.2 秒 # 等外部行程,而且互不相依——排隊跑只是因為當初就那樣寫。 with ThreadPoolExecutor(max_workers=2) as pool: assoc_future = pool.submit(run_associative_recall, query, 2) raw_results = continuity_recall(query, source="both", top_k=4) assoc_text, assoc_results = assoc_future.result() filtered = _filter_results(raw_results, MIN_SCORE_HARD) filtered, _ = _dedup_filter_new(filtered) result_dicts = _results_to_dicts(filtered[:3]) text_parts: list[str] = [] if filtered: text_parts.append(_format_inline(format_continuity_chat(filtered[:3]))) # 卡片與書籤。 # hard trigger 是「我們之前怎麼…」這種明確的回想要求,而使用者存最多東西的地方 # 就是卡片(wiki 只有十幾個 topic,卡片上千張)。原本這條路徑只查 wiki 與記憶檔, # 等於問「之前怎麼做的」反而查不到主要的知識來源。 # (結果在上面就跟 continuity 一起平行取回了。) assoc_results = _drop_irrelevant_cards(query, assoc_results) assoc_results, _ = _dedup_filter_new(assoc_results) if assoc_results: result_dicts += assoc_results if assoc_text: text_parts.append(_format_assoc_chat(assoc_results)) # Action recall (supplement for execution-planning state) action_results = action_recall(query, top_k=3) if action_results: action_text = format_action_hint(action_results) if action_text: text_parts.append(action_text) result_dicts += [{"source_type": "action", "source_file": r.path, "section": xkb_provenance.strip_markers(r.name), "excerpt": xkb_provenance.strip_markers(r.description), "score": r.score, "url": ""} for r in action_results] # 各層分數尺度不同,換算成可比的 unified_score 之後才排序 result_dicts = xkb_score.rank(result_dicts) delivery_mode = "inline_injection" if text_parts else "none" formatted_text = "\n\n".join(text_parts) if text_parts else "" # 卡片層的結果也進了 result_dicts 與輸出文字,卻沒有被標記為已顯示, # 所以同一張卡在整個 session 視窗(四小時)裡每次 hard trigger 都會 # 再推一次。soft 路徑本來就做對了。 _dedup_mark_shown(filtered[:3]) _dedup_mark_shown(assoc_results) duration_ms = int((time.monotonic() - t0) * 1000) _write_telemetry(_build_telemetry(message, parsed, len(result_dicts), delivery_mode, duration_ms)) return { "trigger_class": "hard", "state": parsed.state, "delivery_mode": delivery_mode, "results": result_dicts, "confidence": parsed.confidence, "formatted_text": formatted_text, "query": query, } else: # soft # Light scan:沒有任何規則命中,只是「順手看一眼」。 # 只掃 wiki(~20ms),不跑語意搜尋(1~2 秒),而且分數門檻拉高—— # 沒有明確訊號時,寧可安靜,也不要拿低分結果插嘴。 light = parsed.confidence < 0.4 min_wiki_score = 0.5 if light else 0.4 # Wiki recall (highest priority — synthesized knowledge) # light scan 不做語意:它跑在幾乎每一句話上,每句多打一次 embedding API # 既慢又花錢。有規則命中(真的在問東西)才值得那一次呼叫。 wiki_results = recall_from_wiki(query, top_k=2, semantic=not light) wiki_results_filtered = [r for r in wiki_results if r.score >= min_wiki_score] wiki_results_filtered, _ = _dedup_filter_new(wiki_results_filtered) wiki_text = format_continuity_chat(wiki_results_filtered) if wiki_results_filtered else "" wiki_result_dicts = [{"source_type": r.source_type, "source_file": r.source_file, "section": r.section, "excerpt": r.excerpt, "score": r.score, "url": r.url} for r in wiki_results_filtered] # Associative recall (bookmark/card supplement) — light scan 不跑,太貴 # (run_associative_recall 會打向量/外部呼叫,~1.2s,而 light 幾乎每句都跑)。 if light: # light 掃描不跑昂貴的語意腿,但識別碼(錯誤碼 / 型號 / repo slug / # tweet ID)是純字串比對、不打 embedding,值得跑。走跟非 light # 完全相同的函式(fts_only 模式),不要在這裡另抄一份 # import / try / except / note / 映射——那份抄本審了四輪都在追它 # 跟本尊的差異。 assoc_text, assoc_results = run_associative_recall( query, limit=3, fts_only=True) assoc_results, _ = _dedup_filter_new(assoc_results) assoc_text = _format_assoc_chat(assoc_results) if assoc_results else "" else: assoc_text, assoc_results = run_associative_recall(query, limit=2) assoc_results = _drop_irrelevant_cards(query, assoc_results) assoc_results, _ = _dedup_filter_new(assoc_results) # Contrarian recall (supplement — max 1 result, only on high-confidence soft) contrarian_text = "" contrarian_results = [] if parsed.confidence >= 0.55: c_raw = contrarian_recall(query, top_k=1) c_raw, _ = _dedup_filter_new(c_raw) if c_raw: contrarian_text = format_contrarian_hint(c_raw) contrarian_results = [{"source_type": "contrarian", "source_file": r.source_file, "section": r.section, "excerpt": r.excerpt, "score": r.score, "url": ""} for r in c_raw] # 各層分數尺度不同,換算成可比的 unified_score 之後才排序 all_results = xkb_score.rank(wiki_result_dicts + assoc_results + contrarian_results) # 標記要等到確定送出去之後。原本在這裡就全部標記,於是分數落在 # 0.666–0.749 的 wiki 命中會走 expandable 分支、卡片一個字都沒 # 出現,卻已經被記成「顯示過」而在四小時內被壓掉。 # 被計算過不等於被讀到。 has_wiki = bool(wiki_text) # has_assoc must check dedup-filtered results, not raw assoc_text # (avoids showing "有 N 個相關片段" when everything was dedup'd) has_assoc = bool(assoc_results) or bool(assoc_text and len(assoc_text) >= 20 and wiki_result_dicts) has_content = has_wiki or has_assoc if not has_content and not contrarian_text: duration_ms = int((time.monotonic() - t0) * 1000) _write_telemetry(_build_telemetry(message, parsed, 0, "none", duration_ms)) return { "trigger_class": "soft", "state": parsed.state, "delivery_mode": "none", "results": [], "confidence": parsed.confidence, "formatted_text": "", "query": query, } # Delivery mode based on content quality, not trigger confidence best_wiki_score = max((r.score for r in wiki_results_filtered), default=0.0) # 2.0 是關鍵字時代的門檻(wiki 分數當時是 0.40–3.65)。改用語意之後 # 分數上限是 1.0,所以這個條件永遠不成立:只要有 wiki 命中就一定走 # expandable_hint,而卡片層算完、過濾完、去重完,然後完全不顯示。 wiki_is_semantic = any( r.source_type.endswith("_semantic") for r in wiki_results_filtered ) wiki_threshold = SIDE_HINT_SEMANTIC if wiki_is_semantic else SIDE_HINT_KEYWORD # BM25 命中 + 查詢看起來在找特定東西 => 使用者查的多半就是這張卡, # 直接給內容。概念查詢就算 BM25 也命中,還是走 wiki 門檻那套判斷—— # 不然任何非 light 查詢只要 BM25 回東西就會被強制 side_hint。 _bm25_specific = (_looks_specific(query) and any(str(x.get("score_scale") or "").endswith("_bm25") for x in assoc_results)) if _bm25_specific or best_wiki_score >= wiki_threshold or (not has_wiki and parsed.confidence >= 0.6): delivery_mode = "side_hint" else: delivery_mode = "expandable_hint" text_parts = [] # Wiki first (highest authority) if has_wiki: text_parts.append(wiki_text) # Bookmark supplement — only if there are actual (non-dedup'd) results if has_assoc and assoc_results: if delivery_mode == "side_hint": # 用過濾後的結果重新排版。assoc_text 是 run_associative_recall # 在過濾之前就排好的字串,所以 _drop_irrelevant_cards 與去重 # 只改了數字和遙測,注入給模型的文字一個字都沒少。 text_parts.append(_format_side_hint(_format_assoc_chat(assoc_results))) else: # 有 wiki 命中時原本什麼都不放,卡片就這樣算完、過濾完、 # 去重完,然後消失。摘要式的提示至少讓它可被取用。 text_parts.append(_format_expandable(query, len(assoc_results))) if contrarian_text: text_parts.append(contrarian_text) # 只有真的把內容送出去的才算顯示過。_format_expandable 只印一個數量 # (「你知識庫裡有 2 個相關片段」),卡片的標題與摘要一個字都沒出現; # 把它們記成顯示過,下一輪就會被去重濾掉,於是那個數量變成 0, # 內容永遠送不出去。原本 `if text_parts:` 在這裡永遠成立,等於沒改。 _dedup_mark_shown(wiki_result_dicts + contrarian_results) if delivery_mode == "side_hint": _dedup_mark_shown(assoc_results) formatted_text = "\n\n".join(text_parts) duration_ms = int((time.monotonic() - t0) * 1000) _write_telemetry(_build_telemetry(message, parsed, len(all_results), delivery_mode, duration_ms)) return { "trigger_class": "soft", "state": parsed.state, "delivery_mode": delivery_mode, "results": all_results, "confidence": parsed.confidence, "formatted_text": formatted_text, "query": query, } def main() -> int: parser = argparse.ArgumentParser(description="Recall Router — Active Recall Layer") parser.add_argument("message", nargs="?", help="User message") parser.add_argument("--json", dest="as_json", action="store_true", help="Output full structured result as JSON") parser.add_argument("--dry-run", action="store_true", help="Show routing decision without executing recall") parser.add_argument("--format", choices=["chat", "full"], default="chat") parser.add_argument("--legacy-router", action="store_true", help="Use the old continuity/action/contrarian routing") parser.add_argument("--limit", type=int, default=10) parser.add_argument("--env-file") args = parser.parse_args() message = args.message or sys.stdin.read().strip() if not message: print("Usage: recall_router.py ") return 1 if args.legacy_router or args.dry_run: result = route(message, dry_run=args.dry_run) else: from xkb_recall import run_configured, compatibility_aliases try: result = compatibility_aliases(run_configured(message, args.limit, env_file=args.env_file)) except Exception as exc: if args.as_json: print(json.dumps({"status": "failed", "error": str(exc)}, ensure_ascii=False)) else: print(f"Recall failed: {exc}", file=sys.stderr) return 1 if args.as_json: print(json.dumps(result, ensure_ascii=False, indent=2)) return 0 if args.format == "full": print(f"trigger : {result['trigger_class']}") print(f"state : {result['state']}") print(f"query : {result['query']}") print(f"delivery: {result['delivery_mode']}") print(f"results : {len(result['results'])}") if "confidence" in result: print(f"conf : {result['confidence']:.2f}") print() output = result.get("formatted_text", "") if output: print(output) return 0 import xkb_usage # noqa: E402 — 量測誰在跑,見 scripts/xkb_usage.py if __name__ == "__main__": xkb_usage.record(__file__) raise SystemExit(main())