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

246 lines
10 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.

"""Proactive plan step: expand push candidates into scenario cards (F2.5).
Runs between the topics and agenda steps. Only topics that qualify for today's
push (``first_seen == today`` and ``confidence >= min_push_confidence``,
computed by the topics step into ``state.push_candidates``) are expanded, so
the LLM cost stays proportional to what will actually be surfaced. One batched
LLM call per round; on any LLM failure every selected candidate still gets a
deterministic fallback card so the agenda step never sees an empty input.
Card contract: ``scenario_type`` (resume_task | answer_pending |
explore_interest | prepare_upcoming), ``opener`` (a casual, friend-like
conversation opener that already names the minimal next action - never a
"based on your records" notification tone), ``next_action``, ``preconditions``
and ``delivery`` (in_conversation | notification | agenda_item).
``linked_memory`` is derived from the topic's paths/evidence by code, never by
the LLM.
"""
import asyncio
import json
import time
from ...base_step import BaseStep
from .._evolve import agent_reply_result_text, passthrough_response
from ....components import R
from ....schema import ProactiveState
from ..dream.utils import workspace_dir
from .utils import load_personal_profile_block, parse_plan_reply, resolve_agent_wrapper
SCENARIO_TYPES = ("resume_task", "answer_pending", "explore_interest", "prepare_upcoming")
DELIVERY_MODES = ("in_conversation", "notification", "agenda_item")
DEFAULT_SCENARIO_BY_KIND = {"follow_up": "resume_task", "interest_extend": "explore_interest"}
MAX_OPENER_CHARS = 300
MAX_NEXT_ACTION_CHARS = 200
MAX_PRECONDITIONS = 5
MAX_PRECONDITION_CHARS = 80
def linked_memory(candidate: dict) -> list[str]:
"""Workspace paths related to a topic, derived from paths + evidence."""
out: list[str] = []
raw = list(candidate.get("paths") or [])
evidence = str(candidate.get("evidence") or "").strip()
if evidence:
raw.append(evidence)
for path in raw:
rel = str(path or "").strip().split("#", 1)[0]
if rel and rel not in out:
out.append(rel)
return out
def fallback_card(candidate: dict) -> dict:
"""Deterministic card used when the LLM is absent or its output is unusable."""
kind = str(candidate.get("kind") or "interest_extend")
title = str(candidate.get("title") or "")
scenario = DEFAULT_SCENARIO_BY_KIND.get(kind, "explore_interest")
if kind == "follow_up":
opener = f"上次聊到「{title}」,好像还没收尾——要不要趁现在花几分钟往前推一步?"
next_action = "先回顾相关记录,确认卡点,然后给出下一步动作"
else:
opener = f"感觉你最近可能会想看看「{title}」,有空的话可以先从相关材料扫一眼。"
next_action = "快速浏览相关材料,判断值不值得深入"
memory = linked_memory(candidate)
if memory:
next_action = f"{next_action}(材料:{memory[0]}"
return {
"scenario_type": scenario,
"opener": opener,
"next_action": next_action,
"preconditions": [],
"delivery": "in_conversation",
}
def _clean_card(raw: dict, candidate: dict) -> dict:
"""Validate one LLM card; per-field fallback keeps the card always usable."""
base = fallback_card(candidate)
scenario = str(raw.get("scenario_type") or "").strip()
if scenario not in SCENARIO_TYPES:
scenario = base["scenario_type"]
opener = str(raw.get("opener") or "").strip()[:MAX_OPENER_CHARS]
if not opener:
opener = base["opener"]
next_action = str(raw.get("next_action") or "").strip()[:MAX_NEXT_ACTION_CHARS]
if not next_action:
next_action = base["next_action"]
preconditions = raw.get("preconditions")
if not isinstance(preconditions, list):
preconditions = []
preconditions = [str(item).strip()[:MAX_PRECONDITION_CHARS] for item in preconditions if str(item).strip()]
delivery = str(raw.get("delivery") or "").strip()
if delivery not in DELIVERY_MODES:
delivery = base["delivery"]
return {
"scenario_type": scenario,
"opener": opener,
"next_action": next_action,
"preconditions": preconditions[:MAX_PRECONDITIONS],
"delivery": delivery,
}
@R.register("proactive_plan_step")
class ProactivePlanStep(BaseStep):
"""Expand today's push candidates into scenario cards.
Zero LLM calls when there are no push candidates. At most
``max_plan_topics`` candidates are expanded; the overflow stays card-less
and the agenda step suppresses it with an explicit reason.
"""
def __init__(
self,
max_plan_topics: int = 6,
llm_timeout_seconds: float = 120,
profile_max_chars: int = 800,
skip_key: str = "proactive_skip",
**kwargs,
):
super().__init__(**kwargs)
self.max_plan_topics = max(int(max_plan_topics), 1)
self.llm_timeout_seconds = float(llm_timeout_seconds)
self.profile_max_chars = max(int(profile_max_chars), 0)
self.skip_key = skip_key
async def execute(self):
assert self.context is not None
if self.context.get(self.skip_key):
return passthrough_response(self, self.skip_key)
started = time.monotonic()
raw_state = self.context.get("proactive")
if not raw_state:
self.context.response.success = True
self.context.response.answer = "Skipped plan: no proactive extract state in context"
return self.context.response
state = ProactiveState.model_validate(raw_state)
if state.early_exit:
self.context.response.success = True
self.context.response.answer = f"Skipped plan: {state.early_exit}"
return self.context.response
candidates = [c for c in state.push_candidates if isinstance(c, dict) and c.get("id")]
if not candidates:
state.scenario_cards = []
self._store(state)
self.context.response.success = True
self.context.response.answer = "Plan: no push candidates, 0 cards, 0 LLM calls"
self.logger.info(f"[{self.name}] no push candidates; skipping")
return self.context.response
selected = candidates[: self.max_plan_topics]
self.logger.info(
f"[{self.name}] start candidates={len(candidates)} selected={len(selected)} "
f"max_plan_topics={self.max_plan_topics}",
)
cards_by_id = await self._llm_cards(state, selected)
cards: list[dict] = []
fallbacks = 0
for candidate in selected:
cid = str(candidate.get("id"))
raw_card = cards_by_id.get(cid)
if raw_card is None:
fallbacks += 1
card = _clean_card(raw_card, candidate) if raw_card is not None else fallback_card(candidate)
card = {
"topic_id": cid,
"title": str(candidate.get("title") or ""),
"kind": str(candidate.get("kind") or "interest_extend"),
**card,
"linked_memory": linked_memory(candidate),
}
cards.append(card)
state.scenario_cards = cards
state.duration_ms = state.duration_ms + int((time.monotonic() - started) * 1000)
self._store(state)
answer = f"Plan: {len(cards)} scenario card(s), fallback={fallbacks}, llm_calls={state.plan_llm_calls}"
self.context.response.success = True
self.context.response.answer = answer
self.logger.info(f"[{self.name}] finish {answer}")
return self.context.response
async def _llm_cards(self, state: ProactiveState, selected: list[dict]) -> dict[str, dict]:
"""One batched LLM call; returns candidate-id -> raw card dict."""
wrapper = resolve_agent_wrapper(self)
if wrapper is None:
self.logger.warning(f"[{self.name}] no agent_wrapper available; using fallback cards")
return {}
selected_ids = {str(c.get("id")) for c in selected}
ws = workspace_dir(self)
user_message = self.prompt_format(
"plan_user_message",
profile_block=self._profile_block(ws),
candidates_json=json.dumps(
[
{
"id": c.get("id"),
"title": c.get("title"),
"kind": c.get("kind"),
"reason": c.get("reason"),
"confidence": c.get("confidence"),
"first_seen": c.get("first_seen"),
"evidence": c.get("evidence"),
}
for c in selected
],
ensure_ascii=False,
),
)
system_prompt = self.prompt_format("plan_system_prompt")
state.plan_llm_calls += 1
try:
result = await asyncio.wait_for(
wrapper.reply(user_message, system_prompt=system_prompt),
timeout=self.llm_timeout_seconds,
)
except asyncio.TimeoutError:
self.logger.warning(f"[{self.name}] LLM reply timed out after {self.llm_timeout_seconds}s")
return {}
except Exception as e: # noqa: BLE001 - network/provider errors degrade to fallback cards
self.logger.warning(
f"[{self.name}] LLM reply failed ({type(e).__name__}: {e}); using fallback cards",
)
return {}
raw = agent_reply_result_text(result)
cards_by_id: dict[str, dict] = {}
for card in parse_plan_reply(raw):
cid = str(card.get("topic_id") or "").strip()
if cid in selected_ids and cid not in cards_by_id:
cards_by_id[cid] = card
if not cards_by_id:
self.logger.warning(f"[{self.name}] plan reply unusable; raw={raw[:200]!r}")
return cards_by_id
def _profile_block(self, ws) -> str:
"""Digest-personal profile sketch so openers respect user preferences."""
digest_dir = str(self.config_value("digest_dir"))
return load_personal_profile_block(ws, digest_dir, self.profile_max_chars)
def _store(self, state: ProactiveState) -> None:
assert self.context is not None
data = state.model_dump()
self.context["proactive"] = data
self.context.response.metadata["proactive"] = data