Ctrl K
ComunidadGuías…

tests/test_one_definition.py

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

Archivo original ↗Preguntar a KiBot
"""一件事只能有一個定義。

這裡的每一條規則都對應一次真的發生過的故障,不是風格意見:

  路徑    2026-07-28 資料目錄搬家,兩半管線各自推導位置,卡片全被吃掉。
          xkb_paths 是為了這件事寫的,但十五支腳本 import 了它之後,
          又用 WORKSPACE / "memory" / ... 自己拼一次,共二十八處。

  數量    「有幾張卡片」有兩個答案,1,538 和 1,539:一支用 rglob,
          把治理判定重複而移走的卡片也算了進去。

  出處    self-derived 的判斷曾經有三個寫法,於是 913 條自產知識繞過了
          回音室降權。

  斷詞    「這段文字的詞是哪些」有過八份實作。那個正則對中文是壞的:
          整串中文被吃成一個 token,於是中文問句幾乎命中不了任何東西,
          看起來像知識庫沒內容。xkb_text 修好了它,但四支腳本仍各留一份。

  門檻    餘弦門檻曾經有三個數字、兩個預設值共用同一個環境變數
          (XKB_CARD_MIN_SIMILARITY 同時被 0.55 和 0.58 兩處讀走)。
          舊的防護存在,但白名單只列了兩支腳本,第三支自動繞過。

  尺度    score_scale 是散在各處的字串字面量。打錯一個字不會報錯,
          會靜默掉到 0.5 的 fallback,整層排序錯位。

  編碼    「檔案是什麼編碼」曾經有兩個答案:程式沒說,於是 Linux 上是
          UTF-8、Windows 上是 cp950。同一份卡片在 VPS 讀得到、在本機
          炸掉,而本機是 Pan 每次改動的第一站。

一個新的定義出現時,這裡會失敗,而不是等它在某個早上說出錯的數字。
"""
from __future__ import annotations

import ast
import re
import sys
import unittest
from pathlib import Path

ROOT = Path(__file__).resolve().parents[1]
SCRIPTS = ROOT / "scripts"

sys.path.insert(0, str(SCRIPTS))
import xkb_score  # noqa: E402

SHARED_PATHS = (
    "CARDS_DIR", "BOOKMARKS_DIR", "WIKI_DIR", "WIKI_TOPICS_DIR",
    "INDEX_FILE", "VECTOR_FILE", "TOPIC_PROFILE_FILE", "DATA_DIR",
)
ASSIGNMENT = re.compile(r"^(%s)\s*=\s*(.+)$" % "|".join(SHARED_PATHS), re.M)


def _sources() -> list[Path]:
    return [p for p in sorted(SCRIPTS.glob("*.py")) if p.name != "xkb_paths.py"]


