Align tag contracts and index health behavior

This commit is contained in:
酱牛肉 2026-09-10 17:32:39 +08:00
parent c1c339e396
commit 7a7890c940
7 changed files with 52 additions and 12 deletions

View file

@ -12,7 +12,13 @@ from ...schema import FileFrontMatter, FileNode
class LocalTagIndex(BaseTagIndex):
"""Maintain bidirectional path/tag relationships without separate source I/O."""
reserved_tag_keys = frozenset(FileFrontMatter.model_fields)
reserved_tag_keys = frozenset(FileFrontMatter.model_fields) | {
"kind",
"session_id",
"source_conversation",
"source_resource",
"status",
}
def __init__(
self,
@ -47,7 +53,7 @@ class LocalTagIndex(BaseTagIndex):
for item in value:
if isinstance(item, bool) or not isinstance(item, (str, int)):
continue
raw = " ".join(str(item).split())
raw = "_".join(str(item).split())
if not raw or len(raw) > self.max_tag_length:
continue
if not any(char.isalnum() for char in raw):
@ -183,9 +189,9 @@ class LocalTagIndex(BaseTagIndex):
raise ValueError("order must be one of ['asc', 'desc']")
async with self._maintenance_lock:
items: list[TagListItem] = (
[(tag, len(paths)) for tag, paths in self.tag_to_paths.items()] if self.is_healthy else []
)
if not self.is_healthy:
raise RuntimeError("tag index is unavailable")
items: list[TagListItem] = [(tag, len(paths)) for tag, paths in self.tag_to_paths.items()]
if order_by == "tag":
items.sort(key=lambda item: item[0], reverse=order == "desc")

View file

@ -207,6 +207,8 @@ jobs:
- session_id
steps:
- backend: auto_memory_cc_step
- backend: auto_tag_step
max_tags_per_file: 3
auto_resource:
backend: base

View file

@ -47,7 +47,7 @@ def normalize_memory_tags(
for item in value:
if not isinstance(item, str):
continue
tag = " ".join(item.split())
tag = "_".join(item.split())
if not tag or len(tag) > max_tag_length or not any(char.isalnum() for char in tag):
continue
canonical = tag.casefold()
@ -181,6 +181,11 @@ class AutoTagStep(BaseStep):
return self.context.response
tag_index = self.file_store.require_tag_index() if targets else None
if tag_index is not None and not tag_index.is_healthy:
self.context.response.success = False
if initial_success:
self.context.response.answer = "Error: tag index unavailable"
return self.context.response
max_tag_length = self._index_limit(tag_index, "max_tag_length", _DEFAULT_MAX_MEMORY_TAG_LENGTH)
index_max_tags = self._index_limit(tag_index, "max_tags_per_file", None)
if index_max_tags is not None and self.max_tags_per_file > index_max_tags:

View file

@ -91,6 +91,16 @@ async def test_auto_tag_handles_noop_and_rejects_invalid_preconditions(tmp_path,
assert not wrapper.calls
assert note.read_bytes() == before
indexed_store.tag_index = LocalTagIndex()
indexed_store.tag_index.set_healthy(False)
response = await AutoTagStep(file_store=indexed_store, agent_wrapper=wrapper)(
RuntimeContext(changes=[{"change": "added", "path": "daily/2026-09-09/note.md"}]),
)
assert response.success is False
assert response.answer == "Error: tag index unavailable"
assert not wrapper.calls
assert note.read_bytes() == before
@pytest.mark.asyncio
async def test_auto_tag_filters_paths_and_continues_after_one_file_fails(tmp_path, monkeypatch):

View file

@ -88,7 +88,7 @@ def test_auto_tag_normalizes_frontmatter_tags():
"宁德时代",
"黄金",
],
) == ["OpenAI", "Sam Altman", "宁德时代"]
) == ["OpenAI", "Sam_Altman", "宁德时代"]
# pylint: disable=use-implicit-booleaness-not-comparison
assert normalize_memory_tags(None) == []
assert normalize_memory_tags("OpenAI") == []

View file

@ -263,6 +263,10 @@ def test_configs_define_original_jobs_without_daily_variants():
{"backend": "auto_memory_step"},
{"backend": "auto_tag_step", "max_tags_per_file": 3},
]
assert default["jobs"]["auto_memory_cc"]["steps"] == [
{"backend": "auto_memory_cc_step"},
{"backend": "auto_tag_step", "max_tags_per_file": 3},
]
if __name__ == "__main__":

View file

@ -30,12 +30,12 @@ def test_tag_normalization_and_bidirectional_mutations() -> None:
async def run() -> None:
index = LocalTagIndex(max_tags_per_file=3)
await index.start()
await index.upsert_nodes([_node("daily/a.md", ["Python", "PYTHON", "C++", ".NET", "ignored"])])
await index.upsert_nodes([_node("daily/a.md", ["Python Tag", "PYTHON TAG", "C++", ".NET", "ignored"])])
assert await index.tags_for_path("daily/a.md") == ["python", "c++", ".net"]
assert await index.paths_for_tags(["PYTHON"]) == ["daily/a.md"]
assert await index.tags_for_path("daily/a.md") == ["python_tag", "c++", ".net"]
assert await index.paths_for_tags(["PYTHON TAG"]) == ["daily/a.md"]
assert index.tag_to_paths == {
"python": {"daily/a.md"},
"python_tag": {"daily/a.md"},
"c++": {"daily/a.md"},
".net": {"daily/a.md"},
}
@ -99,7 +99,7 @@ def test_queries_are_not_truncated_by_per_file_tag_limit() -> None:
"daily/a.md",
"daily/c.md",
]
assert index.normalize_query_tags([" A ", "B", "c", "a", "too-long-tag", " "]) == ["a", "b", "c"]
assert index.normalize_query_tags([" A ", "B", "c c", "a", "too-long-tag", " "]) == ["a", "b", "c_c"]
asyncio.run(run())
@ -180,6 +180,10 @@ def test_list_tags_paginates_and_applies_default_sort_orders() -> None:
with pytest.raises(ValueError, match=message):
await index.list_tags(**kwargs)
index.set_healthy(False)
with pytest.raises(RuntimeError, match="tag index is unavailable"):
await index.list_tags()
store = LocalFileStore(name="test", embedding_store="", tag_index="")
store.tag_index = LocalTagIndex()
await store.tag_index.rebuild([_node("daily/a.md", ["ReMe"])])
@ -229,6 +233,11 @@ def test_configured_frontmatter_key_contract() -> None:
(123, "tag_key must be a non-empty string", False),
("name", "tag_key must not be a reserved frontmatter key", False),
("description", "tag_key must not be a reserved frontmatter key", False),
("kind", "tag_key must not be a reserved frontmatter key", False),
("session_id", "tag_key must not be a reserved frontmatter key", False),
("source_conversation", "tag_key must not be a reserved frontmatter key", False),
("source_resource", "tag_key must not be a reserved frontmatter key", False),
("status", "tag_key must not be a reserved frontmatter key", False),
("", "tag_key must be a non-empty string", True),
("name", "tag_key must not be a reserved frontmatter key", True),
("description", "tag_key must not be a reserved frontmatter key", True),
@ -508,3 +517,7 @@ def test_default_config_enables_tag_index_with_explicit_key() -> None:
{"backend": "auto_memory_step"},
{"backend": "auto_tag_step", "max_tags_per_file": 3},
]
assert config["jobs"]["auto_memory_cc"]["steps"] == [
{"backend": "auto_memory_cc_step"},
{"backend": "auto_tag_step", "max_tags_per_file": 3},
]