diff --git a/reme/components/tag_index/local_tag_index.py b/reme/components/tag_index/local_tag_index.py index 541201a8..e2f4e209 100644 --- a/reme/components/tag_index/local_tag_index.py +++ b/reme/components/tag_index/local_tag_index.py @@ -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") diff --git a/reme/config/default.yaml b/reme/config/default.yaml index 2df9707d..9f62ccb8 100644 --- a/reme/config/default.yaml +++ b/reme/config/default.yaml @@ -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 diff --git a/reme/steps/evolve/auto_tag.py b/reme/steps/evolve/auto_tag.py index 1abd4a33..19fe0dd5 100644 --- a/reme/steps/evolve/auto_tag.py +++ b/reme/steps/evolve/auto_tag.py @@ -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: diff --git a/tests/unit/test_auto_tag.py b/tests/unit/test_auto_tag.py index b26ba583..2255a816 100644 --- a/tests/unit/test_auto_tag.py +++ b/tests/unit/test_auto_tag.py @@ -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): diff --git a/tests/unit/test_evolve_utils.py b/tests/unit/test_evolve_utils.py index b0356ecf..c29cd0f9 100644 --- a/tests/unit/test_evolve_utils.py +++ b/tests/unit/test_evolve_utils.py @@ -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") == [] diff --git a/tests/unit/test_injected_job_kwargs.py b/tests/unit/test_injected_job_kwargs.py index 80cce695..f4e32500 100644 --- a/tests/unit/test_injected_job_kwargs.py +++ b/tests/unit/test_injected_job_kwargs.py @@ -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__": diff --git a/tests/unit/test_tag_index.py b/tests/unit/test_tag_index.py index 6f4eb6c5..d4189493 100644 --- a/tests/unit/test_tag_index.py +++ b/tests/unit/test_tag_index.py @@ -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}, + ]