Ctrl K
ComunidadGuías…

tests/test_index_is_derived.py

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

Archivo original ↗Preguntar a KiBot
"""搜尋索引是衍生物:只有一個地方寫它,而且是從檔案重算。

2026-09-10 量到的狀態:八支程式各自對 search_index.json 做讀-改-寫。每一支都
是合併而不是覆蓋,所以沒有大規模掉資料,但它們對「這是同一個來源嗎」有不同
的認定(github-x / github_fork-x / github_star-x 被當成三個東西),也沒有共用
鎖。於是索引慢慢跟磁碟上的檔案說的不一樣。

把索引整份從檔案重建之後跟正在用的那份比:

    現行 1680 筆    重建 1680 筆
    重建後才出現的:3 筆(其中一張是當天健檢報出來的「孤兒卡」)
    重建後會消失的:1 筆(同一份內容,被記成另一個路徑)

也就是說重建不只是等價,它比較準。修補才是漂移的來源。

這份測試守三件事:只有 builder 會寫索引、值得留下來的東西寫在檔案裡、
重建之後那些東西還在。
"""
from __future__ import annotations

import json
import os
import subprocess
import sys
import tempfile
import unittest
from unittest import mock
from pathlib import Path

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

import xkb_frontmatter  # noqa: E402

BUILDER = SCRIPTS / "build_search_index.sh"

# 唯一可以寫索引的檔案。要新增的話,先問「這件事為什麼不能由檔案推導出來」。
ALLOWED_WRITERS = {"build_search_index.sh"}

CARD = """---
id: {stem}
type: knowledge-card
source_type: local
source_url: https://example.test/{stem}
category: 99-general
tags: [a, b]
---

# {title}

## 📌 一句話摘要
{summary}
"""


def _require_python3() -> None:
    """builder 用 python3 跑。Windows 上那是 Microsoft Store 的轉接器,
    直接執行會以 49 結束、stderr 全空——看起來像 builder 壞了,其實它一行
    都沒跑到。這種假失敗要說得出原因,不要讓人去查 build_search_index.sh。"""
    try:
        proc = subprocess.run(["python3", "-c", "pass"],
                              capture_output=True, timeout=30)
    except (OSError, subprocess.SubprocessError):
        raise unittest.SkipTest("這個環境沒有可執行的 python3")
    if proc.returncode != 0:
        raise unittest.SkipTest("python3 不是真的直譯器(Windows Store 轉接器)")


def _bash() -> str:
    if os.name != "nt":
        return "bash"
    for candidate in ["C:/Program Files/Git/bin/bash.exe",
                      "C:/Program Files (x86)/Git/bin/bash.exe"]:
        if Path(candidate).exists():
            return candidate
    raise unittest.SkipTest("no POSIX bash available")


class OnlyOneWriter(unittest.TestCase):
    def test_nothing_else_writes_the_index(self):
        offenders = []
        for path in sorted(list(SCRIPTS.glob("*.py")) + list(SCRIPTS.glob("*.sh"))):
            if path.name in ALLOWED_WRITERS:
                continue
            text = path.read_text(encoding="utf-8", errors="ignore")
            if "INDEX_FILE.write_text" in text or "index_file.write_text" in text:
                offenders.append(path.name)
        self.assertEqual(
            offenders, [],
            "索引是衍生物,只能由 build_search_index.sh 寫。這些又開始自己修補了:\n  "
            + "\n  ".join(offenders))

    def test_the_ingesters_go_through_the_rebuild(self):
        for name in ["fetch_github_repos.py", "fetch_youtube_playlist.py",
                     "local_ingest.py", "sync_enriched_index.py"]:
            text = (SCRIPTS / name).read_text(encoding="utf-8")
            with self.subTest(script=name):
                expected = ("xkb_index.rebuild()" if name == "sync_enriched_index.py"
                            else "batch.finish(")
                self.assertIn(expected, text)


