Ctrl K
ComunidadGuías…

tests/test_empty_frontmatter_field.py

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

Archivo original ↗Preguntar a KiBot
"""空的 frontmatter 欄位不可以把下一行吞進來。

2026-09-10:品質檢查說有兩張卡該被排除,理由是 source_url 不是合法網址。
查下去,那兩張卡的 source_url 在索引裡是 `category: 02-seo-geo`——而檔案裡
那一行其實是空的:

    source_url:
    category: 02-seo-geo

builder 的正則是 `^source_url:\\s*"?([^"\\n]+)"?\\s*$`,而 `\\s` 含換行。欄位
空的時候 `\\s*` 跨過換行,`[^"\\n]+` 就吃到了下一行整行。

一個解析 bug 一路走成「把好卡片藏起來」:欄位被讀成一段不是網址的文字 →
品質規則判定 invalid_source_url → 排除 → 那張卡從此不出現在召回裡。其中一
張是「Obsidian + Claude Code:用AI重建你的第二大脑」,當天稍早的召回還引用
過它的內容。

這個模式在 builder 裡用在每一個欄位上,所以這裡把每一個都測一次。
"""
from __future__ import annotations

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

ROOT = Path(__file__).resolve().parents[1]
BUILDER = ROOT / "scripts" / "build_search_index.sh"

# 每個欄位都留空,下一行都是一段「看起來像值」的文字。
CARD_WITH_EMPTY_FIELDS = """---
id: empty-fields
type: knowledge-card
title:
category: 02-seo-geo
source_url:
tags: [seo, mcp]
source_type:
sensitivity: public
tweet_id:
confidence: medium
excluded:
excluded_reason: 不應該被讀成排除
---

# 真正的標題

## 📌 一句話摘要
真正的摘要
"""


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")


def _require_python3() -> None:
    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 轉接器)")


class EmptyFieldsDoNotSwallowTheNextLine(unittest.TestCase):
    def _build(self) -> dict:
        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"
            (cards / "empty-fields.md").write_text(CARD_WITH_EMPTY_FIELDS,
                                                   encoding="utf-8")
            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"]
        self.assertEqual(len(rows), 1)
        return rows[0]

    def test_an_empty_source_url_stays_empty(self):
        """這就是那兩張卡被標成該排除的原因。"""
        row = self._build()
        self.assertNotIn("category:", row["source_url"])
        self.assertEqual(row["source_url"], "")

    def test_an_empty_title_falls_back_to_the_heading_not_the_next_line(self):
        row = self._build()
        self.assertEqual(row["title"], "真正的標題")

    def test_an_empty_source_type_is_inferred_not_swallowed(self):
        row = self._build()
        self.assertNotIn("sensitivity", row["source_type"])

    def test_an_empty_excluded_field_does_not_mean_excluded(self):
        """`excluded:` 空著就是沒有排除。讀成排除等於憑空藏掉一張卡。"""
        row = self._build()
        self.assertFalse(row.get("excluded"))

    def test_the_real_category_and_tags_still_parse(self):
        """修法不能把有值的情況一起弄壞。"""
        row = self._build()
        self.assertEqual(row["category"], "02-seo-geo")
        self.assertEqual(row["tags"], ["seo", "mcp"])
        self.assertEqual(row["summary"], "真正的摘要")


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