mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-09-29 01:41:38 +00:00
140 lines
4.9 KiB
Python
140 lines
4.9 KiB
Python
"""``digest_write_step`` — constrained WriteStep that targets ``digest/<bucket>/<slug>.md``.
|
|
|
|
Subclasses :class:`WriteStep`. The only thing this step adds on top of the
|
|
generic file write is path-shape validation:
|
|
|
|
* ``path`` must look like ``digest/<bucket>/<slug>.md`` (depth 1, ``.md`` suffix).
|
|
* ``<bucket>`` must be one of the configured ``buckets`` (defaults to the
|
|
dreamer's :data:`DEFAULT_BUCKETS`).
|
|
* The target must NOT already exist — for in-place updates the agent uses
|
|
:class:`DigestEditStep` instead.
|
|
|
|
Everything else (atomic write semantics, encoding, parent-dir creation,
|
|
frontmatter handling) is inherited from :class:`WriteStep`.
|
|
|
|
Bucket vocabulary lives in this module (NOT in the dreamer prompt template)
|
|
so it can be swapped / extended without touching the prompt. Future direction:
|
|
externalize via app-config injection; the ``Dreamer`` constructor already
|
|
accepts an override.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
|
|
from ..file_io.write import WriteStep
|
|
from ...components import R
|
|
|
|
|
|
# Each bucket carries a name (the filesystem folder under ``digest/``) and a
|
|
# one-line description that the Phase 2 prompt renders into the bucket-picking
|
|
# heuristic block at runtime.
|
|
DEFAULT_BUCKETS: tuple[dict[str, str], ...] = (
|
|
{
|
|
"name": "concept",
|
|
"description": 'definitions, principles, mental models ("what IS X?")',
|
|
},
|
|
{
|
|
"name": "procedure",
|
|
"description": 'steps, methods, recipes ("how do I do X?")',
|
|
},
|
|
{
|
|
"name": "entity",
|
|
"description": "specific named things (person, system, tool, project)",
|
|
},
|
|
{
|
|
"name": "observation",
|
|
"description": 'findings, results, decisions with rationale ("what happened / was decided?")',
|
|
},
|
|
{
|
|
"name": "preference",
|
|
"description": 'user / team / agent collaboration rules ("how does X like to work / what to avoid?")',
|
|
},
|
|
{
|
|
"name": "unknown",
|
|
"description": "fallback when no specialized bucket fits (first-class, not a failure state)",
|
|
},
|
|
)
|
|
|
|
|
|
def normalize_buckets(buckets) -> tuple[dict[str, str], ...]:
|
|
"""Normalize a caller-supplied bucket spec into ``tuple[{name, description}, ...]``.
|
|
|
|
Accepts ``None`` (→ :data:`DEFAULT_BUCKETS`), tuple/list of dicts (returned
|
|
as-is), or tuple/list of strings (legacy — each wrapped with an empty
|
|
description).
|
|
"""
|
|
if not buckets:
|
|
return DEFAULT_BUCKETS
|
|
first = next(iter(buckets))
|
|
if isinstance(first, dict):
|
|
return tuple(buckets)
|
|
return tuple({"name": str(b), "description": ""} for b in buckets)
|
|
|
|
|
|
def bucket_names(buckets) -> tuple[str, ...]:
|
|
"""Extract just the name field of each bucket — used for path-membership checks."""
|
|
if not buckets:
|
|
return ()
|
|
first = next(iter(buckets))
|
|
if isinstance(first, dict):
|
|
return tuple(b["name"] for b in buckets)
|
|
return tuple(buckets)
|
|
|
|
|
|
@R.register("digest_write_step")
|
|
class DigestWriteStep(WriteStep):
|
|
"""WriteStep variant that enforces the ``<digest_dir>/<bucket>/<slug>.md`` layout.
|
|
|
|
``digest_dir`` is sourced from ``app_context.app_config.digest_dir`` at
|
|
execute time — same convention as ``daily_create`` / ``daily_list`` /
|
|
``daily_reindex`` for their ``daily_dir``.
|
|
"""
|
|
|
|
def __init__(self, buckets=None, **kwargs):
|
|
super().__init__(**kwargs)
|
|
self.buckets = normalize_buckets(buckets)
|
|
|
|
def _reject(self, message: str) -> None:
|
|
assert self.context is not None
|
|
self.context.response.success = False
|
|
self.context.response.answer = f"REJECT: {message}"
|
|
|
|
async def execute(self):
|
|
assert self.context is not None
|
|
raw = str(self.context.get("path") or "")
|
|
digest_dir = getattr(self.app_context.app_config, "digest_dir", "")
|
|
|
|
err = _validate_digest_path(raw, bucket_names(self.buckets), digest_dir)
|
|
if err:
|
|
self._reject(err)
|
|
return None
|
|
|
|
abs_path = (Path(self.vault_path) / raw).resolve()
|
|
if abs_path.exists():
|
|
self._reject(f"{raw} already exists; use digest_edit instead")
|
|
return None
|
|
|
|
return await super().execute()
|
|
|
|
|
|
def _validate_digest_path(
|
|
raw: str,
|
|
allowed_names: tuple[str, ...],
|
|
digest_dir: str,
|
|
) -> str | None:
|
|
"""Return an error message, or ``None`` if ``raw`` matches the digest shape
|
|
and uses one of the allowed bucket names. ``digest_dir`` is the configured
|
|
digest root (e.g. ``"digest"``)."""
|
|
prefix = f"{digest_dir}/"
|
|
expected = f"{digest_dir}/<bucket>/<slug>.md"
|
|
if not raw.startswith(prefix) or not raw.endswith(".md"):
|
|
return f"path must be {expected!r}, got {raw!r}"
|
|
parts = raw.split("/")
|
|
if len(parts) != 3:
|
|
return f"digest is a shallow bucket layout ({expected}, depth 1); got {raw!r}"
|
|
bucket = parts[1]
|
|
if bucket not in allowed_names:
|
|
return (
|
|
f"bucket {bucket!r} not in allowed set {list(allowed_names)}. "
|
|
"Use 'unknown' when no specialized bucket fits."
|
|
)
|
|
return None
|