class Frontmatter(unittest.TestCase):
    def setUp(self):
        self._tmp = tempfile.TemporaryDirectory()
        self.root = Path(self._tmp.name)
        self.addCleanup(self._tmp.cleanup)

    def _card(self, stem: str = "c1") -> Path:
        path = self.root / f"{stem}.md"
        path.write_text(CARD.format(stem=stem, title="標題", summary="一句話"),
                        encoding="utf-8")
        return path

    def test_marking_adds_the_flag_without_disturbing_the_rest(self):
        card = self._card()
        before = card.read_text(encoding="utf-8")

        self.assertTrue(xkb_frontmatter.mark_excluded(card, "duplicate_source_url"))

        after = card.read_text(encoding="utf-8")
        self.assertTrue(xkb_frontmatter.is_excluded(card))
        self.assertIn("excluded_reason: duplicate_source_url", after)
        # 其他欄位與內文原樣保留——不重排、不重寫。
        for line in ["id: c1", "type: knowledge-card", "category: 99-general",
                     "tags: [a, b]", "# 標題", "一句話"]:
            self.assertIn(line, after)
        self.assertEqual(before.count("---"), after.count("---"))

    def test_marking_twice_does_not_rewrite(self):
        card = self._card()
        xkb_frontmatter.mark_excluded(card, "reason")
        self.assertFalse(xkb_frontmatter.mark_excluded(card, "reason"))

    def test_a_file_without_frontmatter_reports_failure(self):
        """標記不上跟標記了不一樣。回 False 讓呼叫端說得出來。"""
        path = self.root / "plain.md"
        path.write_text("# 沒有 frontmatter\n", encoding="utf-8")
        self.assertFalse(xkb_frontmatter.mark_excluded(path, "reason"))


class TheFlagSurvivesARebuild(unittest.TestCase):
    """這是整個設計的關鍵:值得留下來的東西寫在檔案裡,所以重建洗不掉。

    原本它寫在索引列上,於是實測正式索引 1680 筆裡帶 excluded 的有 0 筆——
    兩支腳本的成果一次都沒留下來,而讀它的四個地方一直讀到 False。
    """

    def test_an_excluded_card_is_still_excluded_after_a_full_rebuild(self):
        bash = _bash()
        _require_python3()
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            cards = root / "memory" / "cards"
            bookmarks = root / "memory" / "bookmarks"
            cards.mkdir(parents=True)
            bookmarks.mkdir(parents=True)
            index = bookmarks / "search_index.json"

            keep = cards / "keep.md"
            drop = cards / "drop.md"
            keep.write_text(CARD.format(stem="keep", title="留著", summary="摘要"),
                            encoding="utf-8")
            drop.write_text(CARD.format(stem="drop", title="重複", summary="摘要"),
                            encoding="utf-8")
            xkb_frontmatter.mark_excluded(drop, "duplicate_source_url")

            env = {**os.environ,
                   "WORKSPACE_DIR": str(root),
                   "BOOKMARKS_DIR": str(bookmarks),
                   "CARDS_DIR": str(cards),
                   "INDEX_FILE": str(index),
                   "PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1"}
            proc = subprocess.run([bash, str(BUILDER)], env=env,
                                  capture_output=True, text=True, encoding="utf-8", errors="replace")
            self.assertEqual(proc.returncode, 0, proc.stderr)

            rows = json.loads(index.read_text(encoding="utf-8"))["items"]

        by_stem = {Path(r["relative_path"]).stem: r for r in rows}
        self.assertTrue(by_stem["drop"].get("excluded"))
        self.assertIn("duplicate_source_url", by_stem["drop"].get("excluded_reason", ""))
        self.assertFalse(by_stem["keep"].get("excluded"))


