Ctrl K
ComunidadGuías…

tests/test_xkb_memory_service.py

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

Archivo original ↗Preguntar a KiBot
from __future__ import annotations

import importlib.util
import tempfile
import unittest
import unittest.mock
from unittest import mock
from pathlib import Path

SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "xkb_memory_service.py"
spec = importlib.util.spec_from_file_location("xkb_memory_service", SCRIPT)
assert spec and spec.loader
module = importlib.util.module_from_spec(spec)
import sys
sys.modules[spec.name] = module
spec.loader.exec_module(module)
Store = module.Store

_wiki_patch = None


def setUpModule() -> None:
    """讓測試不要碰到真實的知識庫。

    recall 加入 wiki 搜尋之後,任何呼叫 knowledge_recall 的測試都會去載入
    使用者真實的語意索引(本機 1,872 筆),再對 Gemini 打一次 embedding。
    那讓測試變慢、要花錢、依賴網路,而且結果隨著知識庫內容改變——
    在這台機器上甚至會 segfault。

    預設回空,個別測試要驗 wiki 行為時再自己 patch。
    """
    global _wiki_patch
    _wiki_patch = mock.patch.object(module.KnowledgeCatalog, "_wiki_search", return_value=[])
    _wiki_patch.start()


def tearDownModule() -> None:
    if _wiki_patch is not None:
        _wiki_patch.stop()


