ReMe/reme/steps/file_io/frontmatter_update.py
xyf2020 06fb46fa48
feat(tags): add configurable tag indexing and filtered hybrid search (#530)
* Add optional tag generation and normalization to auto memory

* Add tag index components and clean up temporary JSONL files

* Preserve tag index state when reconciliation fails

* Refactor and streamline application implementation

* Fix pylint C1803 warnings in tag normalization tests

* Document optional tag index configuration

* Make tag index failures non-blocking and disable auto-memory tags

* Add configurable tag indexing and tag listing

* Add tag-filtered hybrid search with exact candidate ranking

* Remove obsolete generated files

* Rename tag index key to tag_key and reject reserved fields

* Extract automatic tagging into a dedicated step

* Restrict frontmatter updates to authorized keys

* Refine tag filtering and automatic memory tagging

* Require underscore-separated tags in auto-tag prompts

- Forbid spaces in tags and require underscores (e.g. sam_altman) in both
  English and Chinese auto_tag prompts, with English examples switched to
  English entities (OpenAI, gold)
- Drop prompt-string assertions superseded by the new tagging rule
- Merge construction/runtime tag_key validation tests into one parametrized case

* Make max_tags_per_file configurable in auto-tag step

* Consolidate tag index tests

* Fix search test fixture lint warnings

* Align tag contracts and index health behavior

* Fall back when tag index is unavailable

---------

Co-authored-by: jinli.yl <jinli.yl@alibaba-inc.com>
2026-09-10 18:01:09 +08:00

94 lines
4.4 KiB
Python

"""``frontmatter_update_step`` — set frontmatter keys on a markdown file.
Read-modify-write the YAML frontmatter; body content is untouched.
The watcher / parser pick up the change asynchronously.
Input shape: ``frontmatter_update_step path=foo.md metadata={"x": "y", "z": "w"}``
— ``metadata`` is an explicit dict whose entries are merged into the
file's frontmatter (existing keys overwritten, missing keys inserted).
``path`` is a path relative to the workspace. Non-markdown targets return
``error="not markdown"``. An empty or missing ``metadata`` returns
``error="no fields to update"``.
"""
from pathlib import Path
import frontmatter
from ._file_io import get_path_lock
from ._path import _check_path_permission, display_path, gate_md, resolve_path
from ..base_step import BaseStep
from ...components import R
@R.register("frontmatter_update_step")
class FrontmatterUpdateStep(BaseStep):
"""Set frontmatter keys on a markdown file from a ``metadata`` dict.
Permission: honors the request-scoped ``_allowed_paths`` constraint
injected by the server into the RuntimeContext;
without it, restricting read/edit/write alone would still leave
frontmatter of arbitrary workspace Markdown files mutable.
``_allowed_frontmatter_keys`` optionally limits updates to an injected
list of top-level keys. Omitting it (or setting it to ``None``) preserves
the unrestricted historical behavior.
"""
async def execute(self):
assert self.context is not None
path: str = self.context.get("path") or ""
assert path, "path is required"
metadata = self.context.get("metadata") or {}
assert isinstance(metadata, dict), "metadata must be a dict"
allowed_keys = self.context.get("_allowed_frontmatter_keys")
key_error: str | None = None
if allowed_keys is not None:
if not isinstance(allowed_keys, list) or any(not isinstance(key, str) for key in allowed_keys):
key_error = "_allowed_frontmatter_keys must be a list of strings"
else:
allowed = set(allowed_keys)
denied = [key for key in metadata if not isinstance(key, str) or key not in allowed]
if denied:
names = ", ".join(sorted(repr(key) for key in denied))
key_error = f"frontmatter key(s) not allowed: {names}"
workspace_dir = Path(self.file_store.workspace_path or ".").resolve()
target, err = resolve_path(workspace_dir, path)
if err or target is None:
payload: dict = {"path": path, "error": err or "invalid path"}
else:
original_target = target
target, is_md = gate_md(target)
if not _check_path_permission(workspace_dir, target, self.context.get("_allowed_paths")):
payload = {"path": path, "error": "no permission to update this file"}
else:
lock = await get_path_lock(target)
async with lock:
if not target.is_file():
payload = {"path": path, "error": f"{display_path(workspace_dir, target)} not found"}
elif not is_md:
payload = {"path": path, "error": "not markdown"}
elif not metadata:
payload = {"path": path, "error": "no fields to update"}
elif key_error:
payload = {"path": path, "error": key_error}
else:
post = frontmatter.loads(target.read_text(encoding="utf-8"))
post.metadata.update(metadata)
target.write_text(frontmatter.dumps(post), encoding="utf-8")
payload = {"path": path, "updated": metadata}
if target != original_target:
payload["resolved_path"] = display_path(workspace_dir, target)
if "error" in payload:
self.context.response.success = False
self.context.response.answer = f"Error: {payload['error']}"
self.logger.info(f"[{self.name}] update failed path={path} error={payload['error']!r}")
else:
self.context.response.success = True
self.context.response.answer = f"Updated {len(metadata)} key(s) on {path}"
self.logger.info(f"[{self.name}] path={path} keys={list(metadata.keys())}")
self.context.response.metadata.update(payload)