class OneDefinitionTest(unittest.TestCase):
    def test_shared_paths_come_from_xkb_paths(self) -> None:
        """別名可以,重新推導不行。

        `CARDS_DIR = xkb_paths.CARDS_DIR` 只是本地名字;
        `CARDS_DIR = WORKSPACE / "memory" / "cards"` 是第二個定義,
        而且它不會跟著 .xkb.json 或 XKB_DATA_DIR 走。
        """
        offenders = []
        for path in _sources():
            lines = path.read_text(encoding="utf-8").splitlines()
            for i, line in enumerate(lines):
                match = ASSIGNMENT.match(line)
                if not match:
                    continue
                rhs, j = match.group(2), i + 1
                while rhs.count("(") > rhs.count(")") and j < len(lines):
                    rhs += " " + lines[j].strip()
                    j += 1
                if "xkb_paths" not in rhs:
                    offenders.append(f"{path.name}:{i + 1} {match.group(1)} = {rhs[:60]}")
        self.assertEqual(offenders, [], "這些路徑沒有取自 xkb_paths,資料目錄一搬就會指到舊位置")

    def test_cards_are_counted_in_one_place(self) -> None:
        """卡片是哪些檔案,只有 xkb_paths.card_files 說了算。

        cards/_deduped_redundant/ 裡是治理判定重複而移走的卡片。用 rglob
        會把它們算回來,用 glob 不會,於是同一個問題有兩個答案。
        """
        offenders = [
            f"{path.name}:{i + 1}"
            for path in _sources()
            for i, line in enumerate(path.read_text(encoding="utf-8").splitlines())
            if re.search(r"\bCARDS_DIR\b.*\.r?glob\(", line)
        ]
        self.assertEqual(offenders, [], "改用 xkb_paths.card_files() / card_ids()")

    def test_provenance_has_one_reader(self) -> None:
        """判斷「這是不是自己產的」只有 xkb_provenance 一份規則。

        健檢曾經自己寫一套,於是把 322 條正確標記的知識報成壞的。
        """
        offenders = []
        for path in _sources():
            text = path.read_text(encoding="utf-8")
            if path.name == "xkb_provenance.py":
                continue
            if re.search(r'["\']self-derived["\']|self-derived\|', text) \
                    and "xkb_provenance" not in text and "is_self_derived" not in text:
                offenders.append(path.name)
        self.assertEqual(offenders, [], "self-derived 的判斷要走 xkb_provenance")


    def test_tokenising_goes_through_xkb_text(self) -> None:
        """切詞的規則只有 xkb_text 一份。

        判準是「有沒有自己拿一條含 CJK 範圍的正則去掃文字」——re.sub 做
        slug、re.search 判斷有沒有中文都不算,那些不是在決定「詞是哪些」。
        """
        tokenising = re.compile(r"\b(findall|finditer|re\.compile|re\.split)\b")
        # 原始碼兩種寫法都算:跳脫的 \u4e00-\u9fff,和直接寫的字元範圍。
        cjk_range = re.compile(r"(?:\\u4e00|一)\s*-")
        offenders = []
        for path in _sources():
            if path.name == "xkb_text.py":
                continue
            for i, line in enumerate(path.read_text(encoding="utf-8").splitlines()):
                if line.lstrip().startswith("#"):
                    continue
                if tokenising.search(line) and cjk_range.search(line):
                    offenders.append(f"{path.name}:{i + 1}")
        self.assertEqual(offenders, [], "自己寫斷詞正則;改用 xkb_text.tokenize()")

    def test_similarity_thresholds_come_from_xkb_relevance(self) -> None:
        """餘弦門檻只有 xkb_relevance 說了算。

        別名可以(`CARD_MIN_SIMILARITY = xkb_relevance.threshold("card_recall")`),
        自己算一個不行。這條規則刻意掃全部腳本:上一版的防護寫死了兩支
        呼叫端的白名單,於是第三支寫自己的門檻時沒有任何東西發現。

        recall_router 的 MIN_SCORE_HARD / SOFT 不在這裡,因為它們不是餘弦:
        那是 xkb_score.rank() 之後的合併分數,另一套尺度、另一個量。
        """
        assignment = re.compile(r"^([A-Z_]*(?:MIN_SIMILARITY|MIN_RELEVANCE)[A-Z_]*)\s*=\s*(.+)$", re.M)
        offenders = []
        for path in _sources():
            if path.name == "xkb_relevance.py":
                continue
            for i, line in enumerate(path.read_text(encoding="utf-8").splitlines()):
                match = assignment.match(line)
                if match and "xkb_relevance" not in match.group(2):
                    offenders.append(f"{path.name}:{i + 1} {match.group(1)}")
        self.assertEqual(
            offenders, [],
            "這些門檻沒有取自 xkb_relevance;改用 threshold() 並把值寫進 DEFAULT_THRESHOLDS",
        )

    def test_every_score_scale_is_in_the_anchor_table(self) -> None:
        """宣告出來的尺度,跨層排序時必須認得。

        xkb_score.rank() 對不認得的鍵會靜默退回 0.5,那一層的排序整段錯位,
        而且不會有任何錯誤訊息——只會是一個看起來排好、實際上翻轉了的順序。
        """
        literal = re.compile(r"""["\']score_scale["\']\]?\s*[:=]\s*["\']([a-z_]+)["\']""")
        known = set(xkb_score.DEFAULT_ANCHORS)
        offenders = []
        for path in _sources():
            for i, line in enumerate(path.read_text(encoding="utf-8").splitlines()):
                for name in literal.findall(line):
                    if name not in known:
                        offenders.append(f"{path.name}:{i + 1} score_scale={name!r}")
        self.assertEqual(offenders, [], "不在 xkb_score.DEFAULT_ANCHORS 裡的尺度 = 靜默錯位")

    def test_file_io_says_which_encoding(self) -> None:
        """讀寫檔案一律講明 utf-8,不要讓作業系統替我們決定。

        不指定時 Python 用平台預設:VPS 是 UTF-8,Windows 是 cp950。
        知識庫裡幾乎每個檔案都有中文,於是同一支腳本在 VPS 正常、在本機
        丟 UnicodeDecodeError——而本機是每次改動的第一站。

        這條連 tests/ 一起掃:抓到的第一個違規就在測試自己身上。
        """
        offenders = []
        for path in sorted(list(SCRIPTS.glob("*.py")) + list((ROOT / "tests").glob("*.py"))):
            try:
                tree = ast.parse(path.read_text(encoding="utf-8"))
            except SyntaxError:
                continue
            for node in ast.walk(tree):
                if not isinstance(node, ast.Call):
                    continue
                fn = node.func
                if not (isinstance(fn, ast.Attribute) and fn.attr in ("read_text", "write_text")):
                    continue
                if any(kw.arg == "encoding" or kw.arg is None for kw in node.keywords):
                    continue
                offenders.append(f"{path.name}:{node.lineno} .{fn.attr}()")
        self.assertEqual(offenders, [], "加上 encoding=\"utf-8\";平台預設編碼不是一個定義")


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