class XKBMemoryServiceTests(unittest.TestCase):
    def setUp(self) -> None:
        self.tmp = tempfile.TemporaryDirectory()
        self.store = Store(Path(self.tmp.name) / "memory.sqlite")
        self.store.catalog.index_file = Path(self.tmp.name) / "search_index.json"
        self.store.catalog.cards_dir = Path(self.tmp.name) / "cards"
        self.store.catalog.wiki_topics_dir = Path(self.tmp.name) / "wiki" / "topics"

    def tearDown(self) -> None:
        self.tmp.cleanup()

    def test_session_turn_complete_recall_and_idempotency(self) -> None:
        session = self.store.open_session(
            {
                "source": "fixture",
                "agent_id": "fixture",
                "namespace": "private",
                "session_key": "session-1",
            }
        )
        resumed = self.store.open_session(
            {
                "source": "fixture",
                "agent_id": "fixture",
                "namespace": "private",
                "session_key": "session-1",
            }
        )
        self.assertFalse(session["resumed"])
        self.assertTrue(resumed["resumed"])
        turn = self.store.start_turn(
            {
                "session_id": session["session_id"],
                "turn_id": "turn-1",
                "query": "shared memory query",
            }
        )
        self.assertFalse(turn["resumed"])
        completed = self.store.complete_turn(
            "turn-1",
            {
                "session_id": session["session_id"],
                "query": "shared memory query",
                "answer": "shared memory answer",
                "status": "succeeded",
                "content": {"answer": "shared memory answer"},
            },
        )
        duplicate = self.store.complete_turn(
            "turn-1",
            {
                "session_id": session["session_id"],
                "query": "shared memory query",
                "answer": "shared memory answer",
                "status": "succeeded",
                "content": {"answer": "shared memory answer"},
            },
        )
        self.assertTrue(completed["stored"])
        self.assertEqual(completed["retrieval"]["query"], "shared memory query")
        with self.store.connect() as db:
            persisted = db.execute("SELECT status, retrieval_json, trace_id FROM turns WHERE turn_id=?", ("turn-1",)).fetchone()
        self.assertEqual(persisted["status"], "succeeded")
        self.assertEqual(persisted["trace_id"], completed["trace_id"])
        self.assertEqual(__import__("json").loads(persisted["retrieval_json"])["query"], "shared memory query")
        self.assertTrue(duplicate["deduplicated"])
        recall = self.store.recall("shared memory")
        self.assertEqual(recall["count"], 1)
        self.assertIn("shared memory answer", recall["context"])

        artifact = self.store.artifact(completed["trace_id"])
        self.assertIsNotNone(artifact)
        self.assertEqual(artifact["retrieval"]["query"], "shared memory query")

    def test_input_validation_rejects_empty_query_bad_limits_and_status(self) -> None:
        with self.assertRaisesRegex(ValueError, "query is required"):
            self.store.knowledge_recall("  ")
        with self.assertRaisesRegex(ValueError, "limit must be an integer"):
            self.store.knowledge_recall("query", limit="10")
        with self.assertRaisesRegex(ValueError, "between 1 and 50"):
            self.store.knowledge_recall("query", limit=0)
        session = self.store.open_session({"source": "fixture", "session_key": "validation"})
        with self.assertRaisesRegex(ValueError, "namespace is required"):
            self.store.open_session({"source": "fixture", "session_key": "bad", "namespace": ""})
        self.store.start_turn({"session_id": session["session_id"], "turn_id": "validation-turn", "query": "query"})
        with self.assertRaisesRegex(ValueError, "status must be"):
            self.store.complete_turn("validation-turn", {"query": "query", "answer": "answer", "status": "running"})

    def test_cancelled_turn_is_not_stored(self) -> None:
        session = self.store.open_session({"source": "fixture", "session_key": "session-2"})
        self.store.start_turn({"session_id": session["session_id"], "turn_id": "turn-2", "query": "cancel me"})
        result = self.store.complete_turn("turn-2", {"status": "cancelled"})
        self.assertFalse(result["stored"])
        self.assertEqual(self.store.recall("cancel me")["count"], 0)

    def test_unified_recall_includes_external_knowledge_records(self) -> None:
        original = self.store.catalog

        class FixtureCatalog:
            def search(self, query, limit=10, namespace="private"):
                return {
                    "records": [{
                        "record_type": "knowledge_card",
                        "id": "fixture-card",
                        "title": "Fixture knowledge",
                        "summary": "shared knowledge evidence",
                        "score": 3,
                    }]
                }

        self.store.catalog = FixtureCatalog()
        try:
            result = self.store.knowledge_recall("shared knowledge")
        finally:
            self.store.catalog = original
        self.assertEqual(result["count"], 1)
        self.assertEqual(result["records"][0]["record_type"], "knowledge_card")
        self.assertIn("Fixture knowledge", result["context"])

    def test_catalog_fails_closed_across_namespaces_and_exposes_metadata(self) -> None:
        catalog = module.KnowledgeCatalog()
        root = Path(self.tmp.name)
        catalog.cards_dir = root / "cards"
        catalog.wiki_topics_dir = root / "topics"
        catalog.index_file = root / "index.json"
        catalog.cards_dir.mkdir()
        catalog.wiki_topics_dir.mkdir()
        (catalog.cards_dir / "secret.md").write_text("---\nnamespace: team-a\nsensitivity: internal\n---\nsecret", encoding="utf-8")
        (catalog.index_file).write_text('{"items":[{"id":"secret","title":"Secret"}]}', encoding="utf-8")
        self.assertIsNone(catalog.card("secret", "team-b"))
        visible = catalog.card("secret", "team-a")
        self.assertEqual(visible["namespace"], "team-a")
        self.assertEqual(catalog.search("secret", namespace="team-b")["count"], 0)

    def test_semantic_backend_is_used_and_declared(self) -> None:
        original = module.xbrain_query

        def fixture_query(query, *, limit, no_expand, semantic, diagnostics=None):
            self.assertEqual(query, "semantic fixture")
            self.assertTrue(semantic)
            return [{
                "slug": "fixture-semantic-card",
                "title": "Semantic fixture",
                "chunk_text": "vector evidence",
                "source_url": "https://example.test/source",
                "type": "knowledge-card",
                "score": 0.91,
            }]

        module.xbrain_query = fixture_query
        try:
            # 前提要寫出來。原本這個測試能過,只是因為 fixture 的 slug 剛好
            # 不在這台機器的 cards_index 裡——換一台機器、或哪天真的建了同名
            # 的卡片,斷言就會翻面。測試不該從磁碟繼承它的前提。
            import xkb_relevance
            with unittest.mock.patch.object(xkb_relevance, "similarities",
                                            return_value=None):
                result = self.store.catalog.search("semantic fixture", 3)
        finally:
            module.xbrain_query = original
        self.assertEqual(result["retrieval_mode"], "xbrain_hybrid")
        self.assertEqual(result["records"][0]["retrieval"], "xbrain_hybrid")
        record = result["records"][0]
        # 一個鍵都對不到向量索引時,這裡不做任何比較,也就不該假裝比過:
        # 後端的分數原樣帶著,只加上「沒驗證過」的標籤。
        # 跨層可比是合併點(knowledge_recall → xkb_score.rank)的事,
        # 不是這裡編一個位置出來——我在這個檔案上來回改了三輪才把它放對地方。
        self.assertEqual(record["score"], 0.91)
        self.assertEqual(record.get("score_basis"), "unverified")
        # 沒被驗證過的記錄有自己的尺度。這件事以前是靠在 xkb_relevance 裡
        # 改寫分數做到的,那等於捏造一個測量值;現在 score 保持是後端自己
        # 算的數字,排序交給 xkb_score 的尺度表。
        self.assertEqual(record.get("score_scale"), "unverified")
        self.assertNotIn("_unverified", record)

    def test_layers_are_ordered_by_a_comparable_score_not_raw_numbers(self) -> None:
        """合併之後要照跨層可比的分數排,不是照各層自己的原始數字。

        三層的尺度互不相同:卡片是 gbrain 的 RRF、wiki 是餘弦、對話是關鍵字
        比例(上限 0.65)。合併點原本直接照 score 排,等於拿公分比英吋。

        這是外部審查連續六輪繞著打轉的那個缺陷——每一輪我都在 xkb_relevance
        裡替驗不出相似度的項目找「壓到哪裡才對」,而不管壓到哪裡都會撞上另一層
        的尺度:壓高壓過 wiki 的真餘弦,壓低掉到對話軌跡之下,而後者會讓索引
        壞掉長得像「知識庫裡沒東西」。真正該修的是合併點。

        測試要走真正的路徑。我第一版直接呼叫 xkb_score.rank(),於是把合併點
        改回原始排序它照樣是綠的——測到的是那個函式,不是那條路。
        """
        def packet_for(card_score: float) -> list[str]:
            knowledge = {
                "records": [
                    {"title": "卡片", "score": card_score, "score_scale": "card_semantic",
                     "record_type": "knowledge_chunk"},
                    {"title": "wiki 結論", "score": 0.68, "score_scale": "wiki_semantic",
                     "record_type": "wiki_topic"},
                ],
                "retrieval_mode": "xbrain_hybrid", "filtered_counts": {},
                "semantic_retrieval_attempted": True,
                "semantic_backend": {"status": "used"},
                "dropped_as_irrelevant": 0,
            }
            conversation = {"memories": [{"query": "對話軌跡", "answer": "", "score": 0.65}]}
            with mock.patch.object(type(self.store.catalog), "search",
                                   return_value=knowledge), \
                 mock.patch.object(type(self.store), "recall", return_value=conversation):
                packet = self.store.knowledge_recall("任意問題", 5, "private")
            return [r.get("title") or r.get("query") for r in packet["records"]]

        strong = packet_for(0.75)
        self.assertEqual(strong[0], "wiki 結論",
                         "消化過的結論該排在相近強度的原始素材之前")
        self.assertLess(strong.index("卡片"), strong.index("對話軌跡"),
                        "語意命中的卡片該排在關鍵字命中的對話軌跡之前")

        # 對話軌跡仍然在封包裡——不是被系統性地丟掉。RRF 之下它就是另一條腿,
        # 照名次貢獻,不再被雙重折扣(呼叫端 0.65 折扣 + 尺度表未折扣錨點)
        # 壓到門檻之下、一填滿 limit 就整段消失。
        marginal = packet_for(0.56)
        self.assertIn("對話軌跡", marginal,
                      "對話層被整段擠出封包")

        # 截斷防護:知識層填滿 limit 時,最強的對話軌跡仍要進封包(不是被
        # marginal 卡片擠到 records[:limit] 之外)。這是舊測試那條順序斷言真正
        # 要守的東西——RRF 之下順序斷言不再成立,但「不被整段截掉」還是要守。
        many_cards = {
            "records": [
                {"title": f"卡片{n}", "score": 0.56, "score_scale": "card_semantic",
                 "record_type": "knowledge_chunk"} for n in range(8)
            ],
            "retrieval_mode": "xbrain_hybrid", "filtered_counts": {},
            "semantic_retrieval_attempted": True,
            "semantic_backend": {"status": "used"}, "dropped_as_irrelevant": 0,
        }
        conversation = {"memories": [{"query": "強對話軌跡", "answer": "", "score": 0.65}]}
        with mock.patch.object(type(self.store.catalog), "search", return_value=many_cards),              mock.patch.object(type(self.store), "recall", return_value=conversation):
            packet = self.store.knowledge_recall("任意", 5, "private")
        titles = [r.get("title") or r.get("query") for r in packet["records"]]
        self.assertIn("強對話軌跡", titles, "知識層填滿 limit 後對話軌跡被整段截掉")

        # scale mixing 結構上不可能:關鍵字尺度的分數(6~20)跟餘弦尺度的
        # 分數(0~1)在 RRF 裡都只是「腿內第 N 名」,不會因為原始數字大就碾壓。
        import xkb_score
        mixed = xkb_score.rank([
            {"title": "關鍵字命中的書籤", "score": 15.0, "score_scale": "card_keyword"},
            {"title": "餘弦命中的卡片", "score": 0.62, "score_scale": "card_semantic"},
        ])
        self.assertLess(abs(mixed[0]["unified_score"] - mixed[1]["unified_score"]),
                        0.001,
                        "15 分的關鍵字書籤把 0.62 的餘弦卡片碾壓了——scale mixing 回來了")

    def test_each_producer_declares_a_scale_xkb_score_knows(self) -> None:
        """算出分數的地方要標對尺度,而且是 xkb_score 認得的那些。

        source_type 是「這是什麼東西」(會回給 API 用戶),score_scale 是
        「這個數字怎麼來的」。標錯或沒標,整層就會用錯錨點對齊,而且不會有
        任何錯誤訊息——不認得的尺度會靜靜掉到 0.5 的 fallback。

        我第一版這個測試是在原始碼文字裡找 `score_scale` 這個子字串,於是
        它對「值是錯的」完全無感——寫這個測試的當下就有兩個標籤是錯的,
        它照樣是綠的。改成檢查實際產出的值。
        """
        import xkb_score

        known = set(xkb_score.DEFAULT_ANCHORS) & set(xkb_score.DEFAULT_WEIGHTS)

        def fixture_query(query, *, limit, no_expand, semantic, diagnostics=None):
            return [{"slug": "s", "title": "t", "chunk_text": "c",
                     "source_url": "", "type": "knowledge-card", "score": 0.8}]

        original = module.xbrain_query
        module.xbrain_query = fixture_query
        try:
            packet = self.store.knowledge_recall("尺度測試", 5, "private")
        finally:
            module.xbrain_query = original

        scales = {r.get("score_scale") for r in packet["records"]}
        self.assertTrue(scales, "一筆記錄都沒有,測不到尺度")
        self.assertNotIn(None, scales, "有記錄沒有標尺度")
        for scale in scales:
            self.assertIn(scale, known,
                          f"{scale} 不在 xkb_score 的尺度表裡,會靜靜掉到 fallback")

    def test_every_record_template_declares_its_specific_scale(self) -> None:
        """每一個產生分數的樣板都要標對尺度——包括這個測試檔案 mock 掉的那些。

        動態的那個測試只走得到 fixture 那一筆:setUpModule 把 _wiki_search 整個
        換成回空陣列、store 也是空的,所以 wiki 與對話那兩個樣板沒有任何測試
        看著。我刪掉的靜態測試本來守著它們,替換的版本反而涵蓋更少。

        而靜態檢查要看**值**。我更早的版本只找 score_scale 這個欄位名,
        於是四個標籤裡有兩個是錯的時候它照樣是綠的。
        """
        source = (Path(module.__file__).parent / "xkb_memory_service.py").read_text(
            encoding="utf-8")
        expected = {
            '"retrieval": "wiki_semantic"': "wiki_semantic",
            '"record_type": "conversation_trace"': "conversation",
        }
        # GBrain declares its scale at the adapter boundary; the service must
        # preserve it. Runtime coverage is in test_recall_backend_contract.
        self.assertIn('"score_scale": hit.get("score_scale", "card")', source)
        for marker, scale in expected.items():
            idx = source.index(marker)
            window = source[max(0, idx - 700):idx + 300]
            self.assertIn(f'"score_scale": "{scale}"', window,
                          f"{marker} 的樣板沒有標成 {scale}")

    def test_the_keyword_fallback_declares_a_keyword_scale(self) -> None:
        """關鍵字退路的分數是 0~1 的覆蓋率,不是 RRF 也不是餘弦。

        這條路徑上的記錄一度被標成 card / wiki_semantic——用餘弦與 RRF 的錨點
        去對齊覆蓋率,會把真實的先後順序翻過來,正是收斂想消滅的那件事,
        只是搬到了退路上。上一版的測試連這兩個樣板都沒看到。
        """
        source = (Path(module.__file__).parent / "xkb_memory_service.py").read_text(
            encoding="utf-8")
        for marker in ('"retrieval": "keyword"',):
            idx = 0
            found = 0
            while True:
                idx = source.find(marker, idx + 1)
                if idx < 0:
                    break
                found += 1
                template = source[max(0, idx - 700):idx]
                self.assertRegex(
                    template, r'"score_scale": "(card_keyword|wiki_keyword)"',
                    "關鍵字退路的記錄要標關鍵字尺度")
            self.assertGreater(found, 0, "找不到關鍵字退路的記錄樣板")

    def test_recall_packet_explains_acl_namespace_filtering_and_backend(self) -> None:
        original = module.xbrain_query

        def fixture_query(query, *, limit, no_expand, semantic, diagnostics=None):
            return [
                {"slug": "allowed", "title": "Allowed", "chunk_text": "visible", "namespace": "team-a"},
                {"slug": "hidden", "title": "Hidden", "chunk_text": "secret", "namespace": "team-b"},
            ]

        module.xbrain_query = fixture_query
        try:
            result = self.store.knowledge_recall("acl packet", namespace="team-a")
        finally:
            module.xbrain_query = original
        self.assertEqual(result["request_namespace"], "team-a")
        self.assertEqual(result["acl_policy"]["decision"], "allow_public_or_matching_namespace")
        self.assertEqual(result["filtered_counts"]["semantic"], 1)
        self.assertIn("records_filtered_by_acl", result["warnings"])
        self.assertEqual(result["semantic_backend"]["status"], "used")
        # The breakdown must say which layer was filtered, otherwise "nothing
        # came back" and "the wiki layer was filtered out" look identical.
        self.assertEqual(result["filtered_counts"]["total"], 1)
        self.assertEqual(result["filtered_counts"]["by_layer"]["semantic"], 1)
        self.assertEqual(result["filtered_counts"]["by_layer"]["conversation"], 0)

    def test_acl_filter_counts_are_attributed_per_layer(self) -> None:
        """A quiet result must say which layer the ACL removed."""
        module = importlib.import_module("xkb_memory_service")
        original = module.xbrain_query
        module.xbrain_query = None  # force the keyword path, where layers are known
        try:
            result = self.store.knowledge_recall("anything", namespace="team-a")
        finally:
            module.xbrain_query = original
        counts = result["filtered_counts"]
        self.assertEqual(result["retrieval_mode"], "keyword_fallback")
        self.assertEqual(set(counts["by_layer"]), {"card", "wiki", "semantic", "conversation"})
        self.assertEqual(counts["total"], sum(counts["by_layer"].values()))
        # No semantic backend ran, so nothing can be attributed to it.
        self.assertEqual(counts["by_layer"]["semantic"], 0)
        # Legacy channel keys stay consistent with the layer breakdown.
        self.assertEqual(counts["keyword"], counts["by_layer"]["card"] + counts["by_layer"]["wiki"])

    def test_turn_start_persists_retrieval_packet_and_source_ids(self) -> None:
        original = self.store.knowledge_recall
        packet = {
            "schema": "xkb-knowledge-service.v1",
            "query": "turn query",
            "records": [{"id": "knowledge-1", "record_type": "knowledge_chunk", "score": 0.8}],
            "context": "historical evidence",
            "retrieval_mode": "xbrain_hybrid",
            "semantic_retrieval_attempted": True,
            "warnings": [],
        }
        self.store.knowledge_recall = lambda query, limit=10, namespace="private", conversation=None, **_: packet
        try:
            session = self.store.open_session({"source": "fixture", "session_key": "retrieval-session"})
            started = self.store.start_turn({"session_id": session["session_id"], "turn_id": "retrieval-turn", "query": "turn query"})
            resumed = self.store.start_turn({"session_id": session["session_id"], "turn_id": "retrieval-turn", "query": "turn query"})
        finally:
            self.store.knowledge_recall = original
        self.assertEqual(started["source_memory_ids"], ["knowledge-1"])
        self.assertEqual(started["retrieval"]["retrieval_mode"], "xbrain_hybrid")
        self.assertTrue(resumed["resumed"])
        self.assertEqual(resumed["retrieval"]["query"], "turn query")

    def test_turn_start_rejects_idempotency_conflicts(self) -> None:
        first = self.store.open_session({"source": "fixture", "session_key": "identity-1", "namespace": "private"})
        other = self.store.open_session({"source": "fixture", "session_key": "identity-2", "namespace": "private"})
        self.store.start_turn({"session_id": first["session_id"], "turn_id": "identity-turn", "query": "original"})
        with self.assertRaisesRegex(ValueError, "session_id"):
            self.store.start_turn({"session_id": other["session_id"], "turn_id": "identity-turn", "query": "original"})
        with self.assertRaisesRegex(ValueError, "query"):
            self.store.start_turn({"session_id": first["session_id"], "turn_id": "identity-turn", "query": "changed"})
        with self.assertRaisesRegex(ValueError, "namespace"):
            self.store.start_turn({"session_id": first["session_id"], "turn_id": "identity-turn", "query": "original", "namespace": "shared"})

    def test_recall_is_scoped_to_session_namespace_and_start_requires_matching_namespace(self) -> None:
        private = self.store.open_session({"source": "fixture", "session_key": "private", "namespace": "private"})
        shared = self.store.open_session({"source": "fixture", "session_key": "shared", "namespace": "shared"})
        self.store.start_turn({"session_id": private["session_id"], "turn_id": "private-turn", "query": "same namespace secret"})
        self.store.complete_turn("private-turn", {"query": "same namespace secret", "answer": "private answer"})
        self.store.start_turn({"session_id": shared["session_id"], "turn_id": "shared-turn", "query": "same namespace secret"})
        self.store.complete_turn("shared-turn", {"query": "same namespace secret", "answer": "shared answer"})
        self.assertEqual([m["answer"] for m in self.store.recall("same namespace", namespace="private")["memories"]], ["private answer"])
        self.assertEqual([m["answer"] for m in self.store.recall("same namespace", namespace="shared")["memories"]], ["shared answer"])
        with self.assertRaisesRegex(ValueError, "namespace conflicts with session"):
            self.store.start_turn({"session_id": private["session_id"], "turn_id": "bad-namespace", "query": "q", "namespace": "shared"})

    def test_turn_start_rejects_unknown_session(self) -> None:
        with self.assertRaisesRegex(ValueError, "unknown session_id"):
            self.store.start_turn({"session_id": "missing", "turn_id": "missing-turn", "query": "q"})

    def test_pipeline_snapshot_is_read_only_and_lists_known_stages(self) -> None:
        snapshot = self.store.catalog.pipeline_snapshot(days=7)
        self.assertTrue(snapshot["read_only"])
        self.assertEqual(snapshot["control_plane"], "observed_status_only")
        stages = {item["stage"] for item in snapshot["stages"]}
        self.assertEqual(
            stages,
            {"ingest", "card_generation", "index", "distill", "promotion", "publish"},
        )
        self.assertTrue(all(item["control"] == "read_only" for item in snapshot["stages"]))
        self.assertIn("summary", snapshot)

    def test_job_event_is_persisted_and_filterable_without_running_worker(self) -> None:
        created = self.store.record_job_event({
            "job_id": "job-1",
            "stage": "index",
            "worker": "build_vector_index.py",
            "status": "running",
            "input_ref": "cards:batch-1",
            "metadata": {"count": 3},
        })
        self.assertTrue(created["stored"])
        self.assertTrue(created["observed_only"])
        completed = self.store.record_job_event({
            "job_id": "job-1",
            "stage": "index",
            "worker": "build_vector_index.py",
            "status": "succeeded",
            "output_ref": "vector-index:batch-1",
        })
        self.assertEqual(completed["status"], "succeeded")
        result = self.store.list_jobs(stage="index", status="succeeded")
        self.assertEqual(result["count"], 1)
        self.assertEqual(result["jobs"][0]["output_ref"], "vector-index:batch-1")
        self.assertFalse(self.store.list_jobs(stage="distill")["count"])

