scripts/xkb_frontmatter.py
Rama main · réplica de lectura del repositorio original.
#!/usr/bin/env python3
"""在卡片檔案的 frontmatter 上做標記——索引是衍生物,檔案才是資產。
search_index.json 的每一個欄位都可以從卡片檔案重算出來(2026-09-10 實測:
把索引整份重建,1680 筆一模一樣,而且比正在用的那份還準——它找回了三張現行
索引漏掉的卡)。所以索引可以隨時刪掉重建。
但這件事有一個前提:**沒有任何東西只活在索引裡**。原本有兩樣:
normalize_index_quality 把低品質的項目標成 excluded
canonicalize_duplicates 把重複的項目標成 excluded
兩支都寫在索引列上,而它們的檔頭都明白寫著「不動 source markdown」。結果是
每次重建都把它們的成果洗掉一次:實測正式索引 1680 筆裡帶 excluded 的有 0 筆,
而讀這個旗標的四個地方一直讀到 False,沒有任何錯誤、沒有任何人發現。
所以標記要寫進檔案。這支模組就是那件事,只有這件事。
刻意不做完整的 YAML 解析:這裡只處理 `key: value` 這一種形狀,而 XKB 的卡片
frontmatter 全部都是這種形狀。引進一個 YAML 相依只為了設一個布林值,換來的是
「重寫整份 frontmatter」的風險——註解、順序、原本的引號都可能被改掉。
"""
from __future__ import annotations
import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
FRONTMATTER = re.compile(r"\A---\r?\n(.*?)\r?\n---\r?\n", re.DOTALL)
def has_frontmatter(text: str) -> bool:
return bool(FRONTMATTER.match(text))
def parse(text: str) -> dict[str, str]:
"""Read the raw scalar fields used by card ingesters, without rewriting YAML."""
match = FRONTMATTER.match(text)
if not match:
return {}
fields = {}
for line in match.group(1).splitlines():
key, separator, value = line.partition(":")
if separator:
fields[key.strip()] = value.strip()
return fields
def get(text: str, key: str) -> str | None:
"""讀一個 frontmatter 欄位的原始字串;沒有這個欄位回 None。"""
match = FRONTMATTER.match(text)
if not match:
return None
pattern = re.compile(rf"^{re.escape(key)}:[ \t]*(.*?)[ \t]*\r?$", re.MULTILINE)
found = pattern.search(match.group(1))
return found.group(1) if found else None
def set_field(text: str, key: str, value: str) -> str:
"""設一個 frontmatter 欄位,回傳新的內容。
已經有這個欄位就就地換值,沒有就加在 frontmatter 結尾——不重排、不重寫
其他行。沒有 frontmatter 的檔案原樣回傳:這支模組的職責是標記,不是替
別人決定檔案該長什麼樣。
"""
match = FRONTMATTER.match(text)
if not match:
return text
block = match.group(1)
line = f"{key}: {value}"
pattern = re.compile(rf"^{re.escape(key)}:.*$", re.MULTILINE)
if pattern.search(block):
# 用 lambda 當替換,不要把 line 交給 re.sub 當模板。
#
# `pattern.sub(line, ...)` 會把 line 裡的反斜線當跳脫序列解讀,而寫進
# 來的值常常含路徑:canonicalize 的理由是
# "duplicate_source_url; canonical={relative_path}",那個 relative_path
# 在 Windows 上是 memory\cards\abc.md —— `\c` 直接拋 re.error,`\a` 更
# 糟,會安靜變成控制字元。而它炸的時機是在已經改寫了一部分卡片之後。
new_block = pattern.sub(lambda _m: line, block, count=1)
else:
new_block = block + "\n" + line
return text[:match.start(1)] + new_block + text[match.end(1):]
def mark_excluded(path: Path, reason: str = "", *, dry_run: bool = False) -> bool:
"""把一張卡標成不進召回。已經標過就不重寫,回傳有沒有改動。"""
try:
text = path.read_text(encoding="utf-8")
except OSError:
return False
if not has_frontmatter(text):
# 沒有 frontmatter 就沒有地方放這個旗標。安靜跳過會讓「標記了」和
# 「標記不上」長得一樣,所以回 False 讓呼叫端自己算得出來。
return False
current = (get(text, "excluded") or "").strip().lower()
already = current in {"true", "yes", "1"}
if already:
return False
updated = set_field(text, "excluded", "true")
if reason:
updated = set_field(updated, "excluded_reason", reason.replace("\n", " ")[:200])
if dry_run:
return True
try:
path.write_text(updated, encoding="utf-8")
except OSError:
return False
return True
def unmark_excluded(path: Path, *, dry_run: bool = False) -> bool:
"""撤掉排除標記。回傳有沒有改動。
一個把知識從召回裡拿掉的決定,必須有辦法反悔。2026-09-10 一個寫錯的
「標記整組」把重複組裡要保留的那一份也標掉了——那份知識會完全消失,而
當時沒有任何工具可以還原,只能一個檔案一個檔案手動改。
"""
try:
text = path.read_text(encoding="utf-8")
except OSError:
return False
if not has_frontmatter(text):
return False
if (get(text, "excluded") or "").strip().lower() not in {"true", "yes", "1"}:
return False
updated = set_field(text, "excluded", "false")
if get(updated, "excluded_reason") is not None:
updated = set_field(updated, "excluded_reason", "")
if dry_run:
return True
try:
path.write_text(updated, encoding="utf-8")
except OSError:
return False
return True
def is_excluded(path: Path) -> bool:
try:
text = path.read_text(encoding="utf-8", errors="ignore")
except OSError:
return False
return (get(text, "excluded") or "").strip().lower() in {"true", "yes", "1"}