class TheQualityScriptsActuallyRun(unittest.TestCase):
    """真的把它們跑一次。

    2026-09-10:把 canonicalize_duplicates 改成標記檔案時,補上 _card_path
    的那一步沒有跑到,於是它引用了一個不存在的名字。py_compile 過、既有測試
    全綠、部署也成功——直到在 VPS 上真的執行才炸成 NameError。

    當時的測試只檢查「原始碼裡有沒有那個字串」。字串在不在跟跑不跑得起來是
    兩件事,而這個專案已經在別的地方吃過同一種虧。
    """

    def _fixture(self, root: Path) -> Path:
        cards = root / "memory" / "cards"
        bookmarks = root / "memory" / "bookmarks"
        cards.mkdir(parents=True)
        bookmarks.mkdir(parents=True)
        for stem in ("a", "b"):
            (cards / f"{stem}.md").write_text(
                CARD.format(stem=stem, title="標題", summary="一句話"),
                encoding="utf-8")
        index = bookmarks / "search_index.json"
        # a 與 b 指向同一個來源:canonicalize 應該挑一個留下、標記另一個。
        index.write_text(json.dumps({"version": "1.1", "items": [
            {"path": str(cards / "a.md"), "relative_path": "memory/cards/a.md",
             "title": "標題", "summary": "一句話", "tags": [], "category": "99-general",
             "source_url": "https://example.test/same", "source_type": "local",
             "enriched": True},
            {"path": str(cards / "b.md"), "relative_path": "memory/cards/b.md",
             "title": "2026-09-10", "summary": "", "tags": [], "category": "99-general",
             "source_url": "https://example.test/same", "source_type": "local",
             "enriched": True},
        ]}, ensure_ascii=False), encoding="utf-8")
        return index

    def _run(self, script: str, root: Path, index: Path):
        env = {**os.environ,
               "XKB_DATA_DIR": str(root / "memory"),
               "CARDS_DIR": str(root / "memory" / "cards"),
               "BOOKMARKS_DIR": str(root / "memory" / "bookmarks"),
               "INDEX_FILE": str(index),
               "PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1"}
        return subprocess.run([sys.executable, str(SCRIPTS / script), "--dry-run"],
                              capture_output=True, text=True, encoding="utf-8", errors="replace", env=env)

    def test_canonicalize_duplicates_runs(self):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            index = self._fixture(root)
            proc = self._run("canonicalize_duplicates.py", root, index)
        self.assertEqual(proc.returncode, 0, proc.stderr)
        self.assertIn("duplicate groups", proc.stdout)

    def test_normalize_index_quality_runs(self):
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            index = self._fixture(root)
            proc = self._run("normalize_index_quality.py", root, index)
        self.assertEqual(proc.returncode, 0, proc.stderr)
        self.assertIn("總項目數", proc.stdout)


class LocalIngestsAreNotLowQuality(unittest.TestCase):
    """本機擷取的出處是一個檔案路徑,不是網址——那不是品質問題。

    2026-09-10:品質規則差一點把兩張本機匯入的好卡片排除掉,
    「every-app/open-seo — Semrush/Ahrefs 開源替代方案」與那本 Obsidian 的書,
    理由只是 source_url 不以 http 開頭。
    """

    def setUp(self):
        sys.path.insert(0, str(SCRIPTS))
        import normalize_index_quality
        self.reasons = normalize_index_quality.exclusion_reasons

    def test_a_file_path_is_valid_provenance_for_a_local_ingest(self):
        item = {"title": "一份本機文件", "summary": "有內容的摘要",
                "source_type": "local-paper",
                "source_url": "memory/bookmarks/github/stars/x.md"}
        self.assertNotIn("invalid_source_url", self.reasons(item))

    def test_a_broken_url_on_a_web_source_is_still_caught(self):
        item = {"title": "測試書籤", "summary": "摘要",
                "source_type": "x-bookmark",
                "source_url": "https://x.com/i/status/test"}
        self.assertIn("invalid_source_url", self.reasons(item))

    def test_an_unenriched_bookmark_is_still_caught(self):
        item = {"title": "Tweet 2073558354233094315", "summary": "",
                "source_type": "x-bookmark",
                "source_url": "https://x.com/i/status/2073558354233094315"}
        self.assertIn("tweet_numeric_low_signal", self.reasons(item))


class GovernanceRunsTheTidyStep(unittest.TestCase):
    def test_the_scheduled_job_calls_both_and_rebuilds(self):
        """排上去了才會跑。原本這兩支沒有任何排程在叫。"""
        text = (SCRIPTS / "run_candidate_governance.sh").read_text(encoding="utf-8")
        self.assertIn("canonicalize_duplicates", text)
        self.assertIn("normalize_index_quality", text)
        self.assertIn("xkb_index", text)


