Make max_tags_per_file configurable in auto-tag step

This commit is contained in:
酱牛肉 2026-09-10 16:37:50 +08:00
parent 94de5da0d3
commit b3a0ba62b2
6 changed files with 90 additions and 22 deletions

View file

@ -189,6 +189,7 @@ jobs:
steps:
- backend: auto_memory_step
- backend: auto_tag_step
max_tags_per_file: 3
auto_memory_cc:
backend: base

View file

@ -13,8 +13,8 @@ from ..file_io._path import display_path, resolve_path
from ..index import normalize_posix_path
from ...components import R
_MAX_MEMORY_TAGS = 3
_MAX_MEMORY_TAG_LENGTH = 64
_DEFAULT_MAX_MEMORY_TAGS = 3
_DEFAULT_MAX_MEMORY_TAG_LENGTH = 64
_SUPPORTED_CHANGES = {"added", "modified"}
@ -24,8 +24,21 @@ class _TagTarget:
path: str
def normalize_memory_tags(value: object) -> list[str]:
"""Normalize up to three human-readable entity labels for frontmatter storage."""
def _positive_int(value: object, *, name: str) -> int:
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
raise ValueError(f"{name} must be a positive integer")
return value
def normalize_memory_tags(
value: object,
*,
max_tags_per_file: int = _DEFAULT_MAX_MEMORY_TAGS,
max_tag_length: int = _DEFAULT_MAX_MEMORY_TAG_LENGTH,
) -> list[str]:
"""Normalize human-readable entity labels for frontmatter storage."""
max_tags_per_file = _positive_int(max_tags_per_file, name="max_tags_per_file")
max_tag_length = _positive_int(max_tag_length, name="max_tag_length")
if not isinstance(value, list):
return []
@ -35,14 +48,14 @@ def normalize_memory_tags(value: object) -> list[str]:
if not isinstance(item, str):
continue
tag = " ".join(item.split())
if not tag or len(tag) > _MAX_MEMORY_TAG_LENGTH or not any(char.isalnum() for char in tag):
if not tag or len(tag) > max_tag_length or not any(char.isalnum() for char in tag):
continue
canonical = tag.casefold()
if canonical in seen:
continue
seen.add(canonical)
tags.append(tag)
if len(tags) >= _MAX_MEMORY_TAGS:
if len(tags) >= max_tags_per_file:
break
return tags
@ -51,10 +64,22 @@ def normalize_memory_tags(value: object) -> list[str]:
class AutoTagStep(BaseStep):
"""Update memory tags for Markdown files described by the common ``changes`` contract."""
def __init__(self, **kwargs):
def __init__(self, max_tags_per_file: int = _DEFAULT_MAX_MEMORY_TAGS, **kwargs):
super().__init__(**kwargs)
self.max_tags_per_file = _positive_int(max_tags_per_file, name="max_tags_per_file")
self.tools = ["read", "list_tags", "frontmatter_read", "frontmatter_update"]
@staticmethod
def _index_limit(tag_index, name: str, fallback: int | None) -> int | None:
"""Read one positive integer index limit, falling back when unavailable or invalid."""
try:
value = getattr(tag_index, name)
except (AttributeError, TypeError, ValueError):
return fallback
if isinstance(value, bool) or not isinstance(value, int) or value <= 0:
return fallback
return value
def _targets(self) -> tuple[list[_TagTarget], list[dict[str, str]]]:
"""Validate, normalize, and de-duplicate added/modified Markdown changes."""
assert self.context is not None
@ -94,10 +119,14 @@ class AutoTagStep(BaseStep):
targets[path] = _TagTarget(change=normalized_change, path=path)
return list(targets.values()), ignored
async def _process_target(self, target: _TagTarget, tag_key: str) -> str:
async def _process_target(self, target: _TagTarget, tag_key: str, max_tag_length: int) -> str:
result = await self.agent_wrapper.reply(
self.prompt_format("user_message", path=target.path, change=target.change, tag_key=tag_key),
system_prompt=self.prompt_format("system_prompt", tag_key=tag_key),
system_prompt=self.prompt_format(
"system_prompt",
tag_key=tag_key,
max_tags_per_file=self.max_tags_per_file,
),
job_tools=self.tools,
injected_job_kwargs={
"_allowed_paths": [target.path],
@ -107,7 +136,11 @@ class AutoTagStep(BaseStep):
path = Path(self.file_store.workspace_path or ".") / target.path
metadata = dict(frontmatter.loads(path.read_text(encoding="utf-8")).metadata or {})
normalized = normalize_memory_tags(metadata.get(tag_key))
normalized = normalize_memory_tags(
metadata.get(tag_key),
max_tags_per_file=self.max_tags_per_file,
max_tag_length=max_tag_length,
)
if metadata.get(tag_key) != normalized:
response = await self.run_job(
"frontmatter_update",
@ -148,12 +181,23 @@ class AutoTagStep(BaseStep):
return self.context.response
tag_index = self.file_store.require_tag_index() if targets else None
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:
self.context.response.success = False
if initial_success:
self.context.response.answer = (
f"Error: auto_tag max_tags_per_file ({self.max_tags_per_file}) exceeds "
f"tag index limit ({index_max_tags})"
)
return self.context.response
dates: set[str] = set()
for target in targets:
if day := self._daily_date(target.path):
dates.add(day)
try:
summary = await self._process_target(target, tag_index.tag_key)
summary = await self._process_target(target, tag_index.tag_key, max_tag_length)
results.append(
{"change": target.change, "path": target.path, "success": True, "summary": summary},
)

View file

@ -6,9 +6,9 @@ system_prompt: |
Required workflow:
1. Use `read` and `frontmatter_read` to inspect the complete target document and its current frontmatter.
2. Use `list_tags` to inspect tags already used in the workspace. Check additional pages when useful.
3. Prefer one primary entity that best answers the question. Use two or three tags only when the memory is genuinely
about multiple independent central entities, and never use more than three. Prefer an existing tag whenever it
represents the same entity. Tags must not contain spaces; replace spaces with underscores, for example
3. Prefer one primary entity that best answers the question. Use multiple tags only when the memory is genuinely
about multiple independent central entities, and never use more than {max_tags_per_file}. Prefer an existing tag
whenever it represents the same entity. Tags must not contain spaces; replace spaces with underscores, for example
`sam_altman`. Examples include a person (`sam_altman`), an organization (`OpenAI`), or an asset/entity (`gold`).
4. Use `frontmatter_update` to write the complete `{tag_key}` list, including `[]` when no entity is central.
@ -25,9 +25,9 @@ system_prompt_zh: |
1. 使用 `read` 和 `frontmatter_read` 阅读目标文档的完整内容及当前 frontmatter。
2. 使用 `list_tags` 查看工作区已经使用的标签;必要时继续查看后续分页。
3. 默认只选择一个最能回答上述问题的主实体。只有当记忆确实同时围绕多个相互独立的核心实体时,才选择
两个或三个标签,并且绝不能超过三个。同一实体已有标签时必须优先复用。标签中不能包含空格;如有空格,
必须替换为下划线,例如将 `Sam Altman` 写成 `sam_altman`。实体可以是人物(如 `sam_altman`)、组织或
公司(如 `宁德时代`),也可以是资产等实体(如 `黄金`)。
多个标签,并且绝不能超过 {max_tags_per_file} 个。同一实体已有标签时必须优先复用。标签中不能包含空格;
如有空格,必须替换为下划线,例如将 `Sam Altman` 写成 `sam_altman`。实体可以是人物(如 `sam_altman`)、
组织或公司(如 `宁德时代`),也可以是资产等实体(如 `黄金`)。
4. 使用 `frontmatter_update` 写入完整的 `{tag_key}` 列表;没有核心实体时也要写入 `[]`。
只选择实体,不要使用宽泛主题、行为、属性、日期或普通关键词。每个实体使用一个简洁、规范的现实名称。

View file

@ -180,13 +180,13 @@ async def test_auto_tag_uses_configured_key_and_normalizes_agent_output(tmp_path
note = tmp_path / "memory/note.md"
_write_note(note)
store = LocalFileStore(name="store", embedding_store="", tag_index="")
store.tag_index = LocalTagIndex(tag_key="keywords")
store.tag_index = LocalTagIndex(tag_key="keywords", max_tag_length=8)
wrapper = _TaggingWrapper(
tmp_path,
tag_key="keywords",
tags=["OpenAI", "openai", "Sam Altman", "++", 100, "宁德时代", "黄金"],
)
step = AutoTagStep(file_store=store, agent_wrapper=wrapper)
step = AutoTagStep(file_store=store, agent_wrapper=wrapper, max_tags_per_file=2)
async def update_frontmatter(name, /, **kwargs):
assert name == "frontmatter_update"
@ -206,7 +206,6 @@ async def test_auto_tag_uses_configured_key_and_normalizes_agent_output(tmp_path
assert response.answer == "Created memory/note.md"
assert frontmatter.loads(note.read_text(encoding="utf-8")).metadata["keywords"] == [
"OpenAI",
"Sam Altman",
"宁德时代",
]
assert "Change: modified" in wrapper.calls[0][0]
@ -225,3 +224,27 @@ def test_normalize_memory_tags_enforces_entity_storage_contract():
"黄金",
],
) == ["OpenAI", "Sam Altman", "宁德时代"]
assert normalize_memory_tags(
["one", "two", "three"],
max_tags_per_file=2,
max_tag_length=3,
) == ["one", "two"]
@pytest.mark.asyncio
async def test_auto_tag_rejects_limit_above_tag_index_ceiling(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
note = tmp_path / "memory/note.md"
_write_note(note)
before = note.read_bytes()
store = LocalFileStore(name="store", embedding_store="", tag_index="")
store.tag_index = LocalTagIndex(max_tags_per_file=2)
wrapper = _TaggingWrapper(tmp_path)
step = AutoTagStep(file_store=store, agent_wrapper=wrapper, max_tags_per_file=3)
response = await step(RuntimeContext(changes=[{"change": "modified", "path": "memory/note.md"}]))
assert response.success is False
assert response.answer == "Error: auto_tag max_tags_per_file (3) exceeds tag index limit (2)"
assert not wrapper.calls
assert note.read_bytes() == before

View file

@ -282,7 +282,7 @@ def test_configs_define_original_jobs_without_daily_variants():
default = resolve_app_config(config="default", log_config=False)
assert default["jobs"]["auto_memory"]["steps"] == [
{"backend": "auto_memory_step"},
{"backend": "auto_tag_step"},
{"backend": "auto_tag_step", "max_tags_per_file": 3},
]

View file

@ -570,5 +570,5 @@ def test_default_config_enables_tag_index_with_explicit_key() -> None:
assert config["jobs"]["search"]["parameters"]["properties"]["tags"]["default"] == []
assert config["jobs"]["auto_memory"]["steps"] == [
{"backend": "auto_memory_step"},
{"backend": "auto_tag_step"},
{"backend": "auto_tag_step", "max_tags_per_file": 3},
]