class RelevanceAndIntentTests(unittest.TestCase):
    """Injecting ten results into every turn is what makes an unrelated
    question cost as much as a real one."""

    def setUp(self) -> None:
        self.module = module

    def test_acknowledgements_and_greetings_skip_retrieval_entirely(self) -> None:
        skip = self.module.Store._skip_reason
        self.assertEqual(skip("好"), "acknowledgement")
        self.assertEqual(skip("謝謝"), "acknowledgement")
        self.assertEqual(skip("早安"), "greeting")
        self.assertEqual(skip("hi"), "greeting")

    def test_real_questions_are_never_skipped(self) -> None:
        """Eight Chinese characters is a question, not chitchat."""
        skip = self.module.Store._skip_reason
        self.assertEqual(skip("碳盤查的計算方式"), "")
        self.assertEqual(skip("XKB 召回"), "")
        self.assertEqual(skip("我們之前怎麼處理碳盤查的"), "")

    def test_vector_key_maps_slug_onto_index_key(self) -> None:
        import xkb_relevance
        self.assertEqual(xkb_relevance.vector_key("01-topic/12345"), "01-topic/12345.md")
        self.assertEqual(xkb_relevance.vector_key("01-topic/12345.md"), "01-topic/12345.md")
        self.assertEqual(xkb_relevance.vector_key("https://x.com/i/status/1"), "")
        self.assertEqual(xkb_relevance.vector_key(""), "")

    def test_rank_score_is_replaced_by_measured_similarity(self) -> None:
        """The backend's score says "ranked first", not "is relevant"."""
        catalog = self.module.KnowledgeCatalog()
        records = [{"id": "a/1", "score": 0.88}, {"id": "a/2", "score": 0.87}]
        with mock.patch("xkb_relevance.similarities",
                               return_value={"a/1.md": 0.72, "a/2.md": 0.30}):
            kept, dropped = catalog._drop_irrelevant("q", records)
        self.assertEqual(dropped, 1)
        self.assertEqual([item["id"] for item in kept], ["a/1"])
        self.assertEqual(kept[0]["score"], 0.72)
        self.assertEqual(kept[0]["rank_score"], 0.88)

    def test_unavailable_similarity_passes_records_through(self) -> None:
        """A missing index must not silently delete every result."""
        catalog = self.module.KnowledgeCatalog()
        records = [{"id": "a/1", "score": 0.88}]
        with mock.patch("xkb_relevance.similarities", return_value=None):
            kept, dropped = catalog._drop_irrelevant("q", records)
        self.assertEqual((len(kept), dropped), (1, 0))

    def test_warnings_separate_broken_backend_from_nothing_relevant(self) -> None:
        broken = self.module._recall_warnings(
            {"retrieval_mode": "keyword_fallback", "dropped_as_irrelevant": 0}, {})
        self.assertIn("semantic_backend_unavailable_or_empty", broken[0])
        nothing_relevant = self.module._recall_warnings(
            {"retrieval_mode": "keyword_fallback", "dropped_as_irrelevant": 10}, {})
        self.assertIn("relevance floor", nothing_relevant[0])
        self.assertNotIn("unavailable", nothing_relevant[0])


