ReMe/reme/steps/evolve/dream/finish.py
imrewce 354837f9af
feat(proactive): separate proactive refresh from auto dream (#488)
* refractor(proactive): upgrade proactive feature with disentangled job and steps

* refactor(proactive): apply audit fixes

- rename read-side job 'proactive' -> 'proactive_read' (less confusing vs the refresh pipeline)
- drop dedicated agent_wrapper.proactive; extraction reuses the default wrapper
- simplify schema: remove unused ProactiveExtractOutput/TopicUpdate, drop resource_paths
- extract no longer scans resource/ directly (daily notes already carry resource content)
- update tests and docs accordingly

* feat(proactive): strict extract-output gate and prompt total budget

- parse_extract_reply now requires a contract section (follow_ups/extends/updates
  as a list); non-empty replies with misspelled section names trigger the
  existing one-shot retry instead of silently checkpointing changed files
- pack_paths gains max_total_chars; extract packs newest daily material first,
  keeps the first file on overflow, and records omitted files in a trailer
  (default budget 300000 chars, configurable via max_total_chars)
- tests: schema gate unit, schema-error retry e2e, budget unit + e2e

* feat(proactive): add scenario-card plan step and generative agenda step

* feat(proactive): digest-personal profile personalization and leaner LLM contract

- extract/plan/agenda now draw a user profile block from <digest_dir>/personal/*.md
  (frontmatter description + body excerpt, per-file budget, profile.md fallback)
- all daily access honours the configured daily_dir (prompt paths parameterized,
  config-driven fallbacks) so workspaces using e.g. memory/ work unchanged
- schema trim: drop dead fields errors/material_paths, carry_forward_all -> count
- shrink LLM output contract: new topics emit title/reason/confidence/paths only;
  keywords removed end-to-end, evidence derived from paths[0] (updates keep it)

* fix(proactive): skip checkpoint when extract reply stays unusable after retry

Two consecutive unparseable replies now short-circuit the round without
checkpointing, so the same material is retried next round instead of being
silently consumed (closes the residual audit #1 gap: the structural gate
detected schema-wrong output but a double failure still checkpointed).

* fix(proactive): replace running bool with reference-counted job activity tracker for the idle gate

* refactor(proactive): remove job activity tracking and idle gate, restore job tree to upstream

* fix(proactive): address second audit round (readonly reader, mtime checkpoint, wider fallbacks, profile containment, horizon content, expiry boundary)

* refactor(dream): strip interests.yaml ownership from dream, proactive is now the sole writer

* refactor(dream): separate proactive topic generation

* ci: update renamed auto dream smoke test

* fix(proactive): complete refresh migration and docs

---------

Co-authored-by: jinli.yl <jinli.yl@alibaba-inc.com>
2026-09-07 17:23:37 +08:00

109 lines
4.8 KiB
Python

"""Dream catalog persistence step."""
from pathlib import Path
from ...base_step import BaseStep
from ....components import R
from ....schema import DreamState, FileNode
from .utils import state_from_context, store_state, workspace_dir
@R.register("dream_finish_step")
class DreamFinishStep(BaseStep):
"""Persist dream catalog and render final auto-dream response."""
def __init__(self, persist: bool = True, **kwargs):
super().__init__(**kwargs)
self.persist = persist
async def execute(self):
assert self.context is not None
state = state_from_context(self)
workspace = Path(state.workspace).resolve() if state.workspace else workspace_dir(self)
if self.file_catalog is None:
raise RuntimeError("dream_finish_step requires file_catalog")
failed_paths = set(state.failed_paths)
checkpoint = [p for p in state.changed_paths if p not in failed_paths]
day_index_paths = [f"{state.daily_dir}/{day}.md" for day in (state.dates or [state.date]) if day]
supplemental = [p for p in day_index_paths if p and p not in failed_paths]
upsert_paths = list(dict.fromkeys([*checkpoint, *supplemental]))
self.logger.info(
f"[{self.name}] start changed={len(state.changed_paths)} failed_paths={len(state.failed_paths)} "
f"checkpoint={len(checkpoint)} day_indexes={len(day_index_paths)} "
f"deleted={len(state.deleted_paths)} persist={self.persist}",
)
upserts = self._nodes(workspace, upsert_paths)
if upserts:
self.logger.info(f"[{self.name}] catalog upsert start nodes={len(upserts)}")
await self.file_catalog.upsert(upserts)
self.logger.info(f"[{self.name}] catalog upsert done nodes={len(upserts)}")
if self.persist and (upserts or state.deleted_paths):
self.logger.info(
f"[{self.name}] catalog dump start upserts={len(upserts)} deleted={len(state.deleted_paths)}",
)
await self.file_catalog.dump()
self.logger.info(f"[{self.name}] catalog dump done")
state.checkpoint_paths = [n.path for n in upserts if n.path in checkpoint]
state.summary = render_summary(state)
store_state(self, state)
self.context.response.success = not state.errors
self.context.response.answer = state.summary
self.context.response.metadata["modified"] = bool(state.modified_paths)
self.logger.info(
f"[{self.name}] finish success={self.context.response.success} "
f"checkpointed={len(state.checkpoint_paths)} failed_units={len(state.failed_units)} "
f"errors={len(state.errors)}",
)
return self.context.response
@staticmethod
def _nodes(workspace: Path, paths: list[str]) -> list[FileNode]:
out: list[FileNode] = []
for rel in paths:
try:
out.append(FileNode(path=rel, st_mtime=(workspace / rel).stat().st_mtime))
except OSError:
continue
return out
def render_summary(state: DreamState) -> str:
"""Render a concise user-facing summary."""
dates = ", ".join(state.dates or [state.date])
lines = [
("AutoDream completed with warnings" if state.warnings else "AutoDream completed"),
"",
f"- Date: {state.date}",
f"- Scan window: {dates}",
(
f"- Files: {state.files_scanned} scanned, {state.files_changed} changed, "
f"{state.files_unchanged} unchanged, {state.files_deleted} deleted"
),
f"- Extracted: {len(state.units)} unit(s)",
(
f"- Integrated: {len(state.integrate_results)} ok, {len(state.skipped_units)} skipped, "
f"{len(state.failed_units)} failed"
),
f"- Catalog: checkpointed {len(state.checkpoint_paths)} changed path(s)",
]
if state.nodes_created:
lines.append(f"- Created: {', '.join(state.nodes_created)}")
if state.nodes_updated:
lines.append(f"- Updated: {', '.join(state.nodes_updated)}")
if state.integrate_results:
lines.extend(["", "Changes:"])
for item in state.integrate_results:
action = str(item.get("action") or "").strip() or "UPDATED"
target = str(item.get("target_path") or "").strip() or "(unknown target)"
note = str(item.get("note") or "").strip()
suffix = f": {note}" if note else ""
lines.append(f"- [{target}][{action}]{suffix}")
if state.failed_paths:
lines.append(f"- Failed paths: {', '.join(state.failed_paths)}")
if state.warnings:
lines.append(f"- Warnings: {'; '.join(state.warnings)}")
if state.errors:
lines.append(f"- Errors: {'; '.join(state.errors)}")
return "\n".join(lines)