Ctrl K
ComunidadGuías…

tests/test_recall_shape.py

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

Archivo original ↗Preguntar a KiBot
"""召回的形狀:一份搜尋邏輯、兩層平行、後端壞掉要出聲。

這三件事都是量出來的,不是偏好:

  一份邏輯   搜尋原本長在 recall_for_conversation 的 main() 裡,所以 router
             只能另開一個 Python 行程去用它。同一份程式兩個入口,而其中一個
             要付行程啟動的錢,還把例外變成無法解析的 stdout。

  兩層平行   continuity 花 2,682ms 在本機向量運算,卡片層花 1,187ms 等外部
             行程,而且互不相依。改成平行後 hard 召回從 3,373ms 降到 2,180ms。
             hook 只給六秒,而索引還在長。

  要出聲     那條路上原本有兩層 except,外層會回報、內層先把例外吃成空陣列,
             所以回報從來沒有執行過。
"""
from __future__ import annotations

import ast
import io
import sys
import threading
import unittest
from contextlib import redirect_stderr
from pathlib import Path
from unittest import mock

ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT / "scripts"))

import recall_router  # noqa: E402


class RecallShapeTest(unittest.TestCase):
    def source(self) -> str:
        return Path(recall_router.__file__).read_text(encoding="utf-8")

    def test_the_search_is_not_a_child_process(self) -> None:
        """再開一次行程就是又多一份索引載入,和一個無法解析的 stdout。"""
        for node in ast.walk(ast.parse(self.source())):
            if not isinstance(node, ast.Call):
                continue
            target = ast.unparse(node.func)
            if target.endswith("subprocess.run") or target == "run":
                args = ast.unparse(node).replace(" ", "")
                self.assertNotIn(
                    "recall_for_conversation", args,
                    "召回不該再另開行程跑 recall_for_conversation.py",
                )

    def test_the_two_expensive_layers_overlap(self) -> None:
        """Both searches must start before either finishes; no timing race."""
        rendezvous = threading.Barrier(2, timeout=3)

        def search(result):
            def run(*args, **kwargs):
                rendezvous.wait()
                return result
            return run

        parsed = recall_router.ParseResult("continuity", "hard", 1.0, "fixture", [])
        with mock.patch.object(recall_router, "parse_state", return_value=parsed), \
             mock.patch.object(recall_router, "run_associative_recall", side_effect=search(("", []))), \
             mock.patch.object(recall_router, "continuity_recall", side_effect=search([])), \
             mock.patch.object(recall_router, "action_recall", return_value=[]), \
             mock.patch.object(recall_router, "_dedup_filter_new", side_effect=lambda rows: (rows, 0)), \
             mock.patch.object(recall_router, "_dedup_mark_shown"), \
             mock.patch.object(recall_router, "_write_telemetry"):
            self.assertEqual(recall_router.route("fixture")["trigger_class"], "hard")

    def test_a_failed_search_is_reported_not_emptied(self) -> None:
        """後端壞掉回空陣列,跟「這個主題沒有知識」長得一模一樣。"""
        import recall_for_conversation

        def boom(*_args, **_kwargs):
            raise RuntimeError("後端壞掉")

        buf = io.StringIO()
        with mock.patch.object(recall_for_conversation, "search", boom):
            with redirect_stderr(buf):
                text, results = recall_router.run_associative_recall("測試", limit=2)

        self.assertEqual(results, [], "行為不變:呼叫端不會因此中斷")
        self.assertIn("associative recall", buf.getvalue(), "但必須留下痕跡")
        self.assertIn("error", text, "回傳的文字也要說明是錯誤,不是查無資料")


if __name__ == "__main__":
    unittest.main()