Ctrl K
ComunidadGuías…

tests/test_harness_text_is_not_a_turn.py

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

Archivo original ↗Preguntar a KiBot
"""harness 產生的文字不是一輪對話,不該抵達召回。

2026-09-24 在正式資料的 1,020 個 turn 裡量到 137 個(13.5%)的 query 其實是
harness 產生的文字,最大宗是背景任務完成通知。它們同時污染三個地方:turns 表、
對話軌跡,以及召回。

召回那邊的後果看得見:這種文字剛好跟 openclaw 類的卡片用字重疊,所以餘弦給
0.57~0.62,高過 0.55 的地板,整批被注入進 context。同一批候選 jev 給 0.03~0.10
——也就是說,這不是門檻調錯,是這些文字根本不該被拿去查。

**這份測試用的是資料庫裡真實出現過的字串。** 今天已經犯過一次「測試覆蓋的是我
想像的情況,不是實際發生的那個」,所以下面每一個樣本都是從 turns 表撈出來的。

最重要的一條是誤殺:`<pasted_content id="...">` 是 Pan 貼東西進來時的包裝,資料
裡有 5 筆。用「以 < 開頭」這種寬鬆規則會把它一起擋掉,等於他貼的東西全部變成
看不見——那比多注入幾筆雜訊糟得多。
"""
from __future__ import annotations

import sys
import tempfile
import unittest
from pathlib import Path
from unittest import mock

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

import xkb_agent_hook  # noqa: E402
from conversation_state_parser import is_harness_text, noise_kind  # noqa: E402

# 以下四段都是 turns 表裡真實出現過的開頭。
REAL_TASK_NOTIFICATION = (
    "<task-notification>\n<task-id>a063252154283d8bf</task-id>\n"
    "<output-file>/tmp/x.output</output-file>\n<status>completed</status>\n"
    "</task-notification>")
REAL_OPENCLAW_CONTEXT = (
    "<<<BEGIN_OPENCLAW_INTERNAL_CONTEXT>>>\n"
    "OpenClaw runtime context (internal):\n"
    "This context is runtime-generated, not user-authored.\n"
    "<<<END_OPENCLAW_INTERNAL_CONTEXT>>>")
REAL_PASTED_CONTENT = (
    '<pasted_content id="46f0"> 畫面由 GPS 研究流程拉回到 REALI 呈現品牌資料、'
    "不同 AI Agent、團隊分工")
REAL_QUESTION = "食品展的客戶通常怎麼找"


class ItRecognisesWhatTheHarnessWrote(unittest.TestCase):
    def test_the_two_shapes_that_actually_occurred(self):
        self.assertTrue(is_harness_text(REAL_TASK_NOTIFICATION))
        self.assertTrue(is_harness_text(REAL_OPENCLAW_CONTEXT))

    def test_the_marker_must_be_at_the_start(self):
        """137 筆全部是開頭就帶標記,沒有夾在中間的。

        不錨定的話,一句提到這些標記的真話會被當成 harness 文字——而這份
        對話本身就是這樣的一句。
        """
        self.assertFalse(is_harness_text(
            "為什麼 <task-notification> 會被當成查詢送進召回?"))

    def test_leading_whitespace_does_not_hide_it(self):
        self.assertTrue(is_harness_text("\n  " + REAL_TASK_NOTIFICATION))

    def test_it_is_reported_as_its_own_kind(self):
        """跟問候/應答分開,因為要修的東西不一樣。

        問候是判斷問題(之後交給 jev),這個是接線問題——這種文字根本不該
        抵達召回。混成同一種的話,報告上看不出哪一種在發生。
        """
        self.assertEqual(noise_kind(REAL_TASK_NOTIFICATION), "harness_text")
        self.assertEqual(noise_kind("早安"), "greeting")
        self.assertEqual(noise_kind("好的"), "acknowledgement")


class ItMustNotSwallowWhatPanPasted(unittest.TestCase):
    """最貴的誤殺。資料裡有 5 筆 `<pasted_content>`,那是他貼進來的東西。"""

    def test_pasted_content_is_not_harness_text(self):
        self.assertFalse(is_harness_text(REAL_PASTED_CONTENT))
        self.assertEqual(noise_kind(REAL_PASTED_CONTENT), "")

    def test_a_plain_question_is_untouched(self):
        self.assertFalse(is_harness_text(REAL_QUESTION))
        self.assertEqual(noise_kind(REAL_QUESTION), "")

    def test_an_angle_bracket_alone_means_nothing(self):
        for text in ("<div> 這段 HTML 要怎麼改", "<3", "<= 這個符號怎麼念"):
            with self.subTest(text=text):
                self.assertFalse(is_harness_text(text))