class KnowledgeRetirementTests(unittest.TestCase):
    """Wiki and cards only ever grew; nothing ever aged out."""

    def setUp(self) -> None:
        self.tmp = tempfile.TemporaryDirectory()
        self.store = Store(Path(self.tmp.name) / "memory.sqlite")
        self.addCleanup(self.tmp.cleanup)

    def test_records_that_never_clear_the_floor_are_reported_as_cold(self) -> None:
        for _ in range(6):
            self.store.record_usage([("cold/1", 0.31, False), ("warm/1", 0.72, True)])
        report = self.store.cold_knowledge(min_considered=5)
        self.assertEqual([r["record_id"] for r in report["records"]], ["cold/1"])
        self.assertEqual(report["tracked_records"], 2)
        self.assertEqual(report["ever_useful"], 1)

    def test_useful_records_are_never_listed_however_often_retrieved(self) -> None:
        for _ in range(20):
            self.store.record_usage([("warm/1", 0.40, False)])
        self.store.record_usage([("warm/1", 0.80, True)])
        report = self.store.cold_knowledge(min_considered=5)
        self.assertEqual(report["records"], [])

    def test_counters_accumulate_and_keep_the_best_similarity(self) -> None:
        self.store.record_usage([("a/1", 0.30, False)])
        self.store.record_usage([("a/1", 0.51, False)])
        row = self.store.cold_knowledge(min_considered=1)["records"][0]
        self.assertEqual(row["considered_count"], 2)
        self.assertEqual(row["injected_count"], 0)
        self.assertAlmostEqual(row["best_similarity"], 0.51, places=4)
        self.assertIsNone(row["last_injected_at"])

    def test_retirement_is_advisory_only(self) -> None:
        """Provenance is the point; nothing may be deleted automatically."""
        self.store.record_usage([("cold/1", 0.10, False)] * 1)
        report = self.store.cold_knowledge(min_considered=1)
        self.assertFalse(report["automatic_retirement"])
        self.assertTrue(report["read_only"])

    def test_usage_accounting_never_breaks_recall(self) -> None:
        catalog = module.KnowledgeCatalog()
        catalog.usage_sink = lambda observations: (_ for _ in ()).throw(RuntimeError("db down"))
        with mock.patch("xkb_relevance.similarities", return_value={"a/1.md": 0.9}):
            kept, dropped = catalog._drop_irrelevant("q", [{"id": "a/1", "score": 0.88}])
        self.assertEqual((len(kept), dropped), (1, 0))


