ReMe/reme/steps/evolve/proactive/proactive.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

204 lines
9.7 KiB
Python

"""Read interests.yaml for proactive consumption (F5).
Migrated from ``dream/proactive.py`` (INV-9 mechanical migration) and extended
with ``min_confidence`` filtering (default 0.4, safely below the 0.5 confidence
fallback) and ``horizon_days``: horizon=1 keeps the legacy single-day file
read (v1 compatible); horizon>1 reads the truth source directly, filtering by
``last_evidence_at`` recency (v5 R4 - the truth source already carries the
cross-day merge, so the reader no longer re-scans exposure products).
"""
import datetime as dt
import yaml
from ...base_step import BaseStep
from ....components import R
from ....schema import ProactiveResult
from ..dream.utils import today, workspace_dir
from .utils import (
dump_topic,
load_yaml_topics,
load_state,
parse_interests_topics,
sort_topics,
state_file_path,
topic_id,
)
@R.register("proactive_step")
class ProactiveStep(BaseStep):
"""Read ``daily/<date>/interests.yaml`` (optionally merged over a horizon).
- v1 files (nightly) are read as ``push=true`` and never rewritten;
- ``push=false`` days contribute nothing;
- resolved ids (truth source registry) are suppressed;
- ``horizon_days>1`` reads the truth source and filters by evidence recency.
"""
def __init__(self, include_content: bool = True, horizon_days: int = 1, min_confidence: float = 0.4, **kwargs):
super().__init__(**kwargs)
self.include_content = include_content
self.horizon_days = max(int(horizon_days), 1)
self.min_confidence = float(min_confidence)
async def execute(self):
assert self.context is not None
day = today(self, str(self.context.get("date", "") or ""))
include_content = bool(self.context.get("include_content", self.include_content))
horizon = int(self.context.get("horizon_days", self.horizon_days) or self.horizon_days)
horizon = max(horizon, 1)
raw_min_confidence = self.context.get("min_confidence", self.min_confidence)
try:
min_confidence = float(raw_min_confidence)
except (TypeError, ValueError):
min_confidence = self.min_confidence
daily = self.config_value("daily_dir")
ws = workspace_dir(self)
default_rel = f"{daily}/{day}/interests.yaml"
result = ProactiveResult(date=day, path=default_rel)
self.logger.info(
f"[{self.name}] start date={day} path={default_rel} include_content={include_content} "
f"horizon_days={horizon} min_confidence={min_confidence}",
)
if horizon == 1:
outcome = self._read_single(ws, daily, day, include_content, min_confidence, result)
if outcome is not None:
return outcome
return self._read_horizon(ws, daily, day, horizon, include_content, min_confidence, result)
# ------------------------------------------------------------------
# Single-day read (legacy-compatible path)
# ------------------------------------------------------------------
def _read_single(self, ws, daily, day, include_content, min_confidence, result: ProactiveResult):
rel_path = f"{daily}/{day}/interests.yaml"
abs_path = ws / daily / day / "interests.yaml"
if not abs_path.is_file():
result.skipped, result.summary = True, f"Skipped: interests file not found at {rel_path}"
self.logger.info(f"[{self.name}] skip missing path={rel_path}")
return self._finish(True, result, include_content=include_content)
try:
raw_text = abs_path.read_text(encoding="utf-8")
# The reader stays strictly read-only: a corrupt file is never
# moved or renamed here; the parse failure propagates to the
# outer handler and surfaces as success=False so callers can see
# it instead of a silent empty read (audit item 2).
data = yaml.safe_load(raw_text)
if not isinstance(data, dict):
raise ValueError("interests.yaml is not a mapping")
topics, is_v1, push = parse_interests_topics(data, day)
if push is False:
result.skipped = True
result.summary = f"Skipped: interests file at {rel_path} has push=false"
result.push = False
self.logger.info(f"[{self.name}] skip push=false path={rel_path}")
return self._finish(True, result, include_content=include_content)
result.push = True
result.content = raw_text if include_content else ""
if is_v1:
# Legacy shape (title/reason/evidence/keywords/paths) for v1 files;
# v1 topics carry no confidence and fall back to 0.5 for filtering.
resolved_ids = self._resolved_ids(ws, daily)
legacy_topics = load_yaml_topics(abs_path)
kept = [
{k: v for k, v in t.items() if k != "keywords"}
for t in legacy_topics
if topic_id(str(t.get("title") or "")) not in resolved_ids
]
if min_confidence > 0.5 + 1e-9: # v1 topics carry no confidence; fallback is 0.5 (F1.1)
kept = []
result.topics = kept
else:
result.generated_at = str(data.get("generated_at") or "")
resolved_ids = self._resolved_ids(ws, daily)
kept = [
dump_topic(t)
for t in sort_topics(topics)
if t.id not in resolved_ids and t.confidence >= min_confidence - 1e-9
]
result.topics = kept
agenda_raw = data.get("agenda")
if isinstance(agenda_raw, list):
kept_ids = {str(t.get("id") or "") for t in kept}
result.agenda = [
item
for item in agenda_raw
if isinstance(item, dict) and str(item.get("topic_id") or "") in kept_ids
]
result.summary = f"Read {len(result.topics)} proactive topic(s) from {rel_path}"
self.logger.info(f"[{self.name}] read done path={rel_path} topics={len(result.topics)}")
return self._finish(True, result, include_content=include_content)
except Exception as e: # noqa: BLE001
result.error, result.summary = f"{type(e).__name__}: {e}", ""
self.logger.error(f"[{self.name}] read failed path={rel_path}: {result.error}")
return self._finish(False, result, include_content=include_content)
# ------------------------------------------------------------------
# Multi-day merge (horizon_days > 1, or fallback target)
# ------------------------------------------------------------------
def _read_horizon(self, ws, daily, day, horizon, include_content, min_confidence, result: ProactiveResult):
"""Truth-source view (v5 R4): open topics with evidence inside the horizon.
The truth source already carries the cross-day merge (carry-forward),
so the reader no longer re-scans N days of exposure products.
"""
state_file, _needs_bootstrap = load_state(ws, daily)
if include_content:
try:
result.content = state_file_path(ws, daily).read_text(encoding="utf-8")
except OSError:
result.content = ""
if not state_file.open_topics:
result.skipped = True
result.summary = f"Skipped: truth source has no open topics (horizon_days={horizon})"
self.logger.info(f"[{self.name}] skip empty truth source horizon={horizon}")
return self._finish(True, result, include_content=include_content)
try:
base = dt.date.fromisoformat(day)
cutoff = (base - dt.timedelta(days=max(horizon - 1, 0))).isoformat()
upper_bound = base.isoformat()
except ValueError:
cutoff = ""
upper_bound = ""
kept = [
dump_topic(t)
for t in state_file.open_topics
if (
(not cutoff or cutoff <= str(t.last_evidence_at or "") <= upper_bound)
and t.confidence >= min_confidence - 1e-9
)
]
kept = sort_topics(kept)
result.path = f"{daily}/_proactive.yaml"
result.topics = kept
result.summary = f"Read {len(kept)} proactive topic(s) from the truth source (horizon_days={horizon})"
self.logger.info(f"[{self.name}] truth-source read done horizon={horizon} topics={len(kept)}")
return self._finish(True, result, include_content=include_content)
def _resolved_ids(self, ws, daily: str) -> set[str]:
state_file, _needs_bootstrap = load_state(ws, daily)
return {str(r.get("id") or "") for r in state_file.resolved if isinstance(r, dict) and r.get("id")}
def _finish(self, success: bool, result: ProactiveResult, *, include_content: bool):
assert self.context is not None
self.context.response.success = success
if not success:
self.context.response.answer = f"Error: {result.error}"
elif result.skipped:
self.context.response.answer = result.summary
else:
self.context.response.answer = {
"summary": result.summary,
"topics": result.topics,
**({"agenda": result.agenda} if result.agenda else {}),
**({"content": result.content} if include_content else {}),
}
self.context.response.metadata.update(result.model_dump())
self.logger.info(f"[{self.name}] finish success={success} answer={self.context.response.answer!r}")
return self.context.response