class DeduplicationMustLeaveOneCopy(unittest.TestCase):
    """去重不可以把整組都標掉——那不是去重,那是刪除。

    2026-09-10:把「標記一個檔案」改成「標記同一份知識的所有檔案」時,用的是
    source_url 去找。品質排除要的正是那個(空殼書籤的每一份副本都該排除),但
    重複組本來就共用同一個來源,於是「要保留的那一份」也被撈進來標掉。七份全
    排除,那份知識會從召回裡完全消失,而輸出只會說「已標記 6 張」。
    """

    def setUp(self):
        sys.path.insert(0, str(SCRIPTS))
        import xkb_index
        self.xkb_index = xkb_index
        self._tmp = tempfile.TemporaryDirectory()
        self.root = Path(self._tmp.name)
        self.cards = self.root / "cards"
        self.cards.mkdir()
        self.addCleanup(self._tmp.cleanup)
        patches = [
            mock.patch.object(xkb_index.xkb_paths, "CARDS_DIR", self.cards),
            mock.patch.object(xkb_index.xkb_paths, "BOOKMARKS_DIR", self.root / "bookmarks"),
            mock.patch.object(xkb_index.xkb_paths, "WORKSPACE", self.root),
        ]
        for patch in patches:
            patch.start()
            self.addCleanup(patch.stop)
        xkb_index.reset_scan()
        self.addCleanup(xkb_index.reset_scan)

    def _card(self, stem: str, url: str) -> Path:
        path = self.cards / f"{stem}.md"
        path.write_text(
            "---\n"
            f"id: {stem}\n"
            "type: knowledge-card\n"
            f"source_url: {url}\n"
            "category: 99-general\n"
            "tags: [a]\n"
            "---\n"
            "\n"
            f"# {stem}\n",
            encoding="utf-8")
        return path

    def test_by_source_gathers_every_copy(self):
        """品質排除要的是這個:同一份知識的每一份副本。"""
        url = "https://x.com/i/status/123456789012345678"
        self._card("copy_a", url)
        self._card("copy_b", url)
        item = {"relative_path": "copy_a.md", "source_url": url}

        found = self.xkb_index.files_for_item(item, by_source=True)

        self.assertEqual({p.stem for p in found}, {"copy_a", "copy_b"})

    def test_without_by_source_only_this_row_files(self):
        """去重要的是這個:不要把同組的其他成員撈進來。"""
        url = "https://x.com/i/status/123456789012345678"
        self._card("copy_a", url)
        self._card("copy_b", url)
        item = {"relative_path": "copy_a.md", "source_url": url}

        found = self.xkb_index.files_for_item(item, by_source=False)

        self.assertEqual({p.stem for p in found}, {"copy_a"})

    def test_the_canonical_copy_is_never_marked(self):
        """跑完整支腳本,確認保留的那一份沒有被標。"""
        url = "https://x.com/i/status/123456789012345678"
        keep = self._card("keeper", url)
        drop = self._card("dropper", url)
        index = self.root / "search_index.json"
        index.write_text(json.dumps({"version": "1.1", "items": [
            {"path": str(keep), "relative_path": "keeper.md", "title": "好標題",
             "summary": "夠長的摘要內容,超過十八個字元的門檻",
             "tags": [], "category": "99-general", "source_url": url,
             "source_type": "x-bookmark", "enriched": True},
            {"path": str(drop), "relative_path": "dropper.md", "title": "dropper",
             "summary": "", "tags": [], "category": "99-general", "source_url": url,
             "source_type": "x-bookmark", "enriched": False},
        ]}, ensure_ascii=False), encoding="utf-8")

        env = {**os.environ,
               "XKB_DATA_DIR": str(self.root),
               "CARDS_DIR": str(self.cards),
               "BOOKMARKS_DIR": str(self.root / "bookmarks"),
               "INDEX_FILE": str(index),
               "PYTHONIOENCODING": "utf-8", "PYTHONUTF8": "1"}
        proc = subprocess.run(
            [sys.executable, str(SCRIPTS / "canonicalize_duplicates.py")],
            capture_output=True, text=True, encoding="utf-8", errors="replace", env=env)
        self.assertEqual(proc.returncode, 0, proc.stderr)

        self.assertFalse(xkb_frontmatter.is_excluded(keep), "保留的那一份被標掉了")
        self.assertTrue(xkb_frontmatter.is_excluded(drop))

    def test_a_marking_can_be_undone(self):
        """把知識從召回裡拿掉的決定,必須有辦法反悔。"""
        card = self._card("x", "https://example.test/x")
        xkb_frontmatter.mark_excluded(card, "reason")
        self.assertTrue(xkb_frontmatter.is_excluded(card))

        self.assertTrue(xkb_frontmatter.unmark_excluded(card))

        self.assertFalse(xkb_frontmatter.is_excluded(card))
        self.assertFalse(xkb_frontmatter.unmark_excluded(card))


