ReMe/reme4/steps/transfer/ingest.py
jinliyl 3cb2579ff7
refactor(steps): update auto-memory (#263)
* refactor(steps): update naming conventions in components and configuration

Updated naming conventions across multiple files, changing colon-separated names to underscore-separated format, and added new step definitions along with documentation updates.

Key changes:
- Replaced `Synchronizer` with `AutoMemory` as the counterpart component for cold-write operations
- Updated naming conventions in all related configuration files (e.g., `frontmatter:read` → `frontmatter_read`)
- Added new step definitions such as `submit_slug_updates` and `auto_memory`
- Updated relevant documentation
- Modified log output format for improved readability

* refactor(evolve): Refactor the auto-memory module and update related configurations

- Remove the old slug update commit step file
- Add new auto-memory planner and writer steps
- Update __init__.py to export the new step classes
- Modify the auto_memory configuration structure in default.yaml
- Update the slug field description for clearer explanation of its purpose

* up

* up

* refactor(tests): Move unit test directory from `tests4/unittest` to `tests4/unit`

Additionally, the assertion logic in test files has been updated: direct comparisons of `payload["notes"]` have been replaced with checks verifying the presence of paths and metadata within the response content. Furthermore, some test expectations have been simplified—for example, using `count` instead of asserting against specific note lists.

Specific changes include:
- Updating workflow configurations to align with the new test directory structure
- Modifying assertions across multiple test methods to make them more flexible and maintainable
- Cleaning up and optimizing parts of the test code structure

This is a comprehensive test refactoring effort aimed at improving test readability and robustness.

* Refactor(steps): Update memory writing logic and optimize JSON schema structure

Improved the write strategy description in `auto_memory_writer.yaml` to emphasize using `edit` over `write`.
Adjusted the `json_schema` structure in `base_step.py` to support the new function definition format.
Also corrected grammatical issues in the related documentation.

* Fix: Improve frontend data parsing error handling and update test files

Added capture and handling logic for YAML parsing exceptions, providing more detailed error messages when frontend data format issues occur. Also corrected the description text in a test file.
2026-05-29 12:07:44 +08:00

447 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""``ingest`` — capture an externally-received asset into ``resource/<date>/``.
This step is the dedicated information-capture interface: when an
external channel (wechat group, email, browser save, API push, ...)
hands the agent a file, ``ingest`` lands it in ``resource/<YYYY-MM-DD>/``
keyed by the day it was received (always today, local time),
alongside a ``meta.json`` row recording provenance.
This is the only ingest path through ``resource/``. Materials the
main agent *actively* fetches or generates during a daily task
belong inside the daily folder as sibling materials, not here.
For generic file copy (local fs → arbitrary vault path) use
``upload`` instead.
Bucket layout::
resource/<YYYY-MM-DD>/
meta.json # JSON array of FileNode rows
<date>.md # derived markdown view
<channel>__<HHMMSS>__<source-basename> # the asset itself
...
Naming convention — the file name is **derived**, never caller-supplied::
<channel>__<HHMMSS>__<source-basename>
* ``<channel>`` — top-level identity (anchors provenance), lowercase
letters/digits/dashes only.
* ``<HHMMSS>`` — receive time within the bucket day (the date is
already implicit in the bucket folder).
* ``<source-basename>`` — the basename of the ``path`` argument,
after rejecting path separators, dot segments, and leading dots.
This format is self-describing in directory listings + wikilinks
(``[[resource/<date>/wechat__153022__report.pdf]]``) and makes
cross-channel basename collisions structurally impossible. Two
genuine duplicates (same channel + same second + same basename)
are reported as an error — the step never silently dedupes, so
callers see the conflict and can decide whether to retry, rename
upstream, or skip.
Each ``ingest`` call:
1. Resolves the bucket date as today (local time).
2. Validates the inputs: ``path`` exists, ``channel`` matches the
allowed character class, ``description`` is non-empty, the
source basename has no path separators / dot segments / leading
dot.
3. Builds the final name from the format above.
4. Under a per-day file lock, checks the final name against
``meta.json`` ∪ the on-disk listing. Any collision → error
(no silent suffixing).
5. Copies the asset into the bucket.
6. Appends a :class:`FileNode` row to ``meta.json`` (with provenance
on ``front_matter``) and re-renders ``<date>.md``.
Parameters:
* ``path`` (required) — local filesystem path to the asset to ingest.
* ``channel`` (required) — inbound channel identifier (wechat /
email / browser / api / ...). Lowercase letters / digits / dashes
only.
* ``description`` (required) — analysis hint for downstream agents:
where the asset came from, what kind of content it carries, and how
it should be interpreted. The digester / auto_memory reads this
verbatim from ``meta.json`` to decide how to read the asset (skim
vs. deep parse, structured extraction vs. summarization, etc.), so
callers should write enough detail to drive that decision — not
just a title. Multi-line is fine; the ``<date>.md`` bullet view
flattens for display while ``meta.json`` preserves the original.
* ``metadata`` (optional dict) — extras persisted on the meta row.
``source`` (free-form origin within the channel) is conventional;
any other keys pass through verbatim. Keys ``name``, ``channel``,
``received_at``, ``description`` are reserved.
Returns ``{date, name, path}`` on success or ``{error}`` on failure.
"""
import datetime
import fcntl
import json
import os
import re
import shutil
import tempfile
from pathlib import Path
from ..base_step import BaseStep
from ...components import R
from ...schema import FileFrontMatter, FileNode
# Channel identifier character class — keeps the derived filename predictable
# and parseable (the `__` separator is also disjoint from this set).
_CHANNEL_RE = re.compile(r"^[a-z0-9][a-z0-9-]*$")
# Keys the step manages itself — callers cannot smuggle them in via metadata.
_RESERVED_METADATA_KEYS = frozenset({"name", "channel", "received_at", "description"})
@R.register("ingest_step")
class IngestStep(BaseStep):
"""Capture an external asset into ``resource/<date>/`` and update the day's meta + index."""
async def execute(self):
assert self.context is not None
path: str = (self.context.get("path", "") or "").strip()
channel: str = (self.context.get("channel", "") or "").strip()
description: str = (self.context.get("description", "") or "").strip()
metadata_raw = self.context.get("metadata") or {}
prepared, prep_error = _prepare_inputs(path, channel, description, metadata_raw)
if prep_error:
self._fail({"error": prep_error})
return
try:
outcome = self._land(
src=prepared["src"],
date=prepared["date"],
final_name=prepared["final_name"],
entry_fields={
**prepared["metadata"],
"channel": channel,
"received_at": prepared["received_at"],
"description": description,
},
)
except _DuplicateIngest as e:
self._fail({"error": str(e)})
return
except Exception as e:
self._fail({"error": f"{type(e).__name__}: {e}"})
return
self.context.response.success = True
self.context.response.answer = f"Ingested {outcome['name']} to {outcome['path']}"
self.context.response.metadata.update(outcome)
# ------------------------------------------------------------------
def _fail(self, payload: dict) -> None:
assert self.context is not None
self.context.response.success = False
self.context.response.answer = f"Error: {payload.get('error', 'ingest failed')}"
self.context.response.metadata.update(payload)
def _resource_dir_name(self) -> str:
"""Configured ``resource_dir`` subdir name; defaults to ``"resource"`` outside an app context."""
if self.app_context is None:
return "resource"
return self.app_context.app_config.resource_dir
def _vault_dir(self) -> Path:
vr = getattr(self.file_store, "vault_path", None)
return Path(vr).resolve() if vr else Path.cwd().resolve()
def _land(self, src: Path, date: str, final_name: str, entry_fields: dict) -> dict:
resource_dir = self._resource_dir_name()
bucket = self._vault_dir() / resource_dir / date
bucket.mkdir(parents=True, exist_ok=True)
meta_path = bucket / "meta.json"
day_md = bucket / f"{date}.md"
lock_path = bucket / ".lock"
rel_path = f"{resource_dir}/{date}/{final_name}"
with _bucket_lock(lock_path):
existing_entries = _read_meta(meta_path)
# Collision check spans meta ∪ on-disk listing so a stray file
# (from a crashed earlier run) and case-insensitive filesystems
# both surface the conflict rather than getting silently clobbered.
on_disk = {p.name for p in bucket.iterdir() if p.is_file()}
existing_names = {Path(e.path).name for e in existing_entries} | on_disk
if final_name in existing_names:
raise _DuplicateIngest(
f"duplicate: {final_name!r} already exists in {resource_dir}/{date}/",
)
dst_path = bucket / final_name
shutil.copyfile(src, dst_path)
# FileFrontMatter has first-class `name` / `description`; everything
# else (channel / source / received_at / passthrough metadata) rides
# the extras bag (model_config extra="allow").
description = entry_fields.pop("description", "")
entry = FileNode(
path=rel_path,
st_mtime=dst_path.stat().st_mtime,
front_matter=FileFrontMatter(description=description, **entry_fields),
)
updated = existing_entries + [entry]
_atomic_write_text(
meta_path,
json.dumps([e.model_dump() for e in updated], ensure_ascii=False, indent=2) + "\n",
)
_atomic_write_text(day_md, _assemble_day_md(updated, date))
return {
"date": date,
"name": final_name,
"path": rel_path,
}
class _DuplicateIngest(Exception):
"""Raised when the derived name already exists in the bucket."""
# ----------------------------------------------------------------------
# Input validation
# ----------------------------------------------------------------------
def _prepare_inputs(
path: str,
channel: str,
description: str,
metadata_raw,
) -> tuple[dict, str]:
"""Validate caller args and derive the bucket date / final name.
Returns ``(prepared, error)``: on success ``prepared`` has
``{src, date, received_at, final_name, metadata}`` and ``error`` is
empty; on failure ``prepared`` is ``{}`` and ``error`` carries the
first violation in user-facing order (path → channel → description
→ metadata-shape → file existence → basename → metadata-keys).
"""
src = Path(path) if path else None
metadata, meta_error = _sanitize_metadata(metadata_raw) if isinstance(metadata_raw, dict) else ({}, "")
error = next(
(
msg
for msg in (
"path is required" if not path else "",
_validate_channel(channel),
"description is required" if not description else "",
"metadata must be a dict" if not isinstance(metadata_raw, dict) else "",
f"path not found: {path}" if src is not None and not src.is_file() else "",
_validate_basename(src.name) if src is not None else "",
meta_error,
)
if msg
),
"",
)
if error:
return {}, error
assert src is not None # narrowed by the "path is required" check
now = datetime.datetime.now()
return (
{
"src": src,
"date": now.strftime("%Y-%m-%d"),
"received_at": now.isoformat(timespec="seconds"),
"final_name": f"{channel}__{now.strftime('%H%M%S')}__{src.name}",
"metadata": metadata,
},
"",
)
def _validate_channel(channel: str) -> str:
"""Return an error string when ``channel`` is unsafe; empty when OK."""
if not channel:
return "channel is required"
if not _CHANNEL_RE.match(channel):
return f"channel {channel!r} must be lowercase letters / digits / dashes " f"and start with a letter or digit"
return ""
def _validate_basename(name: str) -> str:
"""Return an error string when the source basename is unsafe; empty when OK.
The derived filename embeds this string after a ``__`` separator, so we
only need to block characters that would mangle the filesystem path —
path separators, dot segments, leading dots. Bookkeeping-name collisions
(``meta.json`` / ``<date>.md``) are impossible by construction once the
channel + time prefix is prepended.
"""
if not name:
return "source basename is empty"
if "/" in name or "\\" in name:
return f"source basename must not contain path separators: {name!r}"
if name in {".", ".."}:
return f"source basename {name!r} is reserved"
if name.startswith("."):
return f"source basename may not start with '.': {name!r}"
return ""
def _sanitize_metadata(raw: dict) -> tuple[dict, str]:
"""Return ``(cleaned_metadata, error)``.
Rejects reserved keys (those the step manages itself) and coerces
``source`` to a string. All other keys pass through verbatim so
callers can attach arbitrary tags that land on the entry's
``front_matter`` extras.
"""
for key in _RESERVED_METADATA_KEYS:
if key in raw:
return {}, f"metadata key {key!r} is reserved"
cleaned = dict(raw)
if "source" in cleaned:
src = cleaned["source"]
cleaned["source"] = src.strip() if isinstance(src, str) else ""
return cleaned, ""
# ----------------------------------------------------------------------
# Per-day exclusive lock + atomic write helpers
# ----------------------------------------------------------------------
class _bucket_lock:
"""Exclusive ``flock`` on a per-day lock file; serializes meta+md writes."""
def __init__(self, lock_path: Path):
self.lock_path = lock_path
self._fp = None
def __enter__(self):
self._fp = open(self.lock_path, "w", encoding="utf-8")
fcntl.flock(self._fp.fileno(), fcntl.LOCK_EX)
return self
def __exit__(self, *exc):
if self._fp is not None:
try:
fcntl.flock(self._fp.fileno(), fcntl.LOCK_UN)
finally:
self._fp.close()
self._fp = None
def _read_meta(meta_path: Path) -> list[FileNode]:
"""Read ``meta.json`` as a list of :class:`FileNode` rows; missing or malformed → []."""
if not meta_path.is_file():
return []
try:
raw = json.loads(meta_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return []
if not isinstance(raw, list):
return []
out: list[FileNode] = []
for row in raw:
if not isinstance(row, dict):
continue
try:
out.append(FileNode(**row))
except Exception:
continue
return out
def _atomic_write_text(target: Path, text: str) -> None:
"""Atomic text write via tempfile + os.replace in the same directory."""
target.parent.mkdir(parents=True, exist_ok=True)
tmp_fd, tmp_path = tempfile.mkstemp(prefix=".ingest-", dir=target.parent)
try:
with os.fdopen(tmp_fd, "w", encoding="utf-8") as f:
f.write(text)
os.replace(tmp_path, target)
except Exception:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
# ----------------------------------------------------------------------
# Day-view markdown rendering (pure)
# ----------------------------------------------------------------------
def _assemble_day_md(entries: list[FileNode], date: str) -> str:
"""Render the day's bucket as a markdown view.
Layout::
---
name: <date>
assets: [<name1>, <name2>, ...]
---
# <date> resources
- [[<path>]] — <channel> from `<source>` at <hh:mm> — <description>
Provenance lives on each entry's ``front_matter`` (``channel`` /
``source`` / ``received_at`` as extras, ``description`` as the
first-class field). ``received_at`` is rendered as ``HH:MM`` when it
parses as ISO 8601, else dropped silently — the asset list stays
readable even when upstream channels emit malformed timestamps.
``source`` is dropped when empty. ``description`` is flattened
(newlines collapsed to spaces) so the bullet stays one line per
asset; ``meta.json`` preserves the verbatim multi-line text.
"""
names = [Path(e.path).name for e in entries]
lines: list[str] = [
"---",
f"name: {date}",
f"assets: [{', '.join(names)}]",
"---",
"",
f"# {date} resources",
"",
]
for entry in entries:
fm = entry.front_matter
channel = getattr(fm, "channel", "") or ""
source = getattr(fm, "source", "") or ""
received_at = getattr(fm, "received_at", "") or ""
description = fm.description or ""
bits: list[str] = [f"- [[{entry.path}]]"]
provenance = channel
if source:
provenance += f" from `{source}`"
time_part = _hhmm(received_at)
if time_part:
provenance += f" at {time_part}"
bits.append(provenance)
if description:
# Flatten so the bullet stays one line per asset; meta.json keeps
# the verbatim multi-line description for downstream consumers.
bits.append(" ".join(description.split()))
lines.append(" — ".join(bits))
return "\n".join(lines) + "\n"
def _hhmm(received_at: str) -> str:
"""Best-effort HH:MM extraction from an ISO 8601 timestamp."""
if not received_at:
return ""
raw = received_at.replace("Z", "+00:00")
try:
return datetime.datetime.fromisoformat(raw).strftime("%H:%M")
except ValueError:
return ""