fix: 修复笔记 YAML 标签保存与索引重建一致性

This commit is contained in:
2026-09-05 19:40:14 +08:00
parent 8692910508
commit 02dd585a4e
9 changed files with 219 additions and 55 deletions
+24 -12
View File
@@ -20,7 +20,6 @@ from app.errors import ApiError
from app.textutils import count_tokens
_HEADING_RE = re.compile(r"^(#{1,6})[ \t]+(.*?)\s*$")
_FRONTMATTER_KEY_RE = re.compile(r"^([A-Za-z0-9_-]+)\s*:\s*(.*)$")
_FENCE_RE = re.compile(r"^[ \t]{0,3}(`{3,}|~{3,})(?:[^`]*)$")
@@ -261,16 +260,29 @@ def _embedding_policy(markdown: str) -> bool:
return value.value.lower() in {"true", "yes", "on"}
def _extract_frontmatter(markdown: str) -> dict[str, str]:
"""极简 frontmatter 解析,只提取 key: value 行。"""
def _extract_frontmatter(markdown: str) -> dict[str, str | list[str]]:
"""Read YAML scalars and tag sequences without constructing arbitrary objects."""
header = _frontmatter(markdown)
if header is None:
return {}
meta: dict[str, str] = {}
for line in header[0].splitlines():
m = _FRONTMATTER_KEY_RE.match(line)
if m:
meta[m.group(1).lower()] = m.group(2).strip()
try:
node = yaml.compose(header[0], Loader=yaml.SafeLoader)
except yaml.YAMLError as exc:
raise ApiError(422, "INVALID_EMBEDDING_POLICY", "Frontmatter YAML 无效,无法确认本地索引策略。") from exc
meta: dict[str, str | list[str]] = {}
if not isinstance(node, yaml.MappingNode):
return meta # The policy validation below handles unsupported documents.
for key, value in node.value:
if not isinstance(key, yaml.ScalarNode):
continue
name = key.value.lower()
if name not in {"title", "tags"}:
continue
if isinstance(value, yaml.ScalarNode):
# Keep lexical values: YAML 1.1 would otherwise turn tags like on/yes into booleans.
meta[name] = "" if value.tag == "tag:yaml.org,2002:null" else value.value
elif name == "tags" and isinstance(value, yaml.SequenceNode):
meta[name] = [item.value for item in value.value if isinstance(item, yaml.ScalarNode)]
return meta
@@ -282,10 +294,10 @@ def _first_heading(markdown: str) -> str | None:
return None
def _parse_tags(raw: str | None) -> list[str]:
def _parse_tags(raw: str | list[str] | None) -> list[str]:
if isinstance(raw, list):
return raw
if not raw:
return []
raw = raw.strip()
if raw.startswith("[") and raw.endswith("]"):
raw = raw[1:-1]
return [t.strip().strip("'\"") for t in raw.split(",") if t.strip()]
return [t.strip() for t in raw.split(",") if t.strip()]
+46
View File
@@ -0,0 +1,46 @@
import asyncio
from datetime import datetime, timezone
import pytest
from app.contracts import IndexRebuildRequest
from app.knowledge.parser import parse_note
from app.services import index_service, note_service
@pytest.mark.parametrize(('header', 'expected'), [
('tags:\n- python\n- rust', ['python', 'rust']),
('tags:\n - python\n - rust', ['python', 'rust']),
('"tags": ["a,b", "quote\\\"tag", "path\\\\tag"] # comment', ['a,b', 'quote"tag', 'path\\tag']),
('tags: [on, yes, "true", "001"]', ['on', 'yes', 'true', '001']),
('tags: python, rust', ['python', 'rust']),
('tags: []', []),
('tags: null', []),
])
def test_yaml_tags_are_parsed_as_complete_values(header, expected):
now = datetime.now(timezone.utc)
note = parse_note(
markdown=f'---\ntitle: "Demo: YAML"\n{header}\n---\n# Body',
file_path='demo.md', folder='', created_at=now, updated_at=now,
)
assert note.tags == expected
assert note.title == 'Demo: YAML'
def test_saved_metadata_survives_full_index_rebuild():
async def scenario():
note = await note_service.create_note(title='Demo', markdown='# Body', folder=None, tags=['old'])
for tags, yaml_tags in [
(['python', 'a,b', 'on'], '\n - python\n - a,b\n - on'),
([], ' []'),
]:
markdown = f'---\ntitle: "Demo: updated"\ntags:{yaml_tags}\n---\n# Body\n'
saved = await note_service.update_note(note.note_id, markdown=markdown, tags=tags)
assert saved.tags == tags
job = await index_service.rebuild(IndexRebuildRequest())
assert job.status == 'completed'
restored = await note_service.get_note(note.note_id)
assert restored.tags == tags
assert restored.title == 'Demo: updated'
assert restored.markdown == markdown
asyncio.run(scenario())