class ExclusionIsNotAOneWayDoor(unittest.TestCase):
    """排除要有退路,而退路要真的被某個管線步驟走過。

    2026-09-10 審查抓到:旗標改成寫在檔案上之後,sync_enriched_index 裡那段
    「卡片補上標題與摘要就清掉過期排除」只改索引列——而索引現在是衍生物,
    下一次重建就把 excluded: true 原封不動讀回來。unmark_excluded() 除了測試
    沒有任何呼叫者。

    後果是排除變成單行道:一張被誤排除的卡(例如被「空欄位吞掉下一行」那個
    解析 bug 害到的),即使後來補齊了內容也救不回來,只能手動改檔案。改成寫
    在檔案上之前反而是安全的——那時候下一次重建就洗掉了。
    """

    def test_the_pipeline_can_undo_a_quality_exclusion(self):
        source = (SCRIPTS / "sync_enriched_index.py").read_text(encoding="utf-8")
        self.assertIn("unmark_excluded", source)
        self.assertIn("files_for_item", source)

    def test_it_does_not_undo_a_duplicate_exclusion(self):
        """去重的標記不能被「補齊內容」撤掉——那會把副本放回召回裡。"""
        source = (SCRIPTS / "sync_enriched_index.py").read_text(encoding="utf-8")
        self.assertIn("duplicate_source_url", source)

    def test_unmark_leaves_the_card_usable(self):
        import xkb_frontmatter as fm
        with tempfile.TemporaryDirectory() as tmp:
            card = Path(tmp) / "c.md"
            card.write_text(CARD.format(stem="c", title="標題", summary="摘要"),
                            encoding="utf-8")
            fm.mark_excluded(card, "tweet_numeric_low_signal")
            self.assertTrue(fm.is_excluded(card))

            self.assertTrue(fm.unmark_excluded(card))

            after = card.read_text(encoding="utf-8")
            self.assertFalse(fm.is_excluded(card))
            for line in ["id: c", "type: knowledge-card", "# 標題", "摘要"]:
                self.assertIn(line, after)

    def test_a_windows_path_in_the_reason_does_not_crash(self):
        """理由含 Windows 路徑時 re.sub 會把反斜線當跳脫序列。

        canonicalize 的理由是 "duplicate_source_url; canonical={relative_path}",
        而那個路徑在 Windows 上是 memory\cards\abc.md。原本會拋
        re.error: bad escape \c,而且是在已經改寫了一部分卡片之後才炸。
        """
        import xkb_frontmatter as fm
        with tempfile.TemporaryDirectory() as tmp:
            card = Path(tmp) / "c.md"
            card.write_text(CARD.format(stem="c", title="標題", summary="摘要"),
                            encoding="utf-8")
            fm.mark_excluded(card, "first")

            reason = "duplicate_source_url; canonical=" + chr(92).join(
                ["memory", "cards", "abc.md"])
            self.assertTrue(fm.mark_excluded(card, reason) or True)
            updated = fm.set_field(card.read_text(encoding="utf-8"),
                                   "excluded_reason", reason)

        self.assertIn("abc.md", updated)


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