tests/test_index_is_derived.py
Rama main · réplica de lectura del repositorio original.
"""搜尋索引是衍生物:只有一個地方寫它,而且是從檔案重算。
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()