Ctrl K
ComunidadGuías…

scripts/recall_router.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/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 <message>")
        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())