class QueryEmbeddingCacheTests(unittest.TestCase):
    """The same query is embedded twice per recall: once by hybrid search,
    once by the relevance filter. The second call is a wasted round trip."""

    def test_repeated_query_is_embedded_once(self) -> None:
        import continuity_recall as cr
        cr._QUERY_VECTORS.clear()
        calls = []

        def fake(query):
            calls.append(query)
            return [0.1, 0.2]

        with mock.patch.object(cr, "_embed_query_uncached", side_effect=fake):
            first = cr._embed_query("same question")
            second = cr._embed_query("same question")
            cr._embed_query("different question")
        self.assertEqual(first, second)
        self.assertEqual(calls, ["same question", "different question"])
        cr._QUERY_VECTORS.clear()

    def test_failure_is_cached_too_so_a_dead_provider_is_not_retried(self) -> None:
        import continuity_recall as cr
        cr._QUERY_VECTORS.clear()
        calls = []
        with mock.patch.object(cr, "_embed_query_uncached", side_effect=lambda q: calls.append(q) or None):
            self.assertIsNone(cr._embed_query("q"))
            self.assertIsNone(cr._embed_query("q"))
        self.assertEqual(len(calls), 1)
        cr._QUERY_VECTORS.clear()

    def test_cache_is_bounded(self) -> None:
        import continuity_recall as cr
        cr._QUERY_VECTORS.clear()
        with mock.patch.object(cr, "_embed_query_uncached", return_value=[0.0]):
            for i in range(cr._QUERY_VECTOR_LIMIT + 5):
                cr._embed_query(f"q{i}")
        self.assertLessEqual(len(cr._QUERY_VECTORS), cr._QUERY_VECTOR_LIMIT)
        cr._QUERY_VECTORS.clear()


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