class TheHookDoesNotOpenATurnForIt(unittest.TestCase):
    """不是「開了 turn 但不召回」——它根本不是一輪對話。"""

    def setUp(self):
        self.tmp = tempfile.TemporaryDirectory()
        self.addCleanup(self.tmp.cleanup)

    def _run(self, prompt: str) -> list[str]:
        calls: list[str] = []

        def spy(path, body, cfg):
            calls.append(path)
            return {"session_id": "s", "turn_id": "t", "retrieval": {"records": []}}

        state = Path(self.tmp.name) / "state.json"
        with mock.patch.object(xkb_agent_hook, "call", spy), \
             mock.patch.object(xkb_agent_hook, "session_key", return_value="k"), \
             mock.patch.object(xkb_agent_hook, "next_ordinal", return_value=1), \
             mock.patch.object(xkb_agent_hook, "STATE_DIR", Path(self.tmp.name)), \
             mock.patch.object(xkb_agent_hook, "state_path", return_value=state), \
             mock.patch.object(xkb_agent_hook, "emit"):
            xkb_agent_hook.on_prompt({"prompt": prompt}, {"source": "test"})
        return calls

    def test_harness_text_leaves_no_state_behind(self):
        """不開 turn,也不該留下狀態——留下的話會讓下一輪對不上。"""
        self._run(REAL_TASK_NOTIFICATION)
        self.assertEqual(list(Path(self.tmp.name).glob("*.json")), [])

    def test_a_task_notification_opens_nothing(self):
        self.assertEqual(self._run(REAL_TASK_NOTIFICATION), [],
                         "連 session 都不該開——它不是一輪對話")

    def test_the_openclaw_internal_context_opens_nothing(self):
        self.assertEqual(self._run(REAL_OPENCLAW_CONTEXT), [])

    def test_a_real_prompt_still_opens_a_turn(self):
        calls = self._run(REAL_QUESTION)
        self.assertIn("/v1/sessions/open", calls)
        self.assertIn("/v1/turns/start", calls)

    def test_pasted_content_still_opens_a_turn(self):
        calls = self._run(REAL_PASTED_CONTENT)
        self.assertIn("/v1/turns/start", calls)


class OneDefinitionForBothEnds(unittest.TestCase):
    """hook 擋一次、召回再擋一次,但判斷只能有一份。

    兩邊各寫一份就會各自漂移——這個專案的「多寫入者一讀取者」記錄在案,而
    _skip_reason 的註解裡也寫著它從 parser 拿清單的理由:它自己那份拷貝曾經
    漏掉複合應答,於是「ok 收到」撈了十筆進一個什麼都沒問的對話。
    """

    def test_the_hook_imports_the_definition(self):
        import inspect
        source = inspect.getsource(xkb_agent_hook)
        self.assertIn("from conversation_state_parser import is_harness_text",
                      source)
        self.assertNotIn("task-notification", source,
                         "標記清單不要在 hook 裡再寫一份")

    def test_recall_skips_it_through_the_same_function(self):
        import inspect
        from xkb_memory_service import Store
        source = inspect.getsource(Store._skip_reason)
        self.assertIn("_noise_kind", source)
        self.assertEqual(noise_kind(REAL_TASK_NOTIFICATION), "harness_text",
                         "召回端的 skip_reason 會直接變成這個字串")


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


class TheOldRowsStillCrowdTheRecallWindow(unittest.TestCase):
    """在 hook 端擋掉之前累積的那些列,還躺在資料庫裡。

    對話召回只看**最近 500 筆** turn。2026-09-24 的資料庫裡有 137 筆 turn 的 query
    是背景任務通知——也就是視窗的 27% 被佔掉,真正的對話歷史被擠出去。在 Python 端
    過濾救不了這件事:它們已經佔掉名額了,所以排除要發生在 SQL 裡。

    刻意不刪那些列:它們的 answer 是真實的回答內容。代價是那些答案從此不會被對話
    召回撈到——真有知識價值的內容,它的家是卡片或 wiki,不是一個問句是系統通知的
    對話軌跡。
    """

    def setUp(self):
        from xkb_memory_service import Store
        self.tmp = tempfile.TemporaryDirectory()
        self.addCleanup(self.tmp.cleanup)
        self.store = Store(Path(self.tmp.name) / "memory.sqlite")
        # 用真正的 API 建資料,不要自己寫 INSERT——第一版我猜了 sessions 的欄位,
        # 猜錯了。走公開路徑的話,schema 改了測試會跟著壞,那是對的。
        session = self.store.open_session({
            "source": "test", "agent_id": "test",
            "session_key": "k", "namespace": "private"})
        rows = [
            ("t1", "食品展的客戶通常怎麼找", "展前拿名單、展中面對面、展後跟催"),
            ("t2", REAL_TASK_NOTIFICATION, "食品展的客戶開發我剛做完了"),
            ("t3", REAL_OPENCLAW_CONTEXT, "食品展相關的內部通知"),
        ]
        for turn_id, query, answer in rows:
            self.store.start_turn({"session_id": session["session_id"],
                                   "turn_id": turn_id, "query": query})
            self.store.complete_turn(turn_id, {
                "session_id": session["session_id"],
                "query": query, "answer": answer})

    def test_harness_turns_never_enter_the_window(self):
        result = self.store.recall("食品展的客戶通常怎麼找", limit=10,
                                   namespace="private")
        queries = [m["query"] for m in result["memories"]]

        self.assertEqual(queries, ["食品展的客戶通常怎麼找"],
                         "harness 產生的列不該佔用最近 500 筆的名額")

    def test_a_real_turn_is_still_recalled(self):
        result = self.store.recall("展前拿名單", limit=10, namespace="private")
        self.assertEqual(len(result["memories"]), 1)

    def test_the_rows_are_not_deleted(self):
        """排除的是召回,不是資料。那些 answer 是真實的回答內容。"""
        with self.store.connect() as db:
            count = db.execute("SELECT COUNT(*) FROM turns").fetchone()[0]
        self.assertEqual(count, 3)