ReMe/tests4/unit/test_resource_steps.py
huangsen e16b52e68b feat(dream): replace digester with abstraction-layer dreamer pipeline
Reframe digest as the abstract memory layer (details stay in the daily/
resource material; digest holds principles, patterns, precedents reachable
via derived_from provenance edges). Replaces the old digester with a
2-phase ReAct workflow + a daily-tick wrapper:

- Phase 1 (Dreamer extract): clusters material into orthogonal memory
  sub-units; each sub-unit maps 1:1 to a digest node (no inner atom
  enumeration). Biases toward fewer / richer sub-units.
- Phase 2 (Dreamer integrate per sub-unit): cross-bucket recall +
  exactly one write decision (CREATE / UPDATE / SKIP); UPDATE shapes
  surfaced explicitly (corroborate / refine / correct).
- CronDreamer: scans <daily_dir>/<today>.md + <daily_dir>/<today>/**
  + <resource_dir>/<today>/** and runs dream_one per file.

Write tools are proper subclasses of the canonical file_io WriteStep /
EditStep with only path-shape + bucket + E-1 edge-conservation rules
layered on top:
- DigestWriteStep(WriteStep): path = <digest_dir>/<bucket>/<slug>.md,
  must-not-exist, schema mirrors `write` (path / name / description /
  content) so frontmatter lands automatically.
- DigestEditStep(EditStep): body-only find-and-replace + must-exist +
  E-1 conservation preflight (refuses if any outbound wikilink would
  be dropped).

Configuration:
- Bucket vocabulary structured in code (tuple[{name, description}]);
  prompt renders the heuristic block at runtime via {buckets}.
- digest_dir / daily_dir / resource_dir come from app config (not tool
  params); prompts use {digest_dir} placeholder.
- BaseStep walks class MRO when loading prompts, so subclasses inherit
  parent yaml without duplication.

Tooling: agentscope register_tool_function schemas now wrap in the
proper {"type":"function","function":{...}} envelope. OpenAIAsLLM
routes base_url through client_kwargs so non-default endpoints work.

Smoke: tests4/smoke/{_dreamer_fixture.py,test_dreamer_inproc.py,
test_dreamer_cli.sh} drive the end-to-end pipeline.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-06-01 11:49:21 +08:00

605 lines
23 KiB
Python

"""Tests for the resource ingest path: ``IngestStep`` + helpers.
``ingest`` is the **passive** ingest entry point — external channels
push assets into ``resource/<YYYY-MM-DD>/``, where each call appends a
:class:`FileNode` row to ``meta.json`` (provenance on
``front_matter``) and regenerates the day's ``<date>.md`` view from
the updated meta. These tests exercise that contract end-to-end on a
temp vault, plus the pure ``_assemble_day_md`` helper in isolation.
The bucket file name is always derived: ``<channel>__<HHMMSS>__<basename>``.
Duplicates surface as errors (no silent suffixing).
"""
# pylint: disable=protected-access
import asyncio
import datetime
import json
import os
import re
import tempfile
import warnings
from pathlib import Path
from reme4.components.file_store import LocalFileStore
from reme4.schema import FileFrontMatter, FileNode
from reme4.steps.transfer import ingest as crud_ingest
from reme4.steps.transfer.ingest import _assemble_day_md
warnings.filterwarnings("ignore", category=DeprecationWarning, module="jieba")
warnings.filterwarnings("ignore", category=DeprecationWarning, module="pkg_resources")
class temp_chdir:
"""Context manager to temporarily chdir into a path and restore on exit."""
def __init__(self, path):
self.path = path
self.old = None
def __enter__(self):
self.old = os.getcwd()
os.chdir(self.path)
return self
def __exit__(self, *exc):
os.chdir(self.old)
async def _make_store() -> LocalFileStore:
"""Minimal LocalFileStore (embedding disabled). vault_path resolves to CWD."""
store = LocalFileStore(name="t", embedding_model="")
await store.start()
return store
def _metadata(step) -> dict:
return step.context.response.metadata
def _meta(tmp: str, date: str) -> list[dict]:
return json.loads((Path(tmp) / "resource" / date / "meta.json").read_text(encoding="utf-8"))
def _today() -> str:
return datetime.datetime.now().strftime("%Y-%m-%d")
# Matches the canonical derived filename shape.
_NAME_RE = re.compile(r"^([a-z0-9][a-z0-9-]*)__(\d{6})__(.+)$")
# -- _assemble_day_md ----------------------------------------------------
def _entry(name: str, **fm_fields) -> FileNode:
"""Build a FileNode resource entry: path = resource/<date>/<name>, all
provenance fields go onto front_matter (extras allowed)."""
return FileNode(
path=f"resource/2026-05-22/{name}",
st_mtime=0.0,
front_matter=FileFrontMatter(**fm_fields),
)
def test_assemble_day_md_renders_entries():
"""The derived view lists entries with channel / source / time / description."""
entries = [
_entry(
"wechat__143000__report.pdf",
description="Q1 report",
channel="wechat",
source="design-group",
received_at="2026-05-22T14:30:00",
),
_entry("browser__095501__bare.png", channel="browser"),
]
md = _assemble_day_md(entries, "2026-05-22")
assert "name: 2026-05-22" in md
assert "assets: [wechat__143000__report.pdf, browser__095501__bare.png]" in md
assert (
"- [[resource/2026-05-22/wechat__143000__report.pdf]] — wechat from `design-group` at 14:30 — Q1 report" in md
)
assert "- [[resource/2026-05-22/browser__095501__bare.png]] — browser" in md
print("✓ test_assemble_day_md_renders_entries passed")
def test_assemble_day_md_empty_bucket():
"""An empty bucket still produces a well-formed frontmatter + header."""
md = _assemble_day_md([], "2026-05-22")
assert "assets: []" in md
assert "# 2026-05-22 resources" in md
print("✓ test_assemble_day_md_empty_bucket passed")
# -- _validate_basename (direct, pure) -----------------------------------
def test_validate_basename_rejects_path_separators():
"""Path-separator basenames are rejected even if the public API can no
longer reach this code path (Path(...).name strips them) — defense in depth."""
for bad in ("evil/payload.pdf", "..\\winpath.pdf", "../escape.pdf"):
err = crud_ingest._validate_basename(bad)
assert "path separators" in err or "reserved" in err, (bad, err)
print("✓ test_validate_basename_rejects_path_separators passed")
def test_validate_basename_rejects_dot_segments():
"""`.` and `..` are explicitly reserved."""
for bad in (".", ".."):
err = crud_ingest._validate_basename(bad)
assert "reserved" in err or "start with '.'" in err, (bad, err)
print("✓ test_validate_basename_rejects_dot_segments passed")
# -- _validate_channel (direct, pure) ------------------------------------
def test_validate_channel_accepts_safe_identifiers():
"""Lowercase letters / digits / dashes, starting alnum — all accepted."""
for ok in ("wechat", "email", "api", "browser", "slack-1", "ch1"):
assert crud_ingest._validate_channel(ok) == "", ok
print("✓ test_validate_channel_accepts_safe_identifiers passed")
def test_validate_channel_rejects_unsafe_identifiers():
"""Uppercase, underscores, leading dash, empty, special chars — rejected."""
for bad in ("", "WeChat", "we_chat", "-leading", "we chat", "we/chat", "我"):
err = crud_ingest._validate_channel(bad)
assert err, bad
print("✓ test_validate_channel_rejects_unsafe_identifiers passed")
# -- IngestStep end-to-end --------------------------------------
def test_upload_first_call_creates_bucket():
"""First upload creates resource/<date>/, copies the asset under the derived name."""
async def run():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = await _make_store()
src = Path(tmp) / "incoming.pdf"
src.write_bytes(b"%PDF-fake")
step = crud_ingest.IngestStep(file_store=store)
await step(
path=str(src),
channel="wechat",
description="Q1 report",
metadata={"source": "design-group"},
)
payload = _metadata(step)
assert "error" not in payload, payload
date = payload["date"]
assert date == _today()
m = _NAME_RE.match(payload["name"])
assert m is not None, payload["name"]
assert m.group(1) == "wechat"
assert m.group(3) == "incoming.pdf"
assert payload["path"] == f"resource/{date}/{payload['name']}"
bucket = Path(tmp) / "resource" / date
assert (bucket / payload["name"]).read_bytes() == b"%PDF-fake"
meta = _meta(tmp, date)
assert len(meta) == 1
assert Path(meta[0]["path"]).name == payload["name"]
fm = meta[0]["front_matter"]
assert fm["channel"] == "wechat"
assert fm["source"] == "design-group"
assert fm["description"] == "Q1 report"
day_md = (bucket / f"{date}.md").read_text(encoding="utf-8")
assert f"name: {date}" in day_md
assert payload["name"] in day_md
await store.close()
print("✓ test_upload_first_call_creates_bucket passed")
asyncio.run(run())
def test_upload_metadata_optional():
"""metadata is optional — minimal call is just path + channel + description."""
async def run():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = await _make_store()
src = Path(tmp) / "small.txt"
src.write_text("x")
step = crud_ingest.IngestStep(file_store=store)
await step(path=str(src), channel="api", description="minimal")
payload = _metadata(step)
assert "error" not in payload, payload
row = _meta(tmp, payload["date"])[0]
fm = row["front_matter"]
assert fm["channel"] == "api"
assert fm.get("source", "") == ""
await store.close()
print("✓ test_upload_metadata_optional passed")
asyncio.run(run())
def test_upload_appends_to_existing_meta():
"""Subsequent uploads append to meta.json and regenerate <date>.md."""
async def run():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = await _make_store()
names = []
for i, suffix in enumerate(("first", "second"), start=1):
src = Path(tmp) / f"{suffix}.txt"
src.write_text(f"payload-{i}")
step = crud_ingest.IngestStep(file_store=store)
await step(path=str(src), channel="email", description=f"item {i}")
payload = _metadata(step)
assert "error" not in payload, payload
names.append(payload["name"])
date = _today()
meta = _meta(tmp, date)
assert [Path(row["path"]).name for row in meta] == names
day_md = (Path(tmp) / "resource" / date / f"{date}.md").read_text(encoding="utf-8")
assert f"assets: [{', '.join(names)}]" in day_md
for name in names:
assert f"[[resource/{date}/{name}]]" in day_md
await store.close()
print("✓ test_upload_appends_to_existing_meta passed")
asyncio.run(run())
def test_upload_errors_on_duplicate_same_second(monkeypatch):
"""Two uploads of the same (channel, second, basename) → second one errors out."""
async def run():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = await _make_store()
# Pin time so both calls land in the same HHMMSS slot deterministically.
fixed = datetime.datetime(2026, 5, 22, 15, 30, 22)
class _FrozenDT(datetime.datetime):
@classmethod
def now(cls, tz=None): # pylint: disable=unused-argument
return fixed
monkeypatch.setattr(crud_ingest.datetime, "datetime", _FrozenDT)
for i, body in enumerate((b"alpha", b"beta")):
src = Path(tmp) / "incoming.pdf"
src.write_bytes(body)
step = crud_ingest.IngestStep(file_store=store)
await step(path=str(src), channel="wechat", description="dup test")
payload = _metadata(step)
if i == 0:
assert "error" not in payload, payload
else:
assert "duplicate" in payload.get("error", "").lower(), payload
bucket = Path(tmp) / "resource" / "2026-05-22"
# Only the first upload's file should be on disk.
payloads = [p for p in bucket.iterdir() if p.is_file() and p.name.endswith(".pdf")]
assert len(payloads) == 1
assert payloads[0].read_bytes() == b"alpha"
meta = _meta(tmp, "2026-05-22")
assert len(meta) == 1
await store.close()
print("✓ test_upload_errors_on_duplicate_same_second passed")
asyncio.run(run())
def test_upload_errors_on_duplicate_against_on_disk_stray(monkeypatch):
"""A stray file on disk (no meta row) still counts as a collision → error."""
async def run():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = await _make_store()
fixed = datetime.datetime(2026, 5, 22, 15, 30, 22)
class _FrozenDT(datetime.datetime):
@classmethod
def now(cls, tz=None): # pylint: disable=unused-argument
return fixed
monkeypatch.setattr(crud_ingest.datetime, "datetime", _FrozenDT)
bucket = Path(tmp) / "resource" / "2026-05-22"
bucket.mkdir(parents=True)
stray = bucket / "api__153022__report.pdf"
stray.write_bytes(b"orphan")
src = Path(tmp) / "report.pdf"
src.write_bytes(b"fresh")
step = crud_ingest.IngestStep(file_store=store)
await step(path=str(src), channel="api", description="fresh copy")
payload = _metadata(step)
assert "duplicate" in payload.get("error", "").lower(), payload
# Stray untouched.
assert stray.read_bytes() == b"orphan"
await store.close()
print("✓ test_upload_errors_on_duplicate_against_on_disk_stray passed")
asyncio.run(run())
def test_upload_rejects_missing_source():
"""Missing local file → error, no bucket created."""
async def run():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = await _make_store()
step = crud_ingest.IngestStep(file_store=store)
await step(
path=str(Path(tmp) / "ghost.txt"),
channel="email",
description="x",
)
payload = _metadata(step)
assert "not found" in payload.get("error", "")
assert not (Path(tmp) / "resource").exists()
await store.close()
print("✓ test_upload_rejects_missing_source passed")
asyncio.run(run())
def test_upload_requires_channel():
"""Missing / blank / malformed channel → error."""
async def run():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = await _make_store()
src = Path(tmp) / "x.txt"
src.write_text("x")
for bad in (" ", "WeChat", "we_chat"):
step = crud_ingest.IngestStep(file_store=store)
await step(path=str(src), channel=bad, description="x")
payload = _metadata(step)
assert "channel" in payload.get("error", ""), (bad, payload)
await store.close()
print("✓ test_upload_requires_channel passed")
asyncio.run(run())
def test_upload_requires_description():
"""Empty / blank description → error."""
async def run():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = await _make_store()
src = Path(tmp) / "x.txt"
src.write_text("x")
step = crud_ingest.IngestStep(file_store=store)
await step(path=str(src), channel="api", description=" ")
payload = _metadata(step)
assert "description" in payload.get("error", "")
await store.close()
print("✓ test_upload_requires_description passed")
asyncio.run(run())
def test_upload_rejects_non_dict_metadata():
"""Passing a non-dict in `metadata=` → error."""
async def run():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = await _make_store()
src = Path(tmp) / "x.txt"
src.write_text("x")
step = crud_ingest.IngestStep(file_store=store)
await step(
path=str(src),
channel="api",
description="x",
metadata="source=foo",
)
payload = _metadata(step)
assert "metadata" in payload.get("error", "")
await store.close()
print("✓ test_upload_rejects_non_dict_metadata passed")
asyncio.run(run())
def test_upload_rejects_reserved_metadata_keys():
"""Step-managed keys (`name`, `channel`, `received_at`, `description`)
can't be smuggled in via metadata."""
async def run():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = await _make_store()
src = Path(tmp) / "x.txt"
src.write_text("x")
for bad in ("name", "channel", "received_at", "description"):
step = crud_ingest.IngestStep(file_store=store)
await step(
path=str(src),
channel="api",
description="x",
metadata={bad: "evil"},
)
payload = _metadata(step)
assert "reserved" in payload.get("error", ""), (bad, payload)
await store.close()
print("✓ test_upload_rejects_reserved_metadata_keys passed")
asyncio.run(run())
def test_upload_preserves_extra_metadata_keys():
"""Arbitrary keys in `metadata` land on the meta.json row verbatim."""
async def run():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = await _make_store()
src = Path(tmp) / "x.txt"
src.write_text("x")
step = crud_ingest.IngestStep(file_store=store)
await step(
path=str(src),
channel="api",
description="tagged",
metadata={
"source": "https://example.com",
"tag": "design",
"priority": 3,
},
)
payload = _metadata(step)
assert "error" not in payload, payload
row = _meta(tmp, payload["date"])[0]
fm = row["front_matter"]
assert fm["tag"] == "design"
assert fm["priority"] == 3
assert fm["source"] == "https://example.com"
await store.close()
print("✓ test_upload_preserves_extra_metadata_keys passed")
asyncio.run(run())
# -- basename-derivation safety ------------------------------------------
def test_upload_rejects_dotfile_source():
"""A source file whose basename starts with '.' → error."""
async def run():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = await _make_store()
for bad in (".hidden", ".lock", ".env"):
src = Path(tmp) / bad
src.write_text("x")
step = crud_ingest.IngestStep(file_store=store)
await step(
path=str(src),
channel="api",
description="x",
)
payload = _metadata(step)
assert "start with '.'" in payload.get("error", ""), payload
src.unlink()
await store.close()
print("✓ test_upload_rejects_dotfile_source passed")
asyncio.run(run())
# -- payload-shape sanity ------------------------------------------------
def test_upload_records_received_at_internally():
"""`received_at` is not a caller param but the step stamps it from the
system clock so the day's <date>.md HH:MM column renders."""
async def run():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = await _make_store()
src = Path(tmp) / "doc.pdf"
src.write_bytes(b"%PDF")
step = crud_ingest.IngestStep(file_store=store)
await step(path=str(src), channel="api", description="x")
payload = _metadata(step)
assert "error" not in payload
meta = _meta(tmp, payload["date"])
assert len(meta) == 1
stamped = meta[0]["front_matter"]["received_at"]
parsed = datetime.datetime.fromisoformat(stamped)
assert parsed.strftime("%Y-%m-%d") == payload["date"]
# The HHMMSS slot in the name matches the stamped time.
m = _NAME_RE.match(payload["name"])
assert m is not None
assert m.group(2) == parsed.strftime("%H%M%S")
await store.close()
print("✓ test_upload_records_received_at_internally passed")
asyncio.run(run())
def test_upload_preserves_description_verbatim_in_meta():
"""meta.json carries the verbatim multi-line description (downstream agents
rely on it for analysis hints); only the day.md bullet flattens for display."""
async def run():
with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp):
store = await _make_store()
src = Path(tmp) / "doc.pdf"
src.write_bytes(b"%PDF")
multi = "wechat group screenshot\nfrom design-group at 14:30\nlikely a Q1 KPI table — extract numbers"
step = crud_ingest.IngestStep(file_store=store)
await step(
path=str(src),
channel="api",
description=multi,
)
payload = _metadata(step)
assert "error" not in payload, payload
# meta.json preserves the original — downstream dreamer sees full hint.
meta = _meta(tmp, payload["date"])
assert meta[0]["front_matter"]["description"] == multi
# day.md bullet is flattened (single line, no embedded newlines).
day_md = (Path(tmp) / "resource" / payload["date"] / f"{payload['date']}.md").read_text(encoding="utf-8")
flat = " ".join(multi.split())
assert flat in day_md
# The bullet line itself must contain the flattened text — and no embedded newline.
bullet_prefix = f"- [[resource/{payload['date']}/{payload['name']}]]"
bullets = [line for line in day_md.splitlines() if line.startswith(bullet_prefix)]
assert len(bullets) == 1, day_md
assert flat in bullets[0]
await store.close()
print("✓ test_upload_preserves_description_verbatim_in_meta passed")
asyncio.run(run())
def test_assemble_day_md_flattens_multiline_description():
"""Pure helper: a multi-line description on an entry renders as a single
bullet line with newlines collapsed."""
entries = [
_entry(
"api__120000__doc.pdf",
description="line one\nline two\n line three",
channel="api",
received_at="2026-05-22T12:00:00",
),
]
md = _assemble_day_md(entries, "2026-05-22")
assert "line one line two line three" in md
# The bullet line must contain the flattened text on a single line.
bullets = [line for line in md.splitlines() if line.startswith("- [[resource/2026-05-22/api__120000__doc.pdf]]")]
assert len(bullets) == 1, md
assert "line one line two line three" in bullets[0]
print("✓ test_assemble_day_md_flattens_multiline_description passed")
if __name__ == "__main__":
print("\n=== resource step tests ===")
test_assemble_day_md_renders_entries()
test_assemble_day_md_empty_bucket()
test_validate_basename_rejects_path_separators()
test_validate_basename_rejects_dot_segments()
test_validate_channel_accepts_safe_identifiers()
test_validate_channel_rejects_unsafe_identifiers()
test_upload_first_call_creates_bucket()
test_upload_metadata_optional()
test_upload_appends_to_existing_meta()
test_upload_rejects_missing_source()
test_upload_requires_channel()
test_upload_requires_description()
test_upload_rejects_non_dict_metadata()
test_upload_rejects_reserved_metadata_keys()
test_upload_preserves_extra_metadata_keys()
test_upload_rejects_dotfile_source()
test_upload_records_received_at_internally()
test_upload_preserves_description_verbatim_in_meta()
test_assemble_day_md_flattens_multiline_description()
print("\n所有测试通过!")