scripts/test_recall_regression.py
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env python3
"""
舊 router 特殊提示路徑的回歸測試;共用召回核心見 xkb_eval.py。
分兩組,判定標準不同:
「該安靜」——**所有人都適用**,而且是失敗會直接讓人關掉這個工具的那一組。
召回層掛在每一句話上,一旦開始對閒聊、翻譯、算數學插嘴就沒救了。
這組任何一句冒出結果都算失敗。
「該召回」——**取決於知識庫裡有什麼**。預設只回報、不算失敗,
因為剛裝好、還沒吸收任何東西的知識庫本來就撈不到東西,
那不是程式壞掉。要把它當硬性條件請加 --strict。
案例可用 XKB_RECALL_CASES 指向自己的 JSON({"should_recall": [...]})。
這組案例對應 2026-07-28 修掉的三個靜默故障:
- 中文查詢(原本斷詞把整串中文當成一個詞,分數永遠是 0)
- 卡片來源(原本 router 把卡片結果整包丟掉)
- hard trigger 的回想問法(原本規則白名單漏接,且不查卡片)
Usage:
python3 scripts/test_recall_regression.py
python3 scripts/test_recall_regression.py --strict # 該召回也當硬性條件
python3 scripts/test_recall_regression.py --verbose # 印出實際撈到什麼
"""
from __future__ import annotations
import argparse
import atexit
import json
import os
import subprocess
import tempfile
import sys
import time
from pathlib import Path
SCRIPTS_DIR = Path(__file__).resolve().parent
ROUTER = SCRIPTS_DIR / "recall_router.py"
# 預設案例貼著作者自己的知識庫(影像製作、視覺 AI、碳盤查)。
# 換個知識庫就該換一組——用 XKB_RECALL_CASES 指到自己的 JSON。
DEFAULT_SHOULD_RECALL = [
"之前我們怎麼處理碳盤查的",
"我想做一支產品廣告影片",
"seedance 工作流程有沒有案例",
"XKB 下一步是什麼",
"storyboard 要怎麼分鏡",
"gpt image 2 的人像 prompt 有什麼技巧",
]
# 這組與知識庫內容無關,任何安裝都該通過
DEFAULT_SHOULD_STAY_QUIET = [
"早安",
"幫我翻譯這段",
"今天天氣真好想出去走走",
"幫我算一下 3000 乘以 12",
"晚點再說吧我先去吃飯",
"幫我訂一下明天的餐廳",
"ok 收到",
"寫一個 function 把字串反轉",
# 這句是回想語氣、也確實在問事情,但 XKB 裡沒有報價相關內容
# (那些在 my-brain)。召回層應該老實說「沒有」,而不是硬撈三張
# SEO 與新創補助的卡片充數。2026-07-29 之前它會回 2 筆雜訊。
"上次那個客戶的報價怎麼算的",
]
def load_cases() -> tuple[list[str], list[str]]:
path = os.getenv("XKB_RECALL_CASES")
if not path:
return DEFAULT_SHOULD_RECALL, DEFAULT_SHOULD_STAY_QUIET
with open(path, encoding="utf-8") as fh:
data = json.load(fh)
return (
data.get("should_recall", DEFAULT_SHOULD_RECALL),
data.get("should_stay_quiet", DEFAULT_SHOULD_STAY_QUIET),
)
# The router will not show the same knowledge twice in one conversation, and
# it remembers for four hours. Sharing that memory with whatever else has run
# recently makes this suite answer a different question every time it is run:
# four "should recall" cases failed here purely because the same queries had
# been asked while testing something else. A suite whose result depends on the
# last four hours is not measuring the code.
_SESSION_STATE = Path(tempfile.gettempdir()) / f"xkb-regression-session-{os.getpid()}.json"
def run_one(message: str) -> tuple[dict, float]:
started = time.monotonic()
proc = subprocess.run(
[sys.executable, str(ROUTER), "--legacy-router", "--json", "--", message],
capture_output=True, text=True, encoding="utf-8", errors="replace", timeout=60,
env={**os.environ, "XKB_SESSION_FILE": str(_SESSION_STATE)},
)
elapsed_ms = (time.monotonic() - started) * 1000
if proc.returncode != 0:
raise RuntimeError(f"router exited {proc.returncode}: {proc.stderr.strip()[:300]}")
return json.loads(proc.stdout), elapsed_ms
def main() -> int:
_SESSION_STATE.unlink(missing_ok=True)
atexit.register(lambda: _SESSION_STATE.unlink(missing_ok=True))
parser = argparse.ArgumentParser(description="Recall layer regression test")
parser.add_argument("--verbose", action="store_true", help="show what each query actually returned")
parser.add_argument("--strict", action="store_true",
help="「該召回」撈不到也算失敗(需要知識庫裡真的有對應內容)")
args = parser.parse_args()
should_recall, should_stay_quiet = load_cases()
failures = 0
misses = 0
slowest = 0.0
for label, cases, want_results in (
("該召回(取決於知識庫內容)", should_recall, True),
("該安靜(所有安裝都適用)", should_stay_quiet, False),
):
print(f"── {label} ──")
for message in cases:
try:
result, elapsed_ms = run_one(message)
except Exception as exc:
print(f" ERROR {message}\n {exc}")
failures += 1
continue
count = len(result["results"])
passed = (count > 0) if want_results else (count == 0)
slowest = max(slowest, elapsed_ms)
if passed:
mark = "ok "
elif want_results and not args.strict:
mark = "miss" # 知識庫沒這方面的內容,不算程式壞掉
misses += 1
else:
mark = "FAIL"
failures += 1
print(f" {mark} {elapsed_ms:6.0f}ms {result['trigger_class']:8} n={count} {message}")
if args.verbose and count:
for r in result["results"]:
print(f" - {r['source_type']:10} {r['source_file']}")
print()
total = len(should_recall) + len(should_stay_quiet)
print(f"{total - failures - misses}/{total} 通過 最慢 {slowest:.0f}ms")
if misses:
print(f"{misses} 句沒撈到——知識庫裡沒有對應內容,不計為失敗(要當硬性條件用 --strict)")
return 1 if failures else 0
import xkb_usage # noqa: E402 — 量測誰在跑,見 scripts/xkb_usage.py
if __name__ == "__main__":
xkb_usage.record(__file__)
raise SystemExit(main())