From c0bad3d30efc213c576259d587722ec02ddb2263 Mon Sep 17 00:00:00 2001 From: imrewce Date: Mon, 12 Oct 2026 15:36:14 +0800 Subject: [PATCH] refactor(proactive): expand topic kinds from two to four Give each kind one job: follow_up reports progress on the user's unfinished or still-tracked matters, interest_extend recommends knowledge-base content that grows the wiki network, reminder reviews the user's own schedule plus must-tell notices and key events from external resources, and new_idea proposes delegatable work grounded in capabilities the agent already has. Reminders are the priority kind and always enter today's agenda. - extract: two-stage round (batched recall, then one adjudication pass per kind), on by default and fail-open per kind - schedules: long-lived registry so an appointment noted weeks ago still fires; only evidence-anchored schedule_updates may move a due date - agenda/plan: separate reminder and discovery budgets, so a reminder-heavy day still surfaces discovery items Includes the fixes surfaced by the smoke runs: ISO coercion of unquoted YAML dates, per-entry salvage of a malformed truth source, refine move guards for notice-anchored reminders, no revert of an authoritative reschedule, re-anchored reminder due dates, resource-path anchors for schedule updates, pin reasons that name the real cause, empty verdicts read as a clean reply, and one registration per matter. --- reme/config/default.yaml | 40 + reme/schema/__init__.py | 2 + reme/schema/proactive.py | 169 ++- reme/steps/evolve/proactive/agenda.py | 115 +- reme/steps/evolve/proactive/agenda.yaml | 18 + reme/steps/evolve/proactive/extract.py | 511 ++++++++- reme/steps/evolve/proactive/extract.yaml | 682 ++++++++++-- reme/steps/evolve/proactive/finish.py | 2 +- reme/steps/evolve/proactive/plan.py | 74 +- reme/steps/evolve/proactive/plan.yaml | 49 +- reme/steps/evolve/proactive/proactive.py | 31 +- reme/steps/evolve/proactive/refine.py | 196 ++++ reme/steps/evolve/proactive/schedules.py | 1008 ++++++++++++++++++ reme/steps/evolve/proactive/topics.py | 116 ++- reme/steps/evolve/proactive/utils.py | 225 +++- tests/unit/test_proactive_refresh.py | 1218 +++++++++++++++++++++- 16 files changed, 4231 insertions(+), 225 deletions(-) create mode 100644 reme/steps/evolve/proactive/refine.py create mode 100644 reme/steps/evolve/proactive/schedules.py diff --git a/reme/config/default.yaml b/reme/config/default.yaml index 8da90d01..a29f4901 100644 --- a/reme/config/default.yaml +++ b/reme/config/default.yaml @@ -100,13 +100,33 @@ jobs: llm_timeout_seconds: 300 max_chars_per_file: 60000 max_total_chars: 300000 + profile_max_chars: 1500 + capability_max_chars: 1200 + extends_enabled: true + reminders_enabled: true + new_ideas_enabled: true + max_follow_ups: 5 + max_extends: 5 + max_new_ideas: 3 + reminder_lead_days: 3 + resources_enabled: true + resource_max_chars: 40000 + max_notice_reminders: 3 + refine_enabled: true + refine_timeout_seconds: 150 - backend: proactive_topics_step known_threshold: 0.85 known_threshold_calibrated_for: text-embedding-v4@1024 min_push_confidence: 0.5 + min_reminder_confidence: 0.3 max_topics: 10 + schedule_retention_days: 180 + max_schedules: 80 + max_schedule_history: 8 - backend: proactive_plan_step + max_reminder_cards: 8 - backend: proactive_agenda_step + min_discovery_slots: 2 - backend: proactive_finish_step file_catalog: proactive @@ -122,13 +142,33 @@ jobs: llm_timeout_seconds: 300 max_chars_per_file: 60000 max_total_chars: 300000 + profile_max_chars: 1500 + capability_max_chars: 1200 + extends_enabled: true + reminders_enabled: true + new_ideas_enabled: true + max_follow_ups: 5 + max_extends: 5 + max_new_ideas: 3 + reminder_lead_days: 3 + resources_enabled: true + resource_max_chars: 40000 + max_notice_reminders: 3 + refine_enabled: true + refine_timeout_seconds: 150 - backend: proactive_topics_step known_threshold: 0.85 known_threshold_calibrated_for: text-embedding-v4@1024 min_push_confidence: 0.5 + min_reminder_confidence: 0.3 max_topics: 10 + schedule_retention_days: 180 + max_schedules: 80 + max_schedule_history: 8 - backend: proactive_plan_step + max_reminder_cards: 8 - backend: proactive_agenda_step + min_discovery_slots: 2 - backend: proactive_finish_step file_catalog: proactive diff --git a/reme/schema/__init__.py b/reme/schema/__init__.py index a6b16834..5dd46c5a 100644 --- a/reme/schema/__init__.py +++ b/reme/schema/__init__.py @@ -9,6 +9,7 @@ from .dream import ( ) from .proactive import ( ProactiveResult, + ProactiveSchedule, ProactiveState, ProactiveStateFile, ProactiveTopic, @@ -42,6 +43,7 @@ __all__ = [ "IntegrateOutcome", "JobConfig", "ProactiveResult", + "ProactiveSchedule", "ProactiveState", "ProactiveStateFile", "ProactiveTopic", diff --git a/reme/schema/proactive.py b/reme/schema/proactive.py index 6b74c2e0..06426304 100644 --- a/reme/schema/proactive.py +++ b/reme/schema/proactive.py @@ -6,9 +6,28 @@ validated structurally by ``parse_extract_reply`` instead of a model here. See ``PROACTIVE_SPEC.md`` sections F1/A2 for the full contracts. """ +import datetime as dt + from pydantic import BaseModel, ConfigDict, Field, field_validator -TOPIC_KINDS = ("follow_up", "interest_extend") +# Topic kinds, ordered by proactive priority: ``reminder`` outranks the rest +# and pins today's agenda, ``interest_extend`` is the lowest-priority +# discovery kind. ``KIND_PRIORITY`` is the single ordering source used by +# ``sort_topics`` and by the plan/agenda steps. +TOPIC_KINDS = ("reminder", "follow_up", "new_idea", "interest_extend") +KIND_PRIORITY = {kind: index for index, kind in enumerate(TOPIC_KINDS)} +DEFAULT_KIND = "interest_extend" + +# Reminder registry (``daily/_proactive.yaml: schedules``) vocabularies. The +# registry outlives ``open_topics`` because a schedule mentioned weeks ago +# still has to fire on its due date. +SCHEDULE_ACTIONS = ("add", "reschedule", "confirm", "done", "cancel") +SCHEDULE_STATUSES = ("open", "done", "cancelled") +RECURRENCES = ("", "daily", "weekly", "monthly", "yearly") +REMINDER_TRIGGERS = ("overdue", "due_today", "due_soon", "conflict") + +# Keys inside the untyped ``history`` audit trail that hold a date/datetime. +HISTORY_WHEN_KEYS = ("at", "from", "to") def clamp_confidence(value) -> float: @@ -19,6 +38,29 @@ def clamp_confidence(value) -> float: return 0.5 +def iso_text(value) -> str: + """Coerce a YAML-native scalar into the ISO text the string fields expect. + + ``yaml.safe_load`` reads an unquoted ``2026-10-11`` as ``datetime.date`` and + ``2026-10-11T20:00`` as ``datetime.datetime``. The truth source is a + hand-editable file and the schedule registry is meant to outlive topics by + months, so a bare date must never invalidate an entry: date and time + scalars are rendered back to ISO text, anything else falls back to ``str``. + """ + if value is None: + return "" + if isinstance(value, (dt.datetime, dt.date, dt.time)): + return value.isoformat() + if isinstance(value, str): + return value + return str(value) + + +def kind_priority(kind) -> int: + """Priority rank of a topic kind; unknown kinds sort last.""" + return KIND_PRIORITY.get(str(kind or "").strip(), len(TOPIC_KINDS)) + + class ProactiveTopic(BaseModel): """One proactive topic; every field has a default so v1 files parse seamlessly. @@ -31,24 +73,33 @@ class ProactiveTopic(BaseModel): id: str = "" title: str = "" reason: str = "" - kind: str = "interest_extend" + kind: str = DEFAULT_KIND confidence: float = 0.5 first_seen: str = "" last_evidence_at: str = "" evidence: str = "" paths: list[str] = Field(default_factory=list) + # Reminder-only links: ``schedule_id`` points at the long-lived registry + # entry, ``due_at`` is the ISO date/datetime the reminder is about. + schedule_id: str = "" + due_at: str = "" @field_validator("kind", mode="before") @classmethod def _fallback_kind(cls, value): text = str(value or "").strip() - return text if text in TOPIC_KINDS else "interest_extend" + return text if text in TOPIC_KINDS else DEFAULT_KIND @field_validator("confidence", mode="before") @classmethod def _fallback_confidence(cls, value): return clamp_confidence(value) + @field_validator("first_seen", "last_evidence_at", "due_at", mode="before") + @classmethod + def _iso_when(cls, value): + return iso_text(value) + @field_validator("paths", mode="before") @classmethod def _clean_str_list(cls, value): @@ -57,6 +108,86 @@ class ProactiveTopic(BaseModel): return [str(item).strip() for item in value if str(item).strip()] +class ProactiveSchedule(BaseModel): + """One personal schedule/appointment in the long-lived reminder registry. + + The registry is a separate truth-source section from ``open_topics``: a + schedule survives topic trimming (``carry_forward_days``) until its due + date has passed by ``schedule_retention_days``, and every change the + pipeline observes is appended to ``history`` so a reschedule or a + cancellation stays auditable long after the originating daily note left + the extract scan window. + """ + + model_config = ConfigDict(extra="ignore") + + id: str = "" + title: str = "" + detail: str = "" + due_at: str = "" + due_end_at: str = "" + recurrence: str = "" + # 0 = "not specified": the reminder scan then applies the step-level + # ``reminder_lead_days`` default instead of a per-schedule value. + lead_days: int = 0 + status: str = "open" + confidence: float = 0.5 + first_seen: str = "" + last_seen_at: str = "" + evidence: str = "" + paths: list[str] = Field(default_factory=list) + history: list[dict] = Field(default_factory=list) + + @field_validator("status", mode="before") + @classmethod + def _fallback_status(cls, value): + text = str(value or "").strip() + return text if text in SCHEDULE_STATUSES else "open" + + @field_validator("recurrence", mode="before") + @classmethod + def _fallback_recurrence(cls, value): + text = str(value or "").strip().lower() + return text if text in RECURRENCES else "" + + @field_validator("due_at", "due_end_at", "first_seen", "last_seen_at", mode="before") + @classmethod + def _iso_when(cls, value): + return iso_text(value) + + @field_validator("confidence", mode="before") + @classmethod + def _fallback_confidence(cls, value): + return clamp_confidence(value) + + @field_validator("lead_days", mode="before") + @classmethod + def _fallback_lead_days(cls, value): + try: + return max(0, min(int(value), 365)) + except (TypeError, ValueError): + return 0 + + @field_validator("paths", mode="before") + @classmethod + def _clean_str_list(cls, value): + if not isinstance(value, list): + return [] + return [str(item).strip() for item in value if str(item).strip()] + + @field_validator("history", mode="before") + @classmethod + def _clean_history(cls, value): + if not isinstance(value, list): + return [] + cleaned = [] + for item in value: + if not isinstance(item, dict): + continue + cleaned.append({k: iso_text(v) if k in HISTORY_WHEN_KEYS else v for k, v in item.items()}) + return cleaned + + class ProactiveState(BaseModel): """Chain-shared proactive context state (``context['proactive']``). @@ -77,20 +208,49 @@ class ProactiveState(BaseModel): carry_forward_days: int = 14 changed_paths: list[str] = Field(default_factory=list) changed_mtimes: dict[str, float] = Field(default_factory=dict) + # External source records (``resource/``) scanned for user-facing notices. + # They feed the reminder branch only, but share ``changed_mtimes`` and the + # proactive catalog watermark with the daily material set, so a notice that + # lands on a day without any conversation still triggers a round. + resource_scanned: int = 0 + resource_skipped: int = 0 + resource_changed: list[str] = Field(default_factory=list) carry_forward_count: int = 0 carry_forward_prompt: list[ProactiveTopic] = Field(default_factory=list) llm_calls: int = 0 follow_ups: list[dict] = Field(default_factory=list) extends: list[dict] = Field(default_factory=list) + reminders: list[dict] = Field(default_factory=list) + new_ideas: list[dict] = Field(default_factory=list) updates: list[dict] = Field(default_factory=list) + schedule_updates: list[dict] = Field(default_factory=list) + # Stage-2 adjudication (precision pass over stage-1 candidates). Every + # failure is fail-open, so ``refine_degraded`` counts the kinds whose + # stage-1 candidates were kept untouched. + refine_calls: int = 0 + refine_degraded: int = 0 + refine_dropped: list[dict] = Field(default_factory=list) + refine_moved: list[dict] = Field(default_factory=list) early_exit: str = "" + # ``reminder_only`` marks a clock-driven round with no new material: the + # chain stays alive (no ``early_exit``) but made zero LLM calls. + reminder_only: bool = False + due_schedules: list[dict] = Field(default_factory=list) updates_applied: int = 0 updates_resolved: int = 0 + schedules_added: int = 0 + schedules_updated: int = 0 + schedules_closed: int = 0 + # Reminders whose published due value was re-read from the registry because + # this round's ``schedule_updates`` moved it after the clock scan. + reminders_reanchored: int = 0 + schedules_out: list[dict] = Field(default_factory=list) candidates_in: int = 0 candidates: list[dict] = Field(default_factory=list) dropped_missing: int = 0 dropped_duplicate: int = 0 dropped_known: int = 0 + dropped_over_cap: int = 0 topics_out: list[dict] = Field(default_factory=list) push_candidates: list[dict] = Field(default_factory=list) scenario_cards: list[dict] = Field(default_factory=list) @@ -110,6 +270,8 @@ class ProactiveStateFile(BaseModel): ``resolved`` tombstones carry ``first_seen`` so a resurrected topic can keep its original age anchor (F2.4 reopen channel). + ``schedules`` is the long-lived reminder registry; it is optional so + files written before the reminder branch existed still load. """ model_config = ConfigDict(extra="ignore") @@ -117,6 +279,7 @@ class ProactiveStateFile(BaseModel): version: int = 1 open_topics: list[ProactiveTopic] = Field(default_factory=list) resolved: list[dict] = Field(default_factory=list) + schedules: list[ProactiveSchedule] = Field(default_factory=list) class ProactiveResult(BaseModel): diff --git a/reme/steps/evolve/proactive/agenda.py b/reme/steps/evolve/proactive/agenda.py index bd15becb..5f57d0b4 100644 --- a/reme/steps/evolve/proactive/agenda.py +++ b/reme/steps/evolve/proactive/agenda.py @@ -14,6 +14,11 @@ call; LLM absent/timed out/unusable -> freshness/confidence order fallback; after validation the agenda is never empty while candidates exist, so the push semantics computed by the topics step never flip. The step also owns the final interests.yaml render, extending the v2 shape with ``agenda``/``suppressed``. + +Reminder priority is enforced here, not negotiated: every reminder card is +pinned to the front of the agenda (nearest due date first) before the editor +LLM sees anything, and only the slots the reminders leave are up for +selection. Pinned items are never suppressed and never counted as "silence". """ import asyncio @@ -37,6 +42,32 @@ from .utils import ( ) FALLBACK_ORDER_REASON = "deterministic fallback: freshness/confidence order" +PINNED_REMINDER_REASONS = { + "due": "reminder pinned: a due schedule always enters today's agenda", + "new": "reminder pinned: a new reminder always enters today's agenda", + "open": "reminder pinned: an open reminder always enters today's agenda", +} + + +def _pin_reason(candidate: dict, day: str = "") -> str: + """Order reason for a pinned reminder, naming the due date when known. + + Every reminder is pinned, but not for the same reason: the audit trail + distinguishes a schedule the clock shows as due/overdue from one this round + merely found (whose due date may still be weeks away), so a reader can tell + a calendar fact from a freshness rule. + """ + due = str(candidate.get("due_at") or "").strip() + due_day, day = due[:10], str(day or "").strip()[:10] + first_seen = str(candidate.get("first_seen") or "").strip()[:10] + if due_day and day and due_day <= day: + cause = "due" + elif first_seen and day and first_seen >= day: + cause = "new" + else: + cause = "open" + base = PINNED_REMINDER_REASONS[cause] + return f"{base} (due {due})" if due else base @R.register("proactive_agenda_step") @@ -51,6 +82,7 @@ class ProactiveAgendaStep(BaseStep): def __init__( self, max_agenda_items: int = 6, + min_discovery_slots: int = 2, llm_timeout_seconds: float = 120, profile_rel_path: str = "profile.md", profile_max_chars: int = 2500, @@ -59,6 +91,7 @@ class ProactiveAgendaStep(BaseStep): ): super().__init__(**kwargs) self.max_agenda_items = max(int(max_agenda_items), 1) + self.min_discovery_slots = max(int(min_discovery_slots), 0) self.llm_timeout_seconds = float(llm_timeout_seconds) self.profile_rel_path = str(profile_rel_path or "") self.profile_max_chars = max(int(profile_max_chars), 0) @@ -91,27 +124,64 @@ class ProactiveAgendaStep(BaseStep): cards_by_id = {str(c.get("topic_id") or ""): c for c in state.scenario_cards if isinstance(c, dict)} carded = [c for c in candidates if str(c.get("id")) in cards_by_id] over_budget = [c for c in candidates if str(c.get("id")) not in cards_by_id] + # Reminders are pinned: every reminder - a due schedule or a freshly + # found one - always reaches today's agenda and is never traded away for + # narrative coherence. What the editor LLM gets + # to choose is the remaining (discovery) part of the agenda, inside the + # slots the pinned reminders leave - but never fewer than + # ``min_discovery_slots``, so a reminder-heavy day still surfaces the + # discovery kinds instead of collapsing into a plain calendar. On such a + # day the agenda may exceed ``max_agenda_items``: pinned reminders are + # additive by design, and dropping either side would break a guarantee. + pinned = [c for c in carded if str(c.get("kind")) == "reminder"] + optional = [c for c in carded if str(c.get("kind")) != "reminder"] + pinned_ids = [str(c.get("id")) for c in pinned] + slots = max(self.max_agenda_items - len(pinned_ids), self.min_discovery_slots) self.logger.info( f"[{self.name}] start candidates={len(candidates)} carded={len(carded)} " - f"over_budget={len(over_budget)} max_agenda_items={self.max_agenda_items}", + f"over_budget={len(over_budget)} " + f"pinned_reminders={len(pinned_ids)} slots={slots} " + f"max_agenda_items={self.max_agenda_items}", ) - if not carded: - agenda_ids, order_reasons, suppressed_reasons = [], {}, {} - elif len(carded) == 1: - agenda_ids = [str(carded[0].get("id"))] - order_reasons = {agenda_ids[0]: "single push candidate; auto-agenda without LLM"} + if not optional: + selected_ids, order_reasons, suppressed_reasons = [], {}, {} + elif slots == 0: + selected_ids, order_reasons = [], {} + suppressed_reasons = { + str(c.get("id")): f"agenda capacity reserved for {len(pinned_ids)} reminder(s)" for c in optional + } + elif len(optional) == 1: + selected_ids = [str(optional[0].get("id"))] + order_reasons = {selected_ids[0]: "single push candidate; auto-agenda without LLM"} suppressed_reasons = {} else: - agenda_ids, order_reasons, suppressed_reasons = await self._llm_agenda(state, ws, carded, cards_by_id) + selected_ids, order_reasons, suppressed_reasons = await self._llm_agenda( + state, + ws, + optional, + cards_by_id, + slots, + ) + for cid in pinned_ids: + order_reasons.setdefault( + cid, + _pin_reason(next((c for c in pinned if str(c.get("id")) == cid), {}), state.date), + ) + agenda_ids = pinned_ids + [cid for cid in selected_ids if cid not in pinned_ids] suppressed: list[dict] = [] for candidate in over_budget: + is_reminder = str(candidate.get("kind")) == "reminder" suppressed.append( { "topic_id": str(candidate.get("id")), "title": str(candidate.get("title") or ""), - "reason": "over plan budget: not expanded into a scenario card", + "reason": ( + "over reminder card budget: not expanded into a scenario card" + if is_reminder + else "over plan budget: not expanded into a scenario card" + ), }, ) for candidate in carded: @@ -136,12 +206,15 @@ class ProactiveAgendaStep(BaseStep): { "topic_id": cid, "title": str(candidate.get("title") or ""), + "kind": str(candidate.get("kind") or ""), "scenario_type": str(card.get("scenario_type") or ""), "opener": str(card.get("opener") or ""), "next_action": str(card.get("next_action") or ""), "preconditions": list(card.get("preconditions") or []), "delivery": str(card.get("delivery") or "in_conversation"), "linked_memory": list(card.get("linked_memory") or []), + **({"due_at": str(candidate.get("due_at"))} if candidate.get("due_at") else {}), + **({"schedule_id": str(candidate.get("schedule_id"))} if candidate.get("schedule_id") else {}), "order_reason": order_reasons.get(cid) or "", }, ) @@ -182,13 +255,19 @@ class ProactiveAgendaStep(BaseStep): ws, carded: list[dict], cards_by_id: dict[str, dict], + slots: int, ) -> tuple[list[str], dict[str, str], dict[str, str]]: - """One list-wise LLM call; returns (agenda ids, order reasons, suppressed reasons).""" + """One list-wise LLM call over the non-pinned cards. + + Returns ``(agenda ids, order reasons, suppressed reasons)``. ``slots`` + is what the pinned reminders left of ``max_agenda_items``, and it is + both the cap told to the model and the hard truncation limit. + """ carded_ids = [str(c.get("id")) for c in carded] wrapper = resolve_agent_wrapper(self) if wrapper is None: self.logger.warning(f"[{self.name}] no agent_wrapper available; deterministic fallback agenda") - return self._fallback_agenda(carded_ids) + return self._fallback_agenda(carded_ids, slots) cards_json = json.dumps( [ @@ -207,12 +286,12 @@ class ProactiveAgendaStep(BaseStep): ) user_message = self.prompt_format( "agenda_user_message", - max_agenda_items=self.max_agenda_items, + max_agenda_items=slots, cards_json=cards_json, changed_files_json=json.dumps(list(dict.fromkeys(state.changed_paths)), ensure_ascii=False), profile_block=self._profile_block(ws), ) - system_prompt = self.prompt_format("agenda_system_prompt", max_agenda_items=self.max_agenda_items) + system_prompt = self.prompt_format("agenda_system_prompt", max_agenda_items=slots) state.plan_llm_calls += 1 try: result = await asyncio.wait_for( @@ -221,12 +300,12 @@ class ProactiveAgendaStep(BaseStep): ) except asyncio.TimeoutError: self.logger.warning(f"[{self.name}] LLM reply timed out after {self.llm_timeout_seconds}s") - return self._fallback_agenda(carded_ids) + return self._fallback_agenda(carded_ids, slots) except Exception as e: # noqa: BLE001 - network/provider errors degrade to fallback agenda self.logger.warning( f"[{self.name}] LLM reply failed ({type(e).__name__}: {e}); using fallback agenda", ) - return self._fallback_agenda(carded_ids) + return self._fallback_agenda(carded_ids, slots) raw = agent_reply_result_text(result) agenda_raw, suppressed_raw = parse_agenda_reply(raw) @@ -236,7 +315,7 @@ class ProactiveAgendaStep(BaseStep): cid = str(item.get("topic_id") or "").strip() if cid in allowed and cid not in agenda_ids: agenda_ids.append(cid) - if len(agenda_ids) >= self.max_agenda_items: + if len(agenda_ids) >= slots: break order_reasons = { str(item.get("topic_id") or "").strip(): str(item.get("order_reason") or "").strip() @@ -250,12 +329,12 @@ class ProactiveAgendaStep(BaseStep): } if not agenda_ids: self.logger.warning(f"[{self.name}] agenda reply unusable; raw={raw[:200]!r}") - return self._fallback_agenda(carded_ids) + return self._fallback_agenda(carded_ids, slots) return agenda_ids, order_reasons, suppressed_reasons - def _fallback_agenda(self, carded_ids: list[str]) -> tuple[list[str], dict[str, str], dict[str, str]]: + def _fallback_agenda(self, carded_ids: list[str], slots: int) -> tuple[list[str], dict[str, str], dict[str, str]]: """Freshness/confidence head when generation is unavailable (cards keep sort order).""" - agenda_ids = carded_ids[: self.max_agenda_items] + agenda_ids = carded_ids[: max(int(slots), 0)] order_reasons = {cid: FALLBACK_ORDER_REASON for cid in agenda_ids} suppressed_reasons = {cid: "capacity (deterministic fallback)" for cid in carded_ids if cid not in agenda_ids} return agenda_ids, order_reasons, suppressed_reasons diff --git a/reme/steps/evolve/proactive/agenda.yaml b/reme/steps/evolve/proactive/agenda.yaml index e67283fd..caf38f95 100644 --- a/reme/steps/evolve/proactive/agenda.yaml +++ b/reme/steps/evolve/proactive/agenda.yaml @@ -4,10 +4,16 @@ agenda_system_prompt: | candidates), the files that changed today (a proxy for what the user is focused on right now) and an optional user profile. + Reminder cards are NOT in your input: a due schedule is pinned to today's + agenda by the pipeline and cannot be traded away. You are editing the + remaining discovery slots. + Decide which cards enter today's agenda, in which order, and which are held back. The number of agenda items is YOUR call: judge it from content richness and redundancy - anything from 1 up to at most {max_agenda_items}. Fewer, stronger items beat many weak ones. + When {max_agenda_items} is 0 the pinned reminders already fill today's + agenda: suppress every card with that reason instead of forcing one in. ## Editorial rules @@ -21,6 +27,10 @@ agenda_system_prompt: | - Auditable silence: EVERY candidate topic_id must appear exactly once, either in `agenda` or in `suppressed` (with a short reason). Never drop a candidate without a word. + - Quote every YAML string containing punctuation such as `:` - an unquoted + `order_reason` with a colon in it makes the whole reply unparseable, and + the agenda then degrades to a deterministic order. Keep both reasons to one + short sentence. ## Output format @@ -38,9 +48,14 @@ agenda_system_prompt_zh: | 你是个人记忆助手当日主动议程的编辑。输入是场景卡(由推送候选扩展而来)、 今天发生变化的文件(代表用户当前关注点)以及可选的用户画像。 + 提醒类(reminder)场景卡不在你的输入里:到期日程已由流程固定进入当日议程, + 不可被替换或压制。你编辑的是剩下的发现类名额。 + 决定哪些卡片进入今日议程、按什么顺序,哪些暂缓。议程条数由你自行裁决: 依据内容丰富度和重复程度决定,最少 1 条,至多不超过 {max_agenda_items} 条。 少而强胜过多而弱。 + 当 {max_agenda_items} 为 0 时,说明固定提醒已占满今日议程:请把每张卡都 + 放入 suppressed 并写明该原因,不要硬塞。 ## 编辑规则 @@ -52,6 +67,9 @@ agenda_system_prompt_zh: | 压制它并在 reason 中说明。 - 沉默可审计:每个候选 topic_id 必须恰好出现一次,要么在 agenda,要么在 suppressed(附简短理由)。禁止不声不响地丢弃任何候选。 + - 包含 `:` 等标点的 YAML 字符串请加引号——`order_reason` 里出现未加引号的 + 冒号会让整份回复无法解析,议程随之退化为确定性排序。两类理由都请控制在一 + 句话以内。 ## 输出格式 diff --git a/reme/steps/evolve/proactive/extract.py b/reme/steps/evolve/proactive/extract.py index 3c8466b7..e873e158 100644 --- a/reme/steps/evolve/proactive/extract.py +++ b/reme/steps/evolve/proactive/extract.py @@ -1,4 +1,25 @@ -"""Proactive refresh extract step: material scan + follow_ups/extends/updates (F2.0-F2.3).""" +"""Proactive refresh extract step: material scan + four proposal kinds (F2.0-F2.3). + +The step owns discovery. It reads the changed daily notes plus three cheap +read-only views - the carry-forward topics, the long-lived schedule registry +and the digest capability inventory - together with the current business +clock, and asks for one sectioned reply covering four proposal kinds: + +``follow_ups`` open loops: propose fetching the latest status and reporting; +``extends`` knowledge-base recommendations: propose a wiki node to expand; +``reminders`` personal schedule review: propose due/conflicting reminders, + plus must-tell notices found in changed ``resource/`` records; +``new_ideas`` delegatable work the user has not thought of: propose only. + +Only the reminder branch reads the ``resource/`` layer, which is the reserved +hook for external source interfaces: whatever lands there becomes eligible for +the schedule review without any further change to the chain. + +Reminders additionally have a deterministic, clock-driven path +(``reminder_candidates_from_view``) so a due schedule still reaches today's +agenda when there is no new material, no agent wrapper, or an unusable reply. +Nothing here executes a proposal; the output stays a proposal end to end. +""" import asyncio import json @@ -9,25 +30,55 @@ from .._evolve import agent_reply_result_text, passthrough_response from ....components import R from ....schema import ProactiveState from ..dream.utils import daily_dir, pack_paths, today, workspace_dir +from .refine import apply_verdicts, candidate_payload, candidates_by_kind, parse_refine_reply +from .schedules import ( + DUE_TRIGGERS, + cap_notice_reminders, + clean_reminder, + clean_schedule_update, + merge_reminders, + reminder_candidates_from_view, + schedule_prompt_entries, + schedule_view, +) from .utils import ( clean_candidate, + current_now, + load_capability_block, load_carry_forward, load_personal_profile_block, load_state, parse_extract_reply, resolve_agent_wrapper, scan_material_daily, + scan_material_resource, +) + +DUE_SCHEDULE_KEYS = ( + "id", + "title", + "status", + "due_at", + "next_due", + "due_in_days", + "trigger", + "conflict_with", ) @R.register("proactive_extract_step") class ProactiveExtractStep(BaseStep): - """Scan the proactive material set and extract follow-ups, extends and updates. + """Scan the proactive material set and extract the four proposal kinds. Owns the ``proactive`` catalog watermark (never touches the dream catalog) and the daily LLM budget. Short-circuits via ``context[skip_key]`` on - busy/budget/timeout per F4.3; early-exits with zero LLM calls when no new - evidence exists (F2.0). + budget/timeout per F4.3; early-exits with zero LLM calls when no new + evidence exists (F2.0) - unless the clock produced due reminders, which + are delivered without any LLM call. + + Per-kind ceilings keep discovery proportional (``max_follow_ups``, + ``max_extends``, ``max_new_ideas``); reminders are deliberately uncapped + because they are bounded by the registry itself and pin today's agenda. """ def __init__( @@ -39,7 +90,21 @@ class ProactiveExtractStep(BaseStep): max_chars_per_file: int = 60000, max_total_chars: int = 300000, profile_max_chars: int = 1500, + capability_max_chars: int = 1200, extends_enabled: bool = True, + reminders_enabled: bool = True, + new_ideas_enabled: bool = True, + max_follow_ups: int = 5, + max_extends: int = 5, + max_new_ideas: int = 3, + reminder_lead_days: int = 3, + schedule_closed_lookback_days: int = 7, + resources_enabled: bool = True, + resource_scan_days: int = 0, + resource_max_chars: int = 40000, + max_notice_reminders: int = 3, + refine_enabled: bool = True, + refine_timeout_seconds: float = 90, skip_key: str = "proactive_skip", **kwargs, ): @@ -51,9 +116,26 @@ class ProactiveExtractStep(BaseStep): self.max_chars_per_file = max(int(max_chars_per_file), 1000) self.max_total_chars = max(int(max_total_chars), 0) self.profile_max_chars = max(int(profile_max_chars), 0) + self.capability_max_chars = max(int(capability_max_chars), 0) self.extends_enabled = bool(extends_enabled) + self.reminders_enabled = bool(reminders_enabled) + self.new_ideas_enabled = bool(new_ideas_enabled) + self.max_follow_ups = max(int(max_follow_ups), 0) + self.max_extends = max(int(max_extends), 0) + self.max_new_ideas = max(int(max_new_ideas), 0) + self.reminder_lead_days = max(int(reminder_lead_days), 0) + self.schedule_closed_lookback_days = max(int(schedule_closed_lookback_days), 0) + self.resources_enabled = bool(resources_enabled) + # 0 inherits ``scan_days``: the notice window normally matches the + # conversation window, but can be widened on its own. + self.resource_scan_days = max(int(resource_scan_days), 0) + self.resource_max_chars = max(int(resource_max_chars), 0) + self.max_notice_reminders = max(int(max_notice_reminders), 0) + self.refine_enabled = bool(refine_enabled) + self.refine_timeout_seconds = float(refine_timeout_seconds) self.skip_key = skip_key + # pylint: disable=too-many-return-statements async def execute(self): assert self.context is not None if self.context.get(self.skip_key): @@ -71,10 +153,27 @@ class ProactiveExtractStep(BaseStep): scan_days=self.scan_days, carry_forward_days=self.carry_forward_days, ) - self.logger.info(f"[{self.name}] start date={day} scan_days={self.scan_days} extends={self.extends_enabled}") + self.logger.info( + f"[{self.name}] start date={day} scan_days={self.scan_days} extends={self.extends_enabled} " + f"reminders={self.reminders_enabled} new_ideas={self.new_ideas_enabled}", + ) - # 1) Material set M (F2.0) - all cheap, before any LLM work. + # 1) Material set M (F2.0) - all cheap, before any LLM work. Daily + # notes feed every branch. The ``resource/`` layer feeds the reminder + # branch only (external notices the user must be told about), so it is + # scanned only when reminders are active. m_daily = scan_material_daily(ws, day, daily, self.scan_days) + notices = self.reminders_enabled and self.resources_enabled + if notices: + m_resource, resource_skipped = scan_material_resource( + ws, + day, + self.resource_scan_days or self.scan_days, + str(self.config_value("resource_dir")), + ) + else: + m_resource, resource_skipped = [], 0 + state.resource_scanned, state.resource_skipped = len(m_resource), resource_skipped # 2) Truth source + carry-forward (F1.3/F1.4). state_file, needs_bootstrap = load_state(ws, daily) @@ -90,57 +189,120 @@ class ProactiveExtractStep(BaseStep): state.carry_forward_count = len(carry_all) state.carry_forward_prompt = carry_prompt - # 3) Change detection against the proactive catalog watermark. - # Only daily notes are material: fresh resource uploads already flow - # into daily notes via auto_resource, so no separate resource scan. + # 3) Change detection against the proactive catalog watermark. Both + # material sets share one watermark, so an external notice arriving on + # a day without any conversation still triggers a round. existing = {} - for rel in m_daily: + for rel in (*m_daily, *m_resource): try: existing[rel] = (ws / rel).stat().st_mtime except OSError as e: self.logger.error(f"[{self.name}] stat failed on {rel}: {e}") nodes = await self.file_catalog.get_nodes() indexed = {n.path: n.st_mtime for n in nodes} - state.changed_paths = [rel for rel, mt in existing.items() if indexed.get(rel) != mt] + changed = [rel for rel, mt in existing.items() if indexed.get(rel) != mt] + daily_set = set(m_daily) + state.changed_paths = [rel for rel in changed if rel in daily_set] + state.resource_changed = [rel for rel in changed if rel not in daily_set] # mtime snapshot of what this round actually read; finish only # checkpoints paths whose mtime still matches, so files modified while # the LLM calls run stay "changed" for the next round (audit item 3). - state.changed_mtimes = {rel: existing[rel] for rel in state.changed_paths} + state.changed_mtimes = {rel: existing[rel] for rel in changed} self.logger.info( - f"[{self.name}] material daily={len(m_daily)} " - f"changed={len(state.changed_paths)} carry_forward={len(carry_all)}", + f"[{self.name}] material daily={len(m_daily)} resource={len(m_resource)} " + f"resource_skipped={resource_skipped} changed={len(state.changed_paths)} " + f"notices_changed={len(state.resource_changed)} carry_forward={len(carry_all)}", ) - # 4) Zero-consumption early exit (A4 row 1). - if not state.changed_paths: + # 4) Clock + schedule registry scan (branch C). Deterministic and free, + # so it runs before any LLM decision: a due schedule must reach today's + # agenda even when there is no new material, no agent wrapper, or an + # unusable reply. + now_dt = current_now(self) + view = schedule_view(state_file.schedules, now_dt, self.reminder_lead_days) if self.reminders_enabled else [] + schedules_by_id = {str(entry.get("id") or ""): entry for entry in view if entry.get("id")} + seeded = reminder_candidates_from_view(view, day) + state.due_schedules = [ + {key: entry.get(key) for key in DUE_SCHEDULE_KEYS} for entry in view if entry.get("trigger") in DUE_TRIGGERS + ] + if seeded: + self.logger.info( + f"[{self.name}] clock scan: registry={len(state_file.schedules)} " + f"due={len(state.due_schedules)} seeded_reminders={len(seeded)} now={now_dt.isoformat()}", + ) + + # 5) Zero-consumption early exit (A4 row 1) - unless the clock alone + # already produced reminders, which need neither material nor an LLM. + if not state.changed_paths and not state.resource_changed: + if seeded: + return self._clock_only(state, started, seeded, "Skipped LLM: no new evidence") state.early_exit = "no_new_evidence" return self._finish(state, started, "Skipped: no new evidence; 0 LLM calls") - # 5) LLM channel (F4.4): structurally one reply per round plus at most + # 6) LLM channel (F4.4): structurally one reply per round plus at most # one parse-failure retry, so no persistent budget is needed (v5 R5). wrapper = resolve_agent_wrapper(self) if wrapper is None: + if seeded: + return self._clock_only(state, started, seeded, "Degraded: no agent_wrapper configured") state.early_exit = "no_agent_wrapper" self.logger.warning(f"[{self.name}] no agent_wrapper available; skipping round") return self._finish(state, started, "Skipped: no agent_wrapper configured") - # 6) One LLM call with sectioned output (A3); retry once on parse failure. - # meta is None when this round must NOT checkpoint (timeout, or two - # unusable replies): the material stays "changed" for the next round - # instead of being silently consumed on output that can never yield - # topics (audit #1). - meta = await self._extract_with_retry(wrapper, state, ws, day, daily) + # 7) One LLM call with sectioned output (A3); retry once on parse failure. + # meta is None when this round produced no usable output (timeout, or + # two unusable replies): the material stays "changed" for the next + # round instead of being silently consumed on output that can never + # yield topics (audit #1). + meta, failure = await self._extract_with_retry(wrapper, state, ws, day, daily, now_dt, view) if meta is None: + if seeded: + return self._clock_only(state, started, seeded, f"Degraded: {failure}") + self.context[self.skip_key] = {"reason": failure} self._store(state) return passthrough_response(self, self.skip_key) - self._clean_output(state, meta, set(m_daily), day) + self._clean_output(state, meta, set(m_daily), day, schedules_by_id, set(m_resource)) + if self.refine_enabled: + await self._refine_stage(state, wrapper, ws, day, now_dt, view, set(m_resource)) + state.reminders = merge_reminders(seeded, state.reminders) answer = ( f"Extracted {len(state.follow_ups)} follow_up(s), {len(state.extends)} extend(s), " - f"{len(state.updates)} update(s) from {len(state.changed_paths)} changed file(s)" + f"{len(state.reminders)} reminder(s), {len(state.new_ideas)} idea(s), " + f"{len(state.updates)} update(s), {len(state.schedule_updates)} schedule update(s) " + f"from {len(state.changed_paths)} changed file(s) " + f"and {len(state.resource_changed)} external notice file(s)" ) + if state.refine_calls: + answer += ( + f"; refine: {state.refine_calls} pass(es), dropped {len(state.refine_dropped)}, " + f"moved {len(state.refine_moved)}, degraded {state.refine_degraded}" + ) return self._finish(state, started, answer) - def _build_messages(self, state: ProactiveState, ws, day: str, daily: str, material_blob: str) -> tuple[str, str]: + def _clock_only(self, state: ProactiveState, started: float, seeded: list[dict], reason: str): + """Deliver clock-driven reminders without consuming this round's material. + + ``changed_paths`` is cleared so the finish step checkpoints nothing and + the unread material stays "changed" for the next round; the chain still + runs, so today's agenda gets its reminders. + """ + state.changed_paths, state.resource_changed, state.changed_mtimes = [], [], {} + state.reminders = seeded + state.reminder_only = True + self.logger.warning(f"[{self.name}] {reason}; delivering {len(seeded)} clock-driven reminder(s)") + return self._finish(state, started, f"{reason}; {len(seeded)} clock-driven reminder(s), 0 LLM output used") + + def _build_messages( + self, + state: ProactiveState, + ws, + day: str, + daily: str, + material_blob: str, + notice_blob: str, + now_dt, + view: list[dict], + ) -> tuple[str, str]: carry_forward_json = json.dumps( [ { @@ -157,20 +319,43 @@ class ProactiveExtractStep(BaseStep): ensure_ascii=False, ) changed = list(dict.fromkeys(state.changed_paths)) + notices = list(dict.fromkeys(state.resource_changed)) + schedules_json = json.dumps( + schedule_prompt_entries(view, self.schedule_closed_lookback_days, day), + ensure_ascii=False, + ) user_message = self.prompt_format( "extract_user_message", date=day, + clock=f"{now_dt.isoformat(timespec='minutes')} ({now_dt.strftime('%A')})", + resource_dir=str(self.config_value("resource_dir")), changed_paths_json=json.dumps(changed, ensure_ascii=False), carry_forward_json=carry_forward_json, + schedules_json=schedules_json, material_blob=material_blob, profile_block=self._profile_block(ws), + capability_block=self._capability_block(ws), + resource_paths_json=json.dumps(notices, ensure_ascii=False), + resource_block=notice_blob or "(no external notices in this round)", extends=self.extends_enabled, + reminders=self.reminders_enabled, + ideas=self.new_ideas_enabled, + resources=self.reminders_enabled and self.resources_enabled, ) system_prompt = self.prompt_format( "extract_system_prompt", workspace_dir=str(ws), daily_dir=daily, + resource_dir=str(self.config_value("resource_dir")), + output_shape=self._output_shape(), + max_follow_ups=self.max_follow_ups, + max_extends=self.max_extends, + max_new_ideas=self.max_new_ideas, + max_notice_reminders=self.max_notice_reminders, extends=self.extends_enabled, + reminders=self.reminders_enabled, + ideas=self.new_ideas_enabled, + resources=self.reminders_enabled and self.resources_enabled, ) return user_message, system_prompt @@ -179,8 +364,41 @@ class ProactiveExtractStep(BaseStep): digest_dir = str(self.config_value("digest_dir")) return load_personal_profile_block(ws, digest_dir, self.profile_max_chars) - async def _extract_with_retry(self, wrapper, state, ws, day: str, daily: str) -> dict | None: - """Returns parsed meta; None means skip without checkpointing.""" + def _capability_block(self, ws) -> str: + """Digest procedure/wiki inventory; grounds the ``new_ideas`` branch.""" + digest_dir = str(self.config_value("digest_dir")) + return load_capability_block(ws, digest_dir, self.capability_max_chars) + + def _output_shape(self) -> str: + """YAML sample listing exactly the active sections. + + Composed from per-section snippets rather than inline ``[flag]`` lines: + the flag filter left-strips kept lines, which would flatten the + indentation of a nested YAML example. + """ + names = ["follow_ups"] + if self.extends_enabled: + names.append("extends") + if self.reminders_enabled: + names.append("reminders") + if self.new_ideas_enabled: + names.append("new_ideas") + names.append("updates") + if self.reminders_enabled: + names.append("schedule_updates") + return "\n".join(self.get_prompt(f"extract_shape_{name}") for name in names) + + async def _extract_with_retry( + self, + wrapper, + state, + ws, + day: str, + daily: str, + now_dt, + view, + ) -> tuple[dict | None, str]: + """Returns ``(meta, failure_reason)``; meta None means no usable output.""" # Newest daily material first: when the total budget bites, the # freshest evidence survives and the oldest files are omitted. ordered = list(dict.fromkeys(state.changed_paths))[::-1] @@ -190,48 +408,111 @@ class ProactiveExtractStep(BaseStep): limit_per_file=self.max_chars_per_file, max_total_chars=self.max_total_chars or None, ) - user_message, system_prompt = self._build_messages(state, ws, day, daily, material_blob) + # External notices are packed under their own, smaller budget so a + # large upload can neither crowd out the conversation material nor be + # crowded out by it. + notice_blob = pack_paths( + ws, + list(dict.fromkeys(state.resource_changed))[::-1], + limit_per_file=self.max_chars_per_file, + max_total_chars=self.resource_max_chars or None, + ) + user_message, system_prompt = self._build_messages( + state, + ws, + day, + daily, + material_blob, + notice_blob, + now_dt, + view, + ) raw = "" for attempt in (1, 2): raw = await self._reply(wrapper, state, user_message, system_prompt) if raw is None: - self.context[self.skip_key] = {"reason": "llm_timeout"} - return None + return None, "llm_timeout" meta = parse_extract_reply(raw) if meta: - return meta + return meta, "" self.logger.warning(f"[{self.name}] parse failed on attempt {attempt}; raw={raw[:200]!r}") self.logger.error( - f"[{self.name}] reply unusable after 2 attempts; keeping material un-checkpointed; " f"raw={raw[:200]!r}", + f"[{self.name}] reply unusable after 2 attempts; keeping material un-checkpointed; raw={raw[:200]!r}", ) - self.context[self.skip_key] = {"reason": "extract_parse_failed"} - return None + return None, "extract_parse_failed" - async def _reply(self, wrapper, state, user_message, system_prompt): + async def _reply(self, wrapper, state, user_message, system_prompt, timeout: float | None = None): """One timeout-wrapped reply; returns raw text or None on timeout.""" state.llm_calls += 1 + limit = self.llm_timeout_seconds if timeout is None else float(timeout) try: result = await asyncio.wait_for( wrapper.reply(user_message, system_prompt=system_prompt), - timeout=self.llm_timeout_seconds, + timeout=limit, ) except asyncio.TimeoutError: - self.logger.warning(f"[{self.name}] LLM reply timed out after {self.llm_timeout_seconds}s") + self.logger.warning(f"[{self.name}] LLM reply timed out after {limit}s") return None return agent_reply_result_text(result) - def _clean_output(self, state: ProactiveState, meta: dict, allowed: set[str], day: str) -> None: - for raw in meta.get("follow_ups") or []: - if candidate := clean_candidate(raw, allowed, "follow_up", day): - state.follow_ups.append(candidate) - else: - state.dropped_missing += 1 + def _clean_output( + self, + state: ProactiveState, + meta: dict, + allowed: set[str], + day: str, + schedules_by_id: dict, + notice_allowed: set[str] | None = None, + ) -> None: + # Only the reminder branch may cite the ``resource/`` layer: the other + # three kinds reason about the user's own conversations, and letting + # them quote an external upload would blur that boundary. + reminder_allowed = set(allowed) | set(notice_allowed or ()) + self._append_kind( + state.follow_ups, + meta.get("follow_ups"), + allowed, + "follow_up", + day, + self.max_follow_ups, + state, + ) if self.extends_enabled: - for raw in meta.get("extends") or []: - if candidate := clean_candidate(raw, allowed, "interest_extend", day): - state.extends.append(candidate) + self._append_kind( + state.extends, + meta.get("extends"), + allowed, + "interest_extend", + day, + self.max_extends, + state, + ) + if self.reminders_enabled: + cleaned: list[dict] = [] + for raw in meta.get("reminders") or []: + if candidate := clean_reminder(raw, reminder_allowed, day, schedules_by_id, notice_allowed): + cleaned.append(candidate) else: state.dropped_missing += 1 + # Dateless notice/key-event reminders are bounded; clock-anchored + # ones are not (they are facts, and they pin today's agenda). + state.reminders, over_cap = cap_notice_reminders(cleaned, self.max_notice_reminders) + state.dropped_over_cap += over_cap + for raw in meta.get("schedule_updates") or []: + if update := clean_schedule_update(raw, reminder_allowed, day): + state.schedule_updates.append(update) + else: + state.dropped_missing += 1 + if self.new_ideas_enabled: + self._append_kind( + state.new_ideas, + meta.get("new_ideas"), + allowed, + "new_idea", + day, + self.max_new_ideas, + state, + ) for raw in meta.get("updates") or []: if not isinstance(raw, dict): continue @@ -251,6 +532,142 @@ class ProactiveExtractStep(BaseStep): }, ) + def _append_kind( + self, + bucket: list[dict], + raw_items, + allowed: set[str], + kind: str, + day: str, + cap: int, + state: ProactiveState, + ) -> None: + """Clean one discovery section into ``bucket``, honouring its ceiling. + + ``cap <= 0`` means unlimited. Over-cap entries are counted separately + from invalid ones so a noisy round stays diagnosable in metadata. + """ + for raw in raw_items or []: + if cap and len(bucket) >= cap: + state.dropped_over_cap += 1 + continue + if candidate := clean_candidate(raw, allowed, kind, day): + bucket.append(candidate) + else: + state.dropped_missing += 1 + + # ------------------------------------------------------------------ + # Stage 2: per-kind adjudication (precision pass) + # ------------------------------------------------------------------ + + def _enabled_kinds(self) -> set[str]: + """Kinds this round may hold candidates in (the legal ``move`` targets).""" + return { + kind + for kind, active in ( + ("follow_up", True), + ("interest_extend", self.extends_enabled), + ("reminder", self.reminders_enabled), + ("new_idea", self.new_ideas_enabled), + ) + if active + } + + def _kind_caps(self) -> dict[str, int]: + """Per-kind ceilings re-applied after stage-2 moves (0 = uncapped).""" + return { + "follow_up": self.max_follow_ups, + "interest_extend": self.max_extends, + "new_idea": self.max_new_ideas, + "reminder": 0, + } + + def _cap_label(self, kind: str) -> str: + """Ceiling as the refine prompt should state it.""" + if kind != "reminder": + return str(self._kind_caps()[kind]) + return f"uncapped, but at most {self.max_notice_reminders} dateless notice/key-event reminder(s)" + + async def _refine_stage(self, state, wrapper, ws, day: str, now_dt, view, notice_paths: set[str]) -> None: + """One small adjudication call per kind that produced candidates. + + Stage 2 never sees the material blob: it receives that kind's candidates + plus the only context slice the kind needs, which keeps the pass cheap + and its judgement focused. Each pass is independently fail-open, so a + timeout or an unusable reply keeps the stage-1 candidates of that kind. + """ + verdicts: list[dict] = [] + for kind, bucket in candidates_by_kind(state): + known = {str(candidate.get("id") or "") for candidate in bucket} + user_message = self.prompt_format( + "refine_user_message", + kind=kind, + cap=self._cap_label(kind), + checks=self.get_prompt(f"refine_checks_{kind}"), + candidates_json=json.dumps(candidate_payload(bucket), ensure_ascii=False), + context_block=self._refine_context(kind, state, ws, view, day, now_dt), + ) + system_prompt = self.prompt_format("refine_system_prompt", kind=kind) + raw = await self._reply(wrapper, state, user_message, system_prompt, self.refine_timeout_seconds) + state.refine_calls += 1 + if raw is None: + state.refine_degraded += 1 + self.logger.warning(f"[{self.name}] refine({kind}) timed out; keeping stage-1 candidates") + continue + parsed = parse_refine_reply(raw) + if parsed is None: + state.refine_degraded += 1 + self.logger.warning(f"[{self.name}] refine({kind}) reply unusable; raw={raw[:160]!r}") + continue + # A pass may not reach into another kind's bucket: ids outside it + # are ignored rather than trusted. + verdicts.extend(verdict for verdict in parsed if verdict["id"] in known) + if not verdicts: + # Nothing to adjudicate: every pass answered "keep everything". + # The caps only need re-applying when a move could have crossed a + # kind boundary, but the summary is worth logging either way. + self.logger.info( + f"[{self.name}] refine passes={state.refine_calls} dropped=0 moved=0 " + f"degraded={state.refine_degraded} no verdicts; kept: " + f"reminders={len(state.reminders)} follow_ups={len(state.follow_ups)} " + f"ideas={len(state.new_ideas)} extends={len(state.extends)}", + ) + return + apply_verdicts(state, verdicts, self._kind_caps(), self._enabled_kinds(), notice_paths) + state.reminders, over_cap = cap_notice_reminders(state.reminders, self.max_notice_reminders) + state.dropped_over_cap += over_cap + self.logger.info( + f"[{self.name}] refine passes={state.refine_calls} dropped={len(state.refine_dropped)} " + f"moved={len(state.refine_moved)} degraded={state.refine_degraded} kept: " + f"reminders={len(state.reminders)} follow_ups={len(state.follow_ups)} " + f"ideas={len(state.new_ideas)} extends={len(state.extends)}", + ) + + def _refine_context(self, kind: str, state, ws, view, day: str, now_dt) -> str: + """The single context slice this kind's adjudication needs (no material).""" + if kind == "reminder": + entries = schedule_prompt_entries(view, self.schedule_closed_lookback_days, day) + return ( + f"now: {now_dt.isoformat(timespec='minutes')} ({now_dt.strftime('%A')})\n" + "schedules (registry, triggers already computed from the clock):\n" + f"{json.dumps(entries, ensure_ascii=False)}" + ) + if kind == "follow_up": + carried = [ + {"id": t.id, "title": t.title, "kind": t.kind, "last_evidence_at": t.last_evidence_at} + for t in state.carry_forward_prompt + ] + return ( + "carry_forward_topics (already tracked; a candidate that restates one of these " + f"must be dropped):\n{json.dumps(carried, ensure_ascii=False)}" + ) + if kind == "new_idea": + return f"capability_inventory (what the agent can actually do):\n{self._capability_block(ws)}" + return ( + "knowledge_base inventory (existing wiki nodes; a candidate that merely restates one " + f"of these must be dropped):\n{self._capability_block(ws)}" + ) + def _store(self, state: ProactiveState) -> None: assert self.context is not None data = state.model_dump() diff --git a/reme/steps/evolve/proactive/extract.yaml b/reme/steps/evolve/proactive/extract.yaml index 0319ffdb..edf7fec0 100644 --- a/reme/steps/evolve/proactive/extract.yaml +++ b/reme/steps/evolve/proactive/extract.yaml @@ -1,27 +1,111 @@ extract_system_prompt: | You are the proactive discovery agent for a personal memory workspace. - You read recently changed conversation notes and maintain a small set of - user-interest topics. You never browse the web, - never write files, and never collect material yourself. + You read recently changed conversation notes, the user's schedule registry + and the current clock, and you propose what the assistant should bring up + next. You never browse the web, never write files, never collect material + yourself, and never execute anything: every entry you emit is a PROPOSAL + that the host agent may raise with the user. workspace_dir: {workspace_dir} - ## Branch A - follow_ups (open loops) + ## The four kinds and their division of labour - Find unresolved matters the user may still want to close: - - questions that were asked but never answered; - - tasks that were started, interrupted, or postponed; - - commitments or plans mentioned without any follow-through yet. + Each branch below has its own section, its own job and its own ceiling. + Every branch is optional; a quiet day produces empty lists. Never let one + branch borrow another's job (a due schedule is a reminder, not a follow-up; + a wiki node worth expanding is an extend, not an idea). + + ## Branch A - follow_ups (kind: follow_up) + + Scope: matters the user assigned, or keeps caring about, that are NOT closed + yet - tasks started/interrupted/postponed, questions asked but never + answered, commitments mentioned without follow-through. Carry-forward topics + already track the long-lived ones, so prefer `updates` for those and open a + `follow_ups` entry only for a matter no carry-forward topic covers. + Job: state the latest progress visible in the material, then propose going + and fetching the current status so the assistant can report back to the user + on its own initiative ("let me check where X stands and tell you"). The + deliverable is a report, never a silent completion of the work. Emit each as a `follow_ups` entry. - [extends]## Branch B - extends (interest boundary) + [extends]## Branch B - extends (kind: interest_extend) [extends] - [extends]Infer topics the user has NOT focused on yet, but that are plausibly - [extends]relevant to their recent work, using the conversation notes. These - [extends]guide future knowledge-source expansion; you only describe them, you - [extends]never collect anything. + [extends]Scope: content recommendation for the user's personal knowledge + [extends]base. Starting from a topic the user already cares about (see the + [extends]wiki inventory in the user message), name one refinement or + [extends]extension of it that is worth adding as a new wiki node: a + [extends]sub-topic, an adjacent concept, a comparison, a missing prerequisite. + [extends]Job: propose introducing and walking the user through that content. + [extends]You only describe the node to expand; you never collect or write it. [extends]Emit each as an `extends` entry. + [reminders]## Branch C - reminders (kind: reminder) + schedule_updates + [reminders] + [reminders]Scope: the user's personal plans and calendar. You are given + [reminders]`schedules` (a long-lived registry that may hold items mentioned + [reminders]weeks ago) and `now` (the current clock). Your jobs: + [reminders]1. Review - compare the registry and the material against the + [reminders] clock, then propose a reminder for what is due soon, due today, + [reminders] already overdue, or clashing with another entry. Warn about a + [reminders] possible conflict explicitly and say which entries collide. + [reminders] Also propose the upcoming todos the user would want to be + [reminders] nudged about. Propose only: never claim a calendar was changed. + [reminders]2. Maintain - whenever the material adds, moves, confirms, + [reminders] completes or cancels a schedule, emit a `schedule_updates` + [reminders] entry. The registry is the only structure that lives long + [reminders] enough to fire on a due date mentioned long ago, so a schedule + [reminders] you do not register will never be reminded. + [reminders] Title an entry by the matter alone, never by its date or time: + [reminders] the id is derived from the title, so a date in it mints a brand + [reminders] new registry entry every time that schedule moves, and the user + [reminders] ends up reminded twice about one appointment. + [resources]3. Notices and key events - scan `resource_notices` (external + [resources] source records that changed this round: whatever an interface + [resources] dropped into `{resource_dir}/` - mail, feeds, exports, + [resources] documents) and pick out only what the user MUST be told today. + [resources] Two shapes count: + [resources] a) a direct notice about the user's own affairs - a deadline or + [resources] date that moved, a cancellation, a bill or payment falling + [resources] due, a confirmation that needs action; + [resources] b) a key event that materially affects the user even though + [resources] nobody addressed it to them - a policy or rule change, an + [resources] incident/recall/security advisory, a development in a matter + [resources] they track, a market or supply shift they are exposed to. + [resources] Emit each as a `reminders` entry citing that resource path. Give + [resources] `due_at` whenever the item has a date, and register that dated + [resources] commitment via `schedule_updates` so it keeps firing from the + [resources] registry. A key event with no date may omit `due_at`; at most + [resources] {max_notice_reminders} such dateless entries survive a round, so + [resources] make them count. Merely interesting reading material is neither + [resources] a notice nor a key event; leave it out. + [reminders]Traceability: a reminder about a registered schedule cites it via + [reminders]`schedule_id` and may leave `paths` empty (its evidence is the + [reminders]clock). A reminder about a brand-new schedule must cite + [reminders]changed_paths and carry `due_at`. + [resources]A notice-driven reminder cites its resource_paths entry instead. + [reminders]Reminders are the highest-priority kind: every reminder you emit + [reminders]enters today's agenda, so they are not capped - but never invent a + [reminders]schedule to justify one, and never repeat a schedule the registry + [reminders]already shows as done or cancelled. + [reminders]Emit reminders as `reminders` entries and registry maintenance as + [reminders]`schedule_updates` entries. + + [ideas]## Branch D - new_ideas (kind: new_idea) + [ideas] + [ideas]Scope: work the user probably does not realise can be delegated. + [ideas]Combine what the agent recently executed, the data it collected and + [ideas]the experience/skills/tools/APIs it already has (the capability + [ideas]inventory in the user message) with what the material shows the user + [ideas]is working on, then propose one concrete piece of work worth handing + [ideas]over - the kind of task a user would never think to ask for. + [ideas]Job: describe the deliverable, why it is valuable right now, and what + [ideas]existing resource makes it feasible. Stop at the proposal: never + [ideas]execute it and never imply it is already done. + [ideas]Ground every idea in a capability or a dataset that actually appears + [ideas]in the inventory or the material; an idea needing resources nobody + [ideas]has is noise. + [ideas]Emit each as a `new_ideas` entry. + ## updates (carried-forward topics) For every topic in carry_forward_topics, decide exactly one action: @@ -41,6 +125,7 @@ extract_system_prompt: | ## Confidence rubric (fixed; do not freestyle scores) - 0.9: explicit unfinished action with a time or commitment; + [reminders]- 0.8: a registered schedule the clock shows as due, overdue or clashing; - 0.7: the user stated intent explicitly but no concrete action yet; - 0.5: inferential link from several weak signals; - 0.3: weak association only; @@ -50,9 +135,21 @@ extract_system_prompt: | - Empty lists are a normal, expected output. Never invent topics to fill space, and never promote a one-off weak mention into a topic. - - Every topic must be traceable to this round's material: `paths` may only - contain values from changed_paths (paths look like `{daily_dir}//...`); - entries violating this are discarded. + - Ceilings are not targets, and entries over a ceiling are discarded + downstream: at most {max_follow_ups} follow_ups. + [extends]- Ceiling: at most {max_extends} extends. + [ideas]- Ceiling: at most {max_new_ideas} new_ideas. + [reminders]- Reminders are uncapped, but each one must be a real finding of + [reminders]the schedule review - never invent a schedule to justify one. + - Every topic must be traceable: `paths` may only contain values from + changed_paths (paths look like `{daily_dir}//...`); entries violating + this are discarded. + [reminders]- Traceability exception: a `reminders` entry citing a registered + [reminders]`schedule_id` may take its paths from the registry instead. + [resources]- Traceability exception: `reminders` and `schedule_updates` may also + [resources]cite resource_paths (`{resource_dir}/...`). No other section may. + - user_profile and the capability inventory are background knowledge: use + them to judge relevance, never cite them in paths/evidence. - Do not restate topics already listed in carry_forward_topics; use `updates` to change their status instead. - Same-matter rule: if a finding is really a carry_forward topic in @@ -69,45 +166,93 @@ extract_system_prompt: | Return only one YAML fenced block with this exact shape: ```yaml - follow_ups: - - title: - reason: - confidence: 0.7 - paths: [, ...] - [extends]extends: - [extends] - title: - [extends] reason: - [extends] confidence: 0.5 - [extends] paths: [, ...] - updates: - - id: - action: keep|update|resolve - evidence: - reason: - confidence: + {output_shape} ``` extract_system_prompt_zh: | - 你是个人记忆 workspace 的 proactive 发现 agent。你阅读近期变化的对话笔记 - ,维护一小组用户兴趣主题。你绝不联网、 - 绝不写文件、绝不自行收集资料。 + 你是个人记忆 workspace 的 proactive 发现 agent。你阅读近期变化的对话笔记、 + 用户日程注册表与当前时钟,产出"下一步值得主动提起什么"的提议。你绝不联网、 + 绝不写文件、绝不自行收集资料、绝不执行任何动作:你输出的每一条都是提议 + (proposal),由宿主 agent 决定是否向用户提起。 workspace_dir: {workspace_dir} - ## 分支 A - follow_ups(未决事项 / open loop) + ## 四类标签及其职责边界 - 找出用户可能仍想关闭的未决事项: - - 提出过但没有得到回答的问题; - - 开始过但被中断或搁置的任务; - - 提到过但没有后续动作的承诺或计划。 + 下面每个分支有自己的 section、职责与条数上限。每个分支都是可选的,平静的 + 一天就应该输出空列表。禁止串味:到期的日程属于 reminder 而不是 follow_up; + 值得展开的知识节点属于 interest_extend 而不是 new_idea。 + + ## 分支 A - follow_ups(kind: follow_up) + + 范围:用户指派过、或持续关心但尚未关闭的事项——开始过却被中断/搁置的任务、 + 提出过但没有回答的问题、提到过却没有后续动作的承诺。carry_forward_topics + 已经跟踪长期未决事项,这类请优先用 `updates` 处置,只有当没有任何 + carry-forward 主题覆盖时,才新开 `follow_ups` 条目。 + 职责:先说清素材里能看到的最新进展,然后提议去检索该事项的最新情况,以便 + 助手主动向用户汇报("我去确认一下 X 现在到哪一步了,再告诉你")。交付物是 + 一次汇报,绝不是悄悄替用户把事做完。 每条作为一个 `follow_ups` 条目输出。 - [extends]## 分支 B - extends(兴趣边界扩展) + [extends]## 分支 B - extends(kind: interest_extend) [extends] - [extends]推断用户尚未关注、但与其近期工作大概率相关的主题,依据是对话笔记 - [extends]。它们用于指引未来的知识源扩展;你只描述主题,不收集任何资料。 + [extends]范围:面向个人知识库的内容推荐。从用户已经关心的主题出发(见用户 + [extends]消息中的 wiki 节点清单),指出一个值得作为新 wiki 节点补上的细化或 + [extends]延伸方向:子话题、相邻概念、横向对比、缺失的前置知识。 + [extends]职责:提议向用户介绍并展示这部分内容。你只描述要扩展哪个节点, + [extends]绝不自行收集或撰写内容。 [extends]每条作为一个 `extends` 条目输出。 + [reminders]## 分支 C - reminders(kind: reminder)+ schedule_updates + [reminders] + [reminders]范围:用户的个人计划与日程。输入包含 `schedules`(长生命周期 + [reminders]注册表,可能有几周前提到的条目)与 `now`(当前时钟)。职责如下: + [reminders]1. 检查回顾——把注册表与素材对照时钟检查一遍,对即将到期、今天 + [reminders] 到期、已经逾期、或与其它日程冲突的安排提出提醒;冲突要明确 + [reminders] 预警并指出是哪几条相撞;同时提议提醒即将到来的 todos。 + [reminders] 只做提议:绝不声称已经改动了任何日历。 + [reminders]2. 维护注册表——素材中出现新增、改期、确认、完成、取消日程时, + [reminders] 必须输出 `schedule_updates` 条目。注册表是唯一能活到"很久以 + [reminders] 前提到的到期日"那天的结构;不登记的日程永远不会被提醒。 + [reminders] 条目标题只写事项本身,绝不要带日期或时间:id 由标题推导,标题 + [reminders] 里含日期会让这条日程每次改期都在注册表里新增一条,同一个约定 + [reminders] 就会被提醒两次。 + [resources]3. 通知与关键事件——通读 `resource_notices`(本轮发生变化的外部 + [resources] 信源记录,即外部信源接口投递到 `{resource_dir}/` 的邮件、订阅 + [resources] 源、导出件、文档等),只挑出"今天必须告知用户"的内容。两种形态 + [resources] 都算: + [resources] a) 直接通知——与用户自身事务相关的:时间/截止日被改动、被取消、 + [resources] 账单或付款到期、需要动作的确认件; + [resources] b) 关键事件——虽不是发给用户本人、但对其有实质影响的:政策或规则 + [resources] 变化、事故/召回/安全公告、其持续跟踪事项的重要进展、其所暴露 + [resources] 的市场或供应变化。 + [resources] 每条作为 `reminders` 输出并引用该 resource 路径。带日期的一律给出 + [resources] `due_at`,并用 `schedule_updates` 把该承诺登记进注册表,使其在素材 + [resources] 离开扫描窗口后仍能按到期日触发。没有日期的关键事件可以省略 + [resources] `due_at`;这类无日期条目每轮最多保留 {max_notice_reminders} 条, + [resources] 请挑真正重要的。仅仅是"有点意思"的阅读材料既不是通知也不是关键 + [resources] 事件,不要输出。 + [reminders]可追溯性:针对已登记日程的提醒用 `schedule_id` 引用,`paths` + [reminders]可以为空(它的证据是时钟);针对全新日程的提醒必须引用 + [reminders]changed_paths 并给出 `due_at`。 + [resources]来自外部通知的提醒改为引用 resource_paths 中的路径。 + [reminders]reminder 是四类中优先级最高的:你输出的每条 reminder 都会进入 + [reminders]当日 agenda,因此不设条数上限——但绝不为了凑提醒编造日程,也 + [reminders]绝不重复注册表中已完成/已取消的条目。 + [reminders]提醒输出为 `reminders` 条目,注册表维护输出为 `schedule_updates`。 + + [ideas]## 分支 D - new_ideas(kind: new_idea) + [ideas] + [ideas]范围:用户根本没想到"这事可以委托 AI 办"的工作事项。把 agent 最近 + [ideas]执行的结果、收集的数据、已积累的经验/skill/工具/API(见用户消息中 + [ideas]的能力清单)与素材里用户正在做的事情结合起来,提议一件具体、值得 + [ideas]交出去做的工作。 + [ideas]职责:说清交付物是什么、为什么现在有价值、依靠哪个已有资源才可行。 + [ideas]停留在提议:绝不执行,也绝不暗示已经做完。 + [ideas]每个想法必须落在能力清单或素材里真实存在的能力/数据上;需要谁都没有 + [ideas]的资源才能完成的想法属于噪音。 + [ideas]每条作为一个 `new_ideas` 条目输出。 + ## updates(carry-forward 主题处置) 对 carry_forward_topics 中的每个主题,恰好选择一个动作: @@ -125,6 +270,7 @@ extract_system_prompt_zh: | ## confidence 评分规则(固化,禁止自由打分) - 0.9:有明确未完成动作且带时间/承诺; + [reminders]- 0.8:注册表中已登记、且时钟显示即将到期/已逾期/存在冲突的日程; - 0.7:用户显式表达意图但无明确动作; - 0.5:推断性关联(多条弱信号); - 0.3:弱联想; @@ -134,8 +280,20 @@ extract_system_prompt_zh: | - 空列表是正常且预期的输出。不得为凑数编造主题,不得把一次性弱提及 拔高为主题。 - - 每个主题必须能追溯到本轮素材:`paths` 只能使用 changed_paths 中的值 - (路径形如 `{daily_dir}//...`),越界条目会被丢弃。 + - 上限是天花板而不是目标,超出上限的条目会在下游被丢弃:follow_ups 至多 + {max_follow_ups} 条。 + [extends]- 上限:extends 至多 {max_extends} 条。 + [ideas]- 上限:new_ideas 至多 {max_new_ideas} 条。 + [reminders]- reminders 不设上限,但每条都必须来自真实的日程检查结论, + [reminders]绝不为了凑提醒编造日程。 + - 每个主题必须可追溯:`paths` 只能使用 changed_paths 中的值(路径形如 + `{daily_dir}//...`),越界条目会被丢弃。 + [reminders]- 可追溯性例外:引用了已登记 `schedule_id` 的 `reminders` 条目, + [reminders]其 paths 可以来自注册表。 + [resources]- 可追溯性例外:`reminders` 与 `schedule_updates` 还可以引用 + [resources]resource_paths(`{resource_dir}/...`);其他 section 不可以。 + - user_profile 与能力清单属于背景知识:只用于判断相关度,禁止引用进 + paths/evidence。 - 不要复述 carry_forward_topics 中已 open 的主题;要改变其状态请用 `updates`。 - 同一件事规则:若新发现其实只是某个 carry_forward 主题的换种说法, @@ -151,61 +309,427 @@ extract_system_prompt_zh: | 只返回一个 YAML fenced block,结构必须严格如下: ```yaml + {output_shape} + ``` + +extract_user_message: | + date: {date} + now (current clock in the business timezone; the reference for every due + judgement): {clock} + + changed_paths (the ONLY allowed values for `paths`): + {changed_paths_json} + + [resources]resource_paths (external source records changed this round; also + [resources]allowed in `paths`, but ONLY for reminders/schedule_updates): + [resources]{resource_paths_json} + + carry_forward_topics (already open; only act on them via updates): + {carry_forward_json} + + [reminders]schedules (long-lived registry; `next_due`, `due_in_days`, + [reminders]`trigger` and `conflict_with` are already computed from the clock + [reminders]above; recently closed entries are shown so you do not re-add + [reminders]them): + [reminders]{schedules_json} + + user_profile (background knowledge about the user's identity, preferences + and constraints; use it ONLY to judge relevance and confidence - it is NOT + material, never cite it in paths/evidence): + {profile_block} + + [ideas]capability_inventory (reusable experience/skills and knowledge nodes + [ideas]the agent already has; background for new_ideas only - it is NOT + [ideas]material, never cite it in paths/evidence): + [ideas]{capability_block} + + # Material + + {material_blob} + + [resources]# External notices ({resource_dir}/, reminder branch only) + [resources] + [resources]{resource_block} + + Emit the sections your active branches define, per the contract: + - follow_ups and updates + [extends]- extends + [reminders]- reminders and schedule_updates + [ideas]- new_ideas + Remember: every section is optional, empty lists are fine, never invent + topics, and never execute a proposal. + +extract_user_message_zh: | + 日期:{date} + 当前时钟(业务时区;所有"是否到期"判断的基准):{clock} + + changed_paths(`paths` 唯一允许的取值): + {changed_paths_json} + + [resources]resource_paths(本轮变化的外部信源记录;也允许写进 `paths`, + [resources]但仅限 reminders/schedule_updates): + [resources]{resource_paths_json} + + carry_forward_topics(已 open 的主题;只能通过 updates 处置): + {carry_forward_json} + + [reminders]schedules(长生命周期日程注册表;其中 `next_due`、`due_in_days`、 + [reminders]`trigger`、`conflict_with` 已按上面的时钟算好;近期已关闭的条目 + [reminders]也会列出,避免你重复登记): + [reminders]{schedules_json} + + 用户画像(用户身份、偏好与约束的背景知识;仅用于判断相关度与 + confidence——它不是素材,禁止引用进 paths/evidence): + {profile_block} + + [ideas]能力清单(agent 已经具备的可复用经验/skill 与已有知识节点;仅供 + [ideas]new_ideas 参考——它不是素材,禁止引用进 paths/evidence): + [ideas]{capability_block} + + # 本轮素材 + + {material_blob} + + [resources]# 外部通知({resource_dir}/,仅供 reminder 分支使用) + [resources] + [resources]{resource_block} + + 按契约输出当前启用分支对应的 section: + - follow_ups 与 updates + [extends]- extends + [reminders]- reminders 与 schedule_updates + [ideas]- new_ideas + 记住:每个 section 都是可选的,空列表是正常输出,绝不编造主题,也绝不 + 执行任何提议。 + +# --------------------------------------------------------------------------- +# Output-shape snippets, composed by ``_output_shape`` for the active branches +# only. They are separate keys (not ``[flag]`` lines) so the flag filter's +# left-strip cannot flatten the YAML indentation of the example. +# --------------------------------------------------------------------------- + +extract_shape_follow_ups: | + follow_ups: + - title: + reason: + confidence: 0.7 + paths: + - + +extract_shape_extends: | + extends: + - title: + reason: + confidence: 0.5 + paths: + - + +extract_shape_reminders: | + reminders: + - title: + reason: + due_at: + schedule_id: + conflict_with: [] + confidence: 0.8 + paths: + - <changed path; may be empty when schedule_id is given> + +extract_shape_new_ideas: | + new_ideas: + - title: <a concrete piece of work worth delegating> + reason: <the deliverable, why now, and which existing skill/tool/data makes it feasible> + confidence: 0.5 + paths: + - <changed path> + +extract_shape_updates: | + updates: + - id: <echoed carry-forward id> + action: keep|update|resolve + evidence: <path or path#anchor> + reason: <short justification> + confidence: <re-scored per rubric; emit for update, omit otherwise> + +extract_shape_schedule_updates: | + schedule_updates: + - id: <registry id; omit for action=add> + action: add|reschedule|confirm|done|cancel + title: <required for add; the matter alone, never its date or time> + due_at: <YYYY-MM-DD or YYYY-MM-DDTHH:MM; required for add and reschedule> + due_end_at: <optional end of a range> + recurrence: <omit or one of daily|weekly|monthly|yearly> + lead_days: <optional; how many days ahead to start reminding> + detail: <optional short note> + evidence: <path from changed_paths, or the notice path; required, it anchors the change> + reason: <short justification> + +extract_shape_follow_ups_zh: | follow_ups: - title: <具体的未决事项> - reason: <为什么仍未关闭> + reason: <素材中看到的最新进展,以及要去检索并汇报什么> confidence: 0.7 - paths: [<素材路径>, ...] - [extends]extends: - [extends] - title: <相关但未被关注的主题> - [extends] reason: <为什么重要,基于素材> - [extends] confidence: 0.5 - [extends] paths: [<素材路径>, ...] + paths: + - <素材路径> + +extract_shape_extends_zh: | + extends: + - title: <相关但尚未被关注的主题> + reason: <它细化/延伸了哪个已关心主题,会补上哪个 wiki 节点> + confidence: 0.5 + paths: + - <素材路径> + +extract_shape_reminders_zh: | + reminders: + - title: <对应的日程事项> + reason: <为什么是现在:即将到期 / 今天到期 / 已逾期 / 与哪条冲突> + due_at: <YYYY-MM-DD 或 YYYY-MM-DDTHH:MM> + schedule_id: <注册表 id;仅全新日程可省略> + conflict_with: [<与之冲突的标题或 schedule_id>] + confidence: 0.8 + paths: + - <素材路径;给了 schedule_id 时可以为空> + +extract_shape_new_ideas_zh: | + new_ideas: + - title: <一件具体、值得委托出去的工作> + reason: <交付物是什么、为什么是现在、依靠哪个已有 skill/工具/数据才可行> + confidence: 0.5 + paths: + - <素材路径> + +extract_shape_updates_zh: | updates: - id: <回显的 carry-forward id> action: keep|update|resolve evidence: <路径或 路径#锚点> reason: <简短理由> confidence: <按 rubric 重新打分;action=update 时给出,其余可省略> + +extract_shape_schedule_updates_zh: | + schedule_updates: + - id: <注册表 id;action=add 时可省略> + action: add|reschedule|confirm|done|cancel + title: <add 时必填;只写事项本身,不要带日期或时间> + due_at: <YYYY-MM-DD 或 YYYY-MM-DDTHH:MM;add 与 reschedule 必填> + due_end_at: <可选的时间段结束点> + recurrence: <可省略,或 daily|weekly|monthly|yearly 之一> + lead_days: <可选;提前多少天开始提醒> + detail: <可选的简短备注> + evidence: <changed_paths 中的路径,或通知所来自的外部信源路径;必填,它是证据锚点> + reason: <简短理由> + +# --------------------------------------------------------------------------- +# Stage 2: per-kind adjudication prompts (``_refine_stage``). One small call per +# kind that produced candidates. The material blob is never re-sent: a pass gets +# its own candidates plus the single context slice that kind needs. +# --------------------------------------------------------------------------- + +refine_system_prompt: | + You are the quality gate of the proactive discovery stage of a personal + memory system. An earlier pass already read today's material and proposed + candidates of kind `{kind}`. You do NOT re-read the material, and you do NOT + write new candidates: you adjudicate the list you are given. + + For each candidate decide exactly one of: + - `keep`: it belongs to this kind, it is grounded, and it is worth raising; + - `drop`: reject it, and say why in one short clause; + - `move`: it is unmistakably another kind's job - name `to_kind`. + + You may also rescore `confidence` with the fixed rubric when the proposer + clearly over- or under-rated it. Never rewrite `title` or `reason`: the + wording the user will see has already been produced. + + ## Rubric (fixed; do not freestyle scores) + + - 0.9: explicit unfinished action with a time or commitment; + - 0.8: a registered schedule the clock shows as due, overdue or clashing; + - 0.7: the user stated intent explicitly but no concrete action yet; + - 0.5: inferential link from several weak signals; + - 0.3: weak association only; + - below 0.3: drop the candidate instead of scoring it. + + ## Hard rules + + - Echo ids exactly; an id that is not in the candidate list is discarded. + - A candidate you do not mention is kept unchanged, so mention only the ones + you want to drop, move or rescore. + - Dropping is for real defects, not for taste: when in doubt, keep. + - The context block is background knowledge, never evidence; do not cite it. + - These stay proposals: never word a verdict as if work had been done. + - Quote YAML strings containing punctuation such as `:`. + + ## Output format + + Return only one YAML fenced block: + ```yaml + verdicts: + - id: <echoed id> + decision: keep|drop|move + to_kind: <only for move: reminder|follow_up|new_idea|interest_extend> + confidence: <optional rescore per the rubric> + reason: <required for drop; one short clause> ``` -extract_user_message: | - date: {date} +refine_system_prompt_zh: | + 你是个人记忆系统 proactive 发现阶段的质量闸门。前一遍已经读过今天的素材, + 并产出了 kind 为 `{kind}` 的候选。你**不重读素材**,也**不新增候选**: + 你只裁决给你的这份列表。 - changed_paths (the ONLY allowed values for `paths`): - {changed_paths_json} + 对每个候选恰好给出一个结论: + - `keep`:属于该 kind、有依据、值得提起; + - `drop`:驳回,并用一小句说明原因; + - `move`:它明显属于另一类职责——给出 `to_kind`。 - carry_forward_topics (already open; only act on them via updates): - {carry_forward_json} + 当 proposer 明显打高或打低时,你可以按固定评分规则重打 `confidence`。 + 绝不改写 `title` 或 `reason`:给用户看的措辞已经产出了。 - user_profile (background knowledge about the user's identity, preferences - and constraints; use it ONLY to judge interest relevance and confidence - - it is NOT material, never cite it in paths/evidence): - {profile_block} + ## 评分规则(固化,禁止自由打分) - # Material + - 0.9:有明确未完成动作且带时间/承诺; + - 0.8:注册表中已登记、且时钟显示即将到期/已逾期/存在冲突的日程; + - 0.7:用户显式表达意图但无明确动作; + - 0.5:推断性关联(多条弱信号); + - 0.3:仅弱关联; + - 低于 0.3:直接 drop,不要打分。 - {material_blob} + ## 硬约束 - Extract follow_ups[extends] / extends / updates per the contract. Remember: - empty lists are fine; never invent topics. + - id 必须原样回显;不在候选列表中的 id 会被丢弃。 + - 未被提及的候选按原样保留,所以只写你要 drop / move / 重打分的那几条。 + - drop 只用于真实缺陷,不用于口味差异:拿不准就 keep。 + - 上下文块是背景知识,不是证据,禁止引用。 + - 这些仍然是提议:任何结论都不得写成"已经做完了"。 + - 包含 `:` 等标点的 YAML 字符串请加引号。 -extract_user_message_zh: | - 日期:{date} + ## 输出格式 - changed_paths(`paths` 唯一允许的取值): - {changed_paths_json} + 只返回一个 YAML fenced block: + ```yaml + verdicts: + - id: <回显的 id> + decision: keep|drop|move + to_kind: <仅 move 时给出:reminder|follow_up|new_idea|interest_extend> + confidence: <可选,按评分规则重打> + reason: <drop 时必填;一小句> + ``` - carry_forward_topics(已 open 的主题;只能通过 updates 处置): - {carry_forward_json} +refine_user_message: | + kind under review: {kind} + ceiling for this kind: {cap} - 用户画像(用户身份、偏好与约束的背景知识;仅用于判断兴趣相关度与 - confidence——它不是素材,禁止引用进 paths/evidence): - {profile_block} + ## What to reject for this kind - # 本轮素材 + {checks} - {material_blob} + ## Context for this kind only (background knowledge, never evidence) - 按契约抽取 follow_ups[extends] / extends / updates。记住:空列表是正常 - 输出,绝不编造主题。 + {context_block} + + ## Candidates to adjudicate + + {candidates_json} + + Return one YAML fenced block with a `verdicts` list, mentioning only the + candidates you want to drop, move or rescore. + +refine_user_message_zh: | + 本次裁决的 kind:{kind} + 该类上限:{cap} + + ## 该类应当驳回什么 + + {checks} + + ## 仅供该类使用的上下文(背景知识,不是证据) + + {context_block} + + ## 待裁决候选 + + {candidates_json} + + 只返回一个 YAML fenced block,其中 `verdicts` 列表只写你要 drop / move / + 重打分的候选。 + +refine_checks_reminder: | + - Drop a reminder whose schedule the registry already shows as done or + cancelled, or whose due value contradicts the registry entry it cites. + - Drop one that is really an open loop on the user's own work (follow_up) or + a topic worth reading about (interest_extend) - but never one anchored on + the external source layer, which no other kind is allowed to quote. + - Keep an external notice or key event even when it also reads as interesting + background: "the user must be told" is exactly this kind's job, and a key + event that materially affects them is not merely reading material. + - Keep a dated reminder even when it looks minor: a clock fact about the + user's own calendar is exactly what this kind exists for. + - A dateless reminder must be an external notice or a key event the user has + to be told today; drop one that is merely interesting background, and + remember only a few dateless entries survive the ceiling. + - Move to follow_up only when the candidate tracks progress on a matter the + user assigned and has no schedule attached. + +refine_checks_reminder_zh: | + - 若注册表已显示该日程完成或取消,或候选给出的到期值与其引用的注册表条目 + 矛盾,drop。 + - 若它其实是用户自己工作的未闭环事项(follow_up),或只是值得阅读的主题 + (interest_extend),drop;但锚定在外部信源上的候选例外——其他任何类都不 + 允许引用外部信源。 + - 外部通知或关键事件即使同时读起来像"有意思的背景资料",也要 keep:"必须 + 告知用户"正是这一类的职责,实质影响用户的关键事件不只是阅读材料。 + - 带日期的提醒即使看起来很小也要 keep:关于用户自己日程的时钟事实正是这一 + 类存在的意义。 + - 无日期的提醒必须是"今天必须告知"的外部通知或关键事件;仅仅是有意思的背景 + 信息就 drop,并记住无日期条目受上限约束、只能留下少数几条。 + - 仅当候选跟踪的是用户指派事项的进展且不带任何日程时,才 move 到 follow_up。 + +refine_checks_follow_up: | + - Drop a candidate that restates a carry_forward topic from the context: + long-lived open loops are maintained through updates, never re-proposed. + - Drop one with no visible open loop (nothing was assigned, asked, or left + unfinished); a general interest is interest_extend. + - Drop one whose "latest progress" is asserted rather than visible in its own + reason. + - Keep a candidate that is really a dated commitment instead of dropping it: + stage 2 cannot attach a due date, so the calendar stays the reminder + branch's job. + +refine_checks_follow_up_zh: | + - 若候选只是复述上下文中的 carry_forward 主题,drop:长期未闭环事项通过 + updates 维护,不应被重新提议。 + - 若看不到任何未闭环动作(没有被指派、没有被提问、没有被搁置的事项),drop; + 泛泛的兴趣属于 interest_extend。 + - 若所谓"最新进展"只是断言、在其自身 reason 里看不到依据,drop。 + - 若候选其实是一个带日期的承诺,keep 而不要 drop:第二阶段无法补上到期日, + 日历始终由 reminder 分支负责。 + +refine_checks_new_idea: | + - Drop an idea that needs a capability, dataset, tool or API absent from the + capability inventory: it would be an offer nobody can fulfil. + - Drop one that is really a task the user already assigned (follow_up) or a + topic to read about (interest_extend). + - Drop one worded as a report of work already done; these stay proposals. + - Keep only ideas naming a concrete deliverable, and rescore vague ones down. + +refine_checks_new_idea_zh: | + - 若想法依赖能力清单中不存在的经验/数据/工具/API,drop:那是没人能兑现的邀约。 + - 若它其实是用户已经指派的任务(follow_up)或只是可读的主题(interest_extend), + drop。 + - 若措辞像"已经做完了的汇报",drop;这些只能是提议。 + - 只保留说清了具体交付物的想法;含糊的把 confidence 打低。 + +refine_checks_interest_extend: | + - Drop a candidate that merely restates a wiki node that already exists in the + inventory: an extend must add a refinement or a new adjacent node. + - Drop one the user has no visible interest in, so there is nothing to extend. + - Drop one that is a to-do or a schedule rather than content worth introducing. + - Move to new_idea only when the candidate is a piece of work to delegate + rather than content to explain. + +refine_checks_interest_extend_zh: | + - 若候选只是复述清单里已存在的 wiki 节点,drop:extend 必须带来细化或新的 + 相邻节点。 + - 若看不到用户的相关兴趣、无从延伸,drop。 + - 若它其实是一条待办或日程,而不是值得介绍的内容,drop。 + - 仅当候选是"可委托的一件工作"而不是"可讲解的内容"时,才 move 到 new_idea。 diff --git a/reme/steps/evolve/proactive/finish.py b/reme/steps/evolve/proactive/finish.py index 6f63fb2d..25e73b4c 100644 --- a/reme/steps/evolve/proactive/finish.py +++ b/reme/steps/evolve/proactive/finish.py @@ -42,7 +42,7 @@ class ProactiveFinishStep(BaseStep): return self.context.response ws = workspace_dir(self) - checkpoint = [rel for rel in state.changed_paths if (ws / rel).is_file()] + checkpoint = [rel for rel in (*state.changed_paths, *state.resource_changed) if (ws / rel).is_file()] snapshot = state.changed_mtimes nodes: list[FileNode] = [] deferred = 0 diff --git a/reme/steps/evolve/proactive/plan.py b/reme/steps/evolve/proactive/plan.py index e29b3af3..38dea621 100644 --- a/reme/steps/evolve/proactive/plan.py +++ b/reme/steps/evolve/proactive/plan.py @@ -1,19 +1,22 @@ """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. +push (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. +explore_interest | prepare_upcoming | propose_delegation), ``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. + +Reminders are expanded first and are never squeezed out by ``max_plan_topics`` +(they are bounded by ``max_reminder_cards`` instead), because the agenda step +pins every reminder card it receives. """ import asyncio @@ -24,12 +27,18 @@ from ...base_step import BaseStep from .._evolve import agent_reply_result_text, passthrough_response from ....components import R from ....schema import ProactiveState +from ....schema.proactive import DEFAULT_KIND 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") +SCENARIO_TYPES = ("resume_task", "answer_pending", "explore_interest", "prepare_upcoming", "propose_delegation") DELIVERY_MODES = ("in_conversation", "notification", "agenda_item") -DEFAULT_SCENARIO_BY_KIND = {"follow_up": "resume_task", "interest_extend": "explore_interest"} +DEFAULT_SCENARIO_BY_KIND = { + "follow_up": "resume_task", + "interest_extend": "explore_interest", + "reminder": "prepare_upcoming", + "new_idea": "propose_delegation", +} MAX_OPENER_CHARS = 300 MAX_NEXT_ACTION_CHARS = 200 MAX_PRECONDITIONS = 5 @@ -52,10 +61,18 @@ def linked_memory(candidate: dict) -> list[str]: 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") + kind = str(candidate.get("kind") or DEFAULT_KIND) title = str(candidate.get("title") or "") - scenario = DEFAULT_SCENARIO_BY_KIND.get(kind, "explore_interest") - if kind == "follow_up": + scenario = DEFAULT_SCENARIO_BY_KIND.get(kind, DEFAULT_SCENARIO_BY_KIND[DEFAULT_KIND]) + if kind == "reminder": + due = str(candidate.get("due_at") or "").strip() + when = f"({due})" if due else "" + opener = f"对了,「{title}」{when}就快到了,要不要现在把准备事项先过一遍?" + next_action = "确认时间、所需材料与是否和其它安排冲突,需要改期就现在说" + elif kind == "new_idea": + opener = f"有件活儿我觉得可以替你办:「{title}」,要不要我先出个方案你看看?" + next_action = "确认值不值得委托;值得的话由 agent 先产出一版草案供你审阅" + elif kind == "follow_up": opener = f"上次聊到「{title}」,好像还没收尾——要不要趁现在花几分钟往前推一步?" next_action = "先回顾相关记录,确认卡点,然后给出下一步动作" else: @@ -105,14 +122,19 @@ def _clean_card(raw: dict, candidate: dict) -> dict: 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. + Zero LLM calls when there are no push candidates. The two budgets are + independent: at most ``max_reminder_cards`` reminders plus + ``max_plan_topics`` discovery candidates are expanded. A reminder-heavy day + therefore cannot starve the discovery kinds of cards, because reminders are + pinned into the agenda later and would otherwise take every slot with them. + The overflow stays card-less and the agenda step suppresses it with an + explicit reason. """ def __init__( self, max_plan_topics: int = 6, + max_reminder_cards: int = 8, llm_timeout_seconds: float = 120, profile_max_chars: int = 800, skip_key: str = "proactive_skip", @@ -120,6 +142,7 @@ class ProactivePlanStep(BaseStep): ): super().__init__(**kwargs) self.max_plan_topics = max(int(max_plan_topics), 1) + self.max_reminder_cards = max(int(max_reminder_cards), 1) self.llm_timeout_seconds = float(llm_timeout_seconds) self.profile_max_chars = max(int(profile_max_chars), 0) self.skip_key = skip_key @@ -149,10 +172,15 @@ class ProactivePlanStep(BaseStep): self.logger.info(f"[{self.name}] no push candidates; skipping") return self.context.response - selected = candidates[: self.max_plan_topics] + # Reminders first and outside the discovery budget: they pin today's + # agenda, so starving them here would silently break that guarantee. + reminders = [c for c in candidates if str(c.get("kind")) == "reminder"][: self.max_reminder_cards] + others = [c for c in candidates if str(c.get("kind")) != "reminder"] + selected = reminders + others[: 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}", + f"reminders={len(reminders)} max_plan_topics={self.max_plan_topics} " + f"max_reminder_cards={self.max_reminder_cards}", ) cards_by_id = await self._llm_cards(state, selected) @@ -167,7 +195,7 @@ class ProactivePlanStep(BaseStep): card = { "topic_id": cid, "title": str(candidate.get("title") or ""), - "kind": str(candidate.get("kind") or "interest_extend"), + "kind": str(candidate.get("kind") or DEFAULT_KIND), **card, "linked_memory": linked_memory(candidate), } @@ -203,6 +231,8 @@ class ProactivePlanStep(BaseStep): "confidence": c.get("confidence"), "first_seen": c.get("first_seen"), "evidence": c.get("evidence"), + **({"due_at": c.get("due_at")} if c.get("due_at") else {}), + **({"schedule_id": c.get("schedule_id")} if c.get("schedule_id") else {}), } for c in selected ], diff --git a/reme/steps/evolve/proactive/plan.yaml b/reme/steps/evolve/proactive/plan.yaml index 5e416141..bb634fbf 100644 --- a/reme/steps/evolve/proactive/plan.yaml +++ b/reme/steps/evolve/proactive/plan.yaml @@ -12,25 +12,39 @@ plan_system_prompt: | - `resume_task`: a task that was started but interrupted/postponed; - `answer_pending`: a question that was asked but never answered; - `explore_interest`: an interest the user has not dived into yet; - - `prepare_upcoming`: something with a future time anchor to prepare for. - follow_up topics become resume_task / answer_pending / prepare_upcoming; - interest_extend topics become explore_interest / prepare_upcoming. Pick by - semantics; use a time anchor whenever the topic carries one. + - `prepare_upcoming`: something with a future time anchor to prepare for; + - `propose_delegation`: a piece of work worth handing over to the agent. + Map by kind first, then refine by semantics: + - `follow_up` -> resume_task / answer_pending; the card offers to fetch the + latest status and report back to the user; + - `interest_extend` -> explore_interest; the card offers to walk the user + through a refinement or extension of a topic they care about; + - `reminder` -> prepare_upcoming, naming the due date (and the clash to + resolve when the topic mentions one); + - `new_idea` -> propose_delegation, naming the deliverable the agent would + produce - an offer, never a report of work already done. - `opener`: one casual spoken sentence, like a friend happening to mention it ("by the way..."). STRICTLY forbidden: notification tone such as "based on your previous records", "the system reminds you", "as you mentioned on <date>". The opener must already point at the minimal executable next action, so it invites doing, not just knowing. + A reminder opener still sounds like a person, but it may not hide the date: + the user has to hear when it is due. - `next_action`: the smallest concrete step the user can start right now. - `preconditions`: list of things that must hold first; empty list is fine. - `delivery`: `in_conversation` (weave into talk), `notification` (standalone nudge) or `agenda_item` (belongs on today's agenda). + Reminders with a near or clashing due date belong on `notification` or + `agenda_item`, not buried in conversation. ## Hard rules - Exactly one card per input topic, no extras. - Ground everything in the given reason/evidence; never invent facts. + - Proposals stay proposals: never word a card as if the work were done. - Quote YAML strings containing punctuation such as `:`. + - Write `opener` and `next_action` in the language the user's own material is + written in, so the assistant never switches tongues mid-conversation. ## Output format @@ -38,7 +52,7 @@ plan_system_prompt: | ```yaml cards: - topic_id: <echoed id> - scenario_type: resume_task|answer_pending|explore_interest|prepare_upcoming + scenario_type: resume_task|answer_pending|explore_interest|prepare_upcoming|propose_delegation opener: <one casual sentence pointing at the next action> next_action: <smallest executable step> preconditions: [<condition>, ...] @@ -53,27 +67,40 @@ plan_system_prompt_zh: | ## 每张卡的字段 - `topic_id`:原样回显给定 id。 - - `scenario_type`:四选一 + - `scenario_type`:五选一 - `resume_task`:开始过但被中断/搁置的任务; - `answer_pending`:提出过但没有得到回答的问题; - `explore_interest`:用户尚未深入的兴趣方向; - - `prepare_upcoming`:带未来时间点、需要提前准备的事项。 - follow_up 主题对应 resume_task / answer_pending / prepare_upcoming; - interest_extend 主题对应 explore_interest / prepare_upcoming。按语义选择; - 主题带时间线索时优先 prepare_upcoming。 + - `prepare_upcoming`:带未来时间点、需要提前准备的事项; + - `propose_delegation`:一件值得委托给 agent 去办的工作。 + 先按 kind 映射,再按语义细化: + - `follow_up` -> resume_task / answer_pending(卡片要表达"我去查最新进展 + 再向你汇报"); + - `interest_extend` -> explore_interest(卡片要表达"带你看看这个主题的 + 细化/延伸内容"); + - `reminder` -> prepare_upcoming,必须点明到期时间,若主题提到冲突还要 + 点明要先协调哪一件事; + - `new_idea` -> propose_delegation,说清 agent 会交付什么——是邀约, + 绝不能写成已经做完的汇报。 - `opener`:一句口语化的开场,像朋友随口提起("对了,上次那个……")。 严禁通知腔:"根据您之前的记录"、"系统提醒您"、"您在某天提到过"等。 开场必须顺势给出最小可执行的下一步,让人愿意动手,而不只是知道。 + reminder 的开场同样要像人话,但不能藏时间:用户必须听到"什么时候到期"。 - `next_action`:用户现在就能开始的最小具体动作。 - `preconditions`:需要先满足的条件列表,可以为空。 - `delivery`:`in_conversation`(对话中自然带出)/ `notification` (单独提醒)/ `agenda_item`(列入当日议程)。 + 临近到期或存在冲突的 reminder 应用 `notification` 或 `agenda_item`, + 不要埋在闲聊里。 ## 硬约束 - 每个输入主题恰好一张卡,不得多给。 - 所有内容必须基于给定的 reason/evidence,禁止编造事实。 + - 提议就是提议:任何卡片都不得写成工作已经完成。 - 包含 `:` 等标点的 YAML 字符串请加引号。 + - `opener` 与 `next_action` 使用用户素材本身所用的语言书写,避免助手在对话 + 中途切换语言。 ## 输出格式 @@ -81,7 +108,7 @@ plan_system_prompt_zh: | ```yaml cards: - topic_id: <回显的 id> - scenario_type: resume_task|answer_pending|explore_interest|prepare_upcoming + scenario_type: resume_task|answer_pending|explore_interest|prepare_upcoming|propose_delegation opener: <一句口语化开场,顺势给出下一步动作> next_action: <最小可执行步骤> preconditions: [<条件>, ...] diff --git a/reme/steps/evolve/proactive/proactive.py b/reme/steps/evolve/proactive/proactive.py index b84f89a3..a5a3ec71 100644 --- a/reme/steps/evolve/proactive/proactive.py +++ b/reme/steps/evolve/proactive/proactive.py @@ -27,6 +27,22 @@ from .utils import ( ) +def _above_floor(topic, min_confidence: float) -> bool: + """Confidence floor check; clock-anchored reminders are exempt. + + A reminder carrying a due value is a clock fact about the user's own + schedule, not a discovery guess, and the refresh chain already applied the + (lower) ``min_reminder_confidence`` when it pinned it to the agenda. + Filtering it again here would silently drop today's agenda item. A dateless + reminder - an external notice or a key event - is a judgement call, so it + still has to clear the reader's floor. + """ + get = topic.get if isinstance(topic, dict) else (lambda key: getattr(topic, key, "")) + if str(get("kind") or "") == "reminder" and str(get("due_at") or "").strip(): + return True + return float(get("confidence") or 0.0) >= min_confidence - 1e-9 + + @R.register("proactive_step") class ProactiveStep(BaseStep): """Read ``daily/<date>/interests.yaml`` (optionally merged over a horizon). @@ -119,7 +135,7 @@ class ProactiveStep(BaseStep): kept = [ dump_topic(t) for t in sort_topics(topics) - if t.id not in resolved_ids and t.confidence >= min_confidence - 1e-9 + if t.id not in resolved_ids and _above_floor(t, min_confidence) ] result.topics = kept agenda_raw = data.get("agenda") @@ -142,7 +158,16 @@ class ProactiveStep(BaseStep): # Multi-day merge (horizon_days > 1, or fallback target) # ------------------------------------------------------------------ - def _read_horizon(self, ws, daily, day, horizon, include_content, min_confidence, result: ProactiveResult): + 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), @@ -171,7 +196,7 @@ class ProactiveStep(BaseStep): 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 + and _above_floor(t, min_confidence) ) ] kept = sort_topics(kept) diff --git a/reme/steps/evolve/proactive/refine.py b/reme/steps/evolve/proactive/refine.py new file mode 100644 index 00000000..3503884c --- /dev/null +++ b/reme/steps/evolve/proactive/refine.py @@ -0,0 +1,196 @@ +"""Stage-2 per-kind adjudication for proactive extraction (precision pass). + +Stage 1 owns recall: one batched reply over the changed material produces the +candidates of all four kinds. Stage 2 owns precision: every enabled kind that +actually produced candidates gets one small, material-free call that re-checks +those candidates against the kind's own contract plus the only context slice +that kind needs (registry and clock for reminders, carry-forward topics for +follow-ups, capability/wiki inventory for ideas and extends). + +A pass may keep, drop or move a candidate and rescore its confidence. It never +rewrites wording - stage 1 owns the user-facing text - and it never invents a +candidate. Every failure mode is fail-open: a timeout or an unusable reply keeps +the stage-1 candidates untouched, because losing a real finding costs more than +keeping a borderline one. +""" + +from ....schema.proactive import TOPIC_KINDS, clamp_confidence +from .utils import parse_fenced_yaml + +# Refine order = proactive priority order, which keeps the audit log readable: +# the highest-priority kind is adjudicated first. +REFINE_ORDER = ("reminder", "follow_up", "new_idea", "interest_extend") +DECISIONS = ("keep", "drop", "move") + +# Fields only a reminder candidate carries. A move out of ``reminder`` has to +# drop them, otherwise the receiving kind publishes a ``schedule_id`` pointing +# at a registry entry that was never created plus a due date nothing fires on. +REMINDER_ONLY_FIELDS = ("schedule_id", "due_at", "next_due", "trigger", "conflict_with") + + +def _notice_anchored(item: dict, notice_paths: set[str]) -> bool: + """True when the candidate cites the external source (``resource/``) layer.""" + return bool({str(p) for p in (item.get("paths") or [])} & notice_paths) + + +def _reminder_contract_met(item: dict, notice_paths: set[str]) -> bool: + """``clean_reminder``'s traceability rule: a due value or a notice anchor.""" + return bool(str(item.get("due_at") or "").strip()) or _notice_anchored(item, notice_paths) + + +def candidates_by_kind(state) -> list[tuple[str, list[dict]]]: + """``(kind, bucket)`` pairs in refine order, skipping empty buckets.""" + buckets = kind_buckets(state) + return [(kind, buckets[kind]) for kind in REFINE_ORDER if buckets[kind]] + + +def kind_buckets(state) -> dict[str, list[dict]]: + """Map each kind to the ``ProactiveState`` bucket holding its candidates.""" + return { + "reminder": list(state.reminders), + "follow_up": list(state.follow_ups), + "new_idea": list(state.new_ideas), + "interest_extend": list(state.extends), + } + + +def candidate_payload(bucket: list[dict]) -> list[dict]: + """Candidate fields worth adjudicating; no material, paths trimmed.""" + out = [] + for item in bucket: + payload = { + "id": str(item.get("id") or ""), + "title": str(item.get("title") or ""), + "reason": str(item.get("reason") or ""), + "confidence": clamp_confidence(item.get("confidence")), + "paths": [str(p) for p in (item.get("paths") or [])][:3], + } + for key in ("due_at", "schedule_id", "trigger"): + if str(item.get(key) or "").strip(): + payload[key] = str(item.get(key)) + conflicts = item.get("conflict_with") + if isinstance(conflicts, list) and conflicts: + payload["conflict_with"] = [str(c) for c in conflicts][:3] + out.append(payload) + return out + + +def parse_refine_reply(text: str) -> list[dict] | None: + """Verdict list from a refine reply; ``None`` when the reply is unusable. + + An empty list is a valid reply ("keep everything"); only a missing or + non-list ``verdicts`` key counts as unusable, which lets the caller keep + the stage-1 candidates instead of silently dropping a whole kind. + """ + payload = parse_fenced_yaml(text) + if "verdicts" not in payload: + return None + verdicts = payload["verdicts"] + if verdicts is None: + # `verdicts:` with nothing beneath it is how YAML spells the empty list, + # and an unmentioned candidate is kept - so this is the documented + # "nothing to change" reply, not a broken one. Counting it as unusable + # would flag the most common clean round as a degrade and hide a real + # stage-2 failure behind the noise. + return [] + if not isinstance(verdicts, list): + return None + out = [] + for raw in verdicts: + if not isinstance(raw, dict): + continue + cid = str(raw.get("id") or "").strip() + if not cid: + continue + decision = str(raw.get("decision") or "keep").strip().lower() + verdict = { + "id": cid, + "decision": decision if decision in DECISIONS else "keep", + "to_kind": str(raw.get("to_kind") or "").strip(), + "reason": str(raw.get("reason") or "").strip()[:200], + } + if raw.get("confidence") is not None: + verdict["confidence"] = clamp_confidence(raw.get("confidence")) + out.append(verdict) + return out + + +def apply_verdicts( + state, + verdicts: list[dict], + caps: dict[str, int], + enabled: set[str], + notice_paths: set[str] | None = None, +) -> None: + """Rebuild the four buckets from the stage-2 verdicts, in place on ``state``. + + A missing verdict means keep (fail-open), an unknown id is ignored, and a + move to an unknown or disabled kind is ignored rather than fatal. Caps are + re-applied afterwards in stage-1 order, so a move can never push a kind over + its ceiling. + + Two moves are refused rather than applied, because stage 1 owns the + path/kind invariants and stage 2 must not break them: + + * out of ``reminder`` for a candidate anchored on the external notice + layer - only the reminder branch may cite ``resource/``, so demoting it + would publish an external upload under a kind that may not quote one; + * into ``reminder`` for a candidate with neither a due value nor a notice + anchor - stage 2 cannot attach a date, so it could never satisfy + ``clean_reminder``'s contract for a dateless reminder. + + Any other successful move out of ``reminder`` strips the reminder-only + fields so no dangling registry link survives the reclassification. + """ + notices = {str(p) for p in (notice_paths or set())} + buckets = kind_buckets(state) + index: dict[str, tuple[str, dict]] = {} + for kind, bucket in buckets.items(): + for item in bucket: + index.setdefault(str(item.get("id") or ""), (kind, item)) + for verdict in verdicts: + entry = index.get(verdict["id"]) + if entry is None: + continue + kind, item = entry + if verdict["decision"] == "drop": + buckets[kind] = [c for c in buckets[kind] if c is not item] + state.refine_dropped.append(_audit(verdict, kind, item)) + elif verdict["decision"] == "move": + target = verdict["to_kind"] + if target not in TOPIC_KINDS or target not in enabled or target == kind: + continue + if kind == "reminder" and _notice_anchored(item, notices): + continue + if target == "reminder" and not _reminder_contract_met(item, notices): + continue + buckets[kind] = [c for c in buckets[kind] if c is not item] + item["kind"] = target + if kind == "reminder": + for field in REMINDER_ONLY_FIELDS: + item.pop(field, None) + buckets[target].append(item) + index[verdict["id"]] = (target, item) + state.refine_moved.append({**_audit(verdict, kind, item), "to_kind": target}) + if "confidence" in verdict: + item["confidence"] = verdict["confidence"] + for kind, bucket in buckets.items(): + cap = int(caps.get(kind, 0)) + if cap and len(bucket) > cap: + state.dropped_over_cap += len(bucket) - cap + buckets[kind] = bucket[:cap] + state.reminders = buckets["reminder"] + state.follow_ups = buckets["follow_up"] + state.new_ideas = buckets["new_idea"] + state.extends = buckets["interest_extend"] + + +def _audit(verdict: dict, kind: str, item: dict) -> dict: + """One audit record so a refine decision stays diagnosable in metadata.""" + return { + "id": verdict["id"], + "kind": kind, + "title": str(item.get("title") or "")[:80], + "decision": verdict["decision"], + "reason": verdict["reason"], + } diff --git a/reme/steps/evolve/proactive/schedules.py b/reme/steps/evolve/proactive/schedules.py new file mode 100644 index 00000000..9f81ad39 --- /dev/null +++ b/reme/steps/evolve/proactive/schedules.py @@ -0,0 +1,1008 @@ +"""Long-lived reminder registry: due-clock maths, maintenance and candidates. + +The registry is the ``schedules`` section of the ``daily/_proactive.yaml`` +truth source. It exists because a schedule mentioned weeks ago still has to +fire on its due date - long after the originating daily note left the extract +scan window and long after the matching topic was trimmed by +``carry_forward_days``. Every change the pipeline observes is appended to the +entry's ``history``, so a reschedule or a cancellation stays auditable. + +Everything here is deterministic: no LLM calls and no file writes. The extract +step owns the clock scan (``schedule_view`` / ``reminder_candidates_from_view``) +and the topics step owns persistence (``apply_schedule_updates``, +``upsert_schedule_from_reminder``, ``trim_schedules`` followed by +``save_state``). +""" + +import calendar +import datetime as dt +import re + +from ....schema import ProactiveSchedule, ProactiveStateFile, ProactiveTopic +from ....schema.proactive import ( + RECURRENCES, + REMINDER_TRIGGERS, + SCHEDULE_ACTIONS, + clamp_confidence, +) +from ....utils import get_logger +from ..dream.utils import clean_paths +from .utils import norm_path, safe_date, topic_id + +logger = get_logger(log_to_file=False) + + +# Triggers that deserve a reminder; ``later``/``unscheduled``/``closed`` stay +# in the prompt view but never become candidates. + + +DUE_TRIGGERS = REMINDER_TRIGGERS + + +_RECURRING = ("daily", "weekly", "monthly", "yearly") + + +_ISO_DUE_RE = re.compile( + r"^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T\s]+(\d{1,2}):(\d{2})(?::(\d{2}))?)?\s*(?:[+-]\d{2}:?\d{2}|Z)?$", +) + + +_CJK_DATE_RE = re.compile(r"(\d{4})\s*年\s*(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?") + + +_CJK_MONTH_DAY_RE = re.compile(r"(?<![\d年.])(\d{1,2})\s*月\s*(\d{1,2})\s*[日号]?") + + +_CJK_CLOCK_RE = re.compile(r"(\d{1,2})\s*[点时::]\s*(\d{1,2})?\s*分?") + + +# --------------------------------------------------------------------------- +# Due-value parsing and recurrence maths +# --------------------------------------------------------------------------- + + +def _safe_ymd(year: int, month: int, day: int) -> dt.date | None: + try: + return dt.date(year, month, day) + except ValueError: + return None + + +def _safe_clock(text: str) -> tuple[int, int] | None: + """First sane ``HH:MM`` inside a due string; None when there is no time.""" + for match in re.finditer(r"(?:^|[T\s])(\d{1,2}):(\d{2})(?::\d{2})?", text): + hour, minute = int(match.group(1)), int(match.group(2)) + if hour <= 23 and minute <= 59: + return hour, minute + match = _CJK_CLOCK_RE.search(text) + if match: + hour = int(match.group(1)) + minute = int(match.group(2) or 0) + if hour <= 23 and minute <= 59: + return hour, minute + return None + + +def parse_when(text: str, reference: dt.date | None = None) -> tuple[dt.date | None, dt.datetime | None]: + """Parse a due value into ``(date, datetime|None)``; ``(None, None)`` when unusable. + + Accepts ISO dates/datetimes (``2026-10-12``, ``2026-10-12T09:00``, + ``2026-10-12 09:00:00+08:00``) plus the CJK forms an LLM tends to echo + from the material (``2026年10月12日 09:00``, ``10月12日``). A year-less + value resolves to its next occurrence at or after ``reference``. The + ``datetime`` element is only set when the value carries a clock time, which + is what makes conflict detection time-aware instead of day-granular. + """ + raw = str(text or "").strip() + if not raw: + return None, None + due_date: dt.date | None = None + match = _ISO_DUE_RE.match(raw) + if match: + due_date = _safe_ymd(int(match.group(1)), int(match.group(2)), int(match.group(3))) + if due_date is None and (match := _CJK_DATE_RE.search(raw)): + due_date = _safe_ymd(int(match.group(1)), int(match.group(2)), int(match.group(3))) + if due_date is None and reference is not None and (match := _CJK_MONTH_DAY_RE.search(raw)): + month, day_number = int(match.group(1)), int(match.group(2)) + for year in (reference.year, reference.year + 1): + candidate = _safe_ymd(year, month, day_number) + if candidate is not None and candidate >= reference: + due_date = candidate + break + if due_date is None: + return None, None + clock = _safe_clock(raw) + if clock is None: + return due_date, None + return due_date, dt.datetime.combine(due_date, dt.time(clock[0], clock[1])) + + +def canonical_due(raw, reference: dt.date | None = None) -> str: + """Normalize a due value to ``YYYY-MM-DD`` or ``YYYY-MM-DDTHH:MM``. + + An unparsable value is kept as a short raw hint (the registry entry then + simply stays ``unscheduled`` for the clock scan) so the user's wording is + never silently dropped. + """ + text = str(raw or "").strip() + if not text: + return "" + due_date, due_dt = parse_when(text, reference) + if due_date is None: + return text[:40] + if due_dt is not None: + return f"{due_date.isoformat()}T{due_dt.strftime('%H:%M')}" + return due_date.isoformat() + + +def _add_months(base: dt.date, months: int) -> dt.date: + total = base.month - 1 + max(int(months), 0) + year, month = base.year + total // 12, total % 12 + 1 + return _safe_ymd(year, month, min(base.day, calendar.monthrange(year, month)[1])) or base + + +# pylint: disable=too-many-return-statements +def next_occurrence(due: dt.date, recurrence: str, reference: dt.date) -> dt.date: + """First occurrence of a (possibly recurring) due date at or after ``reference``.""" + rule = str(recurrence or "").strip().lower() + if rule not in _RECURRING or due >= reference: + return due + elapsed = (reference - due).days + if rule == "daily": + return due + dt.timedelta(days=elapsed) + if rule == "weekly": + return due + dt.timedelta(weeks=-(-elapsed // 7)) + if rule == "monthly": + months = (reference.year - due.year) * 12 + (reference.month - due.month) + for step in (months, months + 1, months + 2): + candidate = _add_months(due, step) + if candidate >= reference: + return candidate + return _add_months(due, months + 3) + years = reference.year - due.year + for step in (years, years + 1, years + 2): + candidate = _add_months(due, max(step, 0) * 12) + if candidate >= reference: + return candidate + return _add_months(due, (years + 3) * 12) + + +def _schedule_window(schedule: ProactiveSchedule, reference: dt.date) -> tuple[dt.datetime, dt.datetime, bool] | None: + """``(start, end, timed)`` of the next occurrence; None when undated. + + A dated-only schedule spans its whole day (``timed=False``); a schedule + with a clock time is a point unless ``due_end_at`` widens it. Conflicts + need at least one timed side, so two all-day items on the same date are a + busy day rather than a clash. + """ + due_date, due_dt = parse_when(str(schedule.due_at or ""), reference) + if due_date is None: + return None + rolled = next_occurrence(due_date, schedule.recurrence, reference) + shift = dt.timedelta(days=(rolled - due_date).days) + timed = due_dt is not None + start = (due_dt + shift) if timed else dt.datetime.combine(rolled, dt.time.min) + end_date, end_dt = parse_when(str(schedule.due_end_at or ""), reference) + if end_date is None: + end = start if timed else dt.datetime.combine(rolled, dt.time.max) + else: + end = (end_dt + shift) if end_dt is not None else dt.datetime.combine(end_date + shift, dt.time.max) + return start, max(end, start), timed + + +def _as_schedule(entry) -> ProactiveSchedule: + return entry if isinstance(entry, ProactiveSchedule) else ProactiveSchedule.model_validate(entry or {}) + + +# --------------------------------------------------------------------------- +# Registry rendering +# --------------------------------------------------------------------------- + + +def dump_schedule(schedule) -> dict: + """Render one registry entry as an ordered dict (state file + prompts).""" + get = (lambda k: getattr(schedule, k)) if not isinstance(schedule, dict) else schedule.get + return { + "id": str(get("id") or ""), + "title": str(get("title") or ""), + "detail": str(get("detail") or ""), + "due_at": str(get("due_at") or ""), + "due_end_at": str(get("due_end_at") or ""), + "recurrence": str(get("recurrence") or ""), + "lead_days": int(get("lead_days") or 0), + "status": str(get("status") or "open"), + "confidence": clamp_confidence(get("confidence")), + "first_seen": str(get("first_seen") or ""), + "last_seen_at": str(get("last_seen_at") or ""), + "evidence": str(get("evidence") or "")[:120], + "paths": [str(p) for p in (get("paths") or [])], + "history": [dict(item) for item in (get("history") or []) if isinstance(item, dict)], + } + + +def slim_schedule(schedule) -> dict: + """Registry entry without the bulky history/paths/detail (metadata + logs).""" + dumped = dump_schedule(schedule) + for key in ("history", "paths", "detail"): + dumped.pop(key, None) + return dumped + + +# --------------------------------------------------------------------------- +# Clock view: due state and conflicts +# --------------------------------------------------------------------------- + + +def schedule_view(schedules, now_dt, default_lead_days: int = 3) -> list[dict]: + """Clock-relative view of the registry: due state plus conflicts per entry. + + ``trigger`` is one of ``overdue`` / ``due_today`` / ``conflict`` / + ``due_soon`` / ``later`` / ``unscheduled`` / ``closed``; the first four are + the ones that deserve a reminder (``DUE_TRIGGERS``). + """ + reference = now_dt.date() if isinstance(now_dt, dt.datetime) else now_dt + lead_default = max(int(default_lead_days), 0) + entries: list[dict] = [] + for raw in schedules or []: + try: + schedule = _as_schedule(raw) + except Exception: # noqa: BLE001 - a malformed entry never breaks the scan + continue + window = _schedule_window(schedule, reference) + due_in_days = (window[0].date() - reference).days if window is not None else None + lead = int(schedule.lead_days or 0) or lead_default + if schedule.status != "open": + trigger = "closed" + elif window is None: + trigger = "unscheduled" + elif due_in_days < 0: + trigger = "overdue" + elif due_in_days == 0: + trigger = "due_today" + elif due_in_days <= lead: + trigger = "due_soon" + else: + trigger = "later" + entries.append({"schedule": schedule, "window": window, "due_in_days": due_in_days, "trigger": trigger}) + + attention = [e for e in entries if e["trigger"] in ("overdue", "due_today", "due_soon")] + for entry in attention: + start, end, timed = entry["window"] + conflicts: list[str] = [] + for other in entries: + if other is entry or other["schedule"].status != "open" or other["window"] is None: + continue + other_start, other_end, other_timed = other["window"] + if not (timed or other_timed): + continue + if start <= other_end and other_start <= end: + title = str(other["schedule"].title or "").strip() + if title and title not in conflicts: + conflicts.append(title) + entry["conflict_with"] = conflicts + + # A clash outranks plain proximity (it needs coordination, not just + # preparation), but one clash is announced once: the earliest occurrence + # becomes the ``conflict`` reminder and the other sides stay ``due_soon`` + # while still naming the overlap, so a busy day does not repeat itself. + announced: set[str] = set() + for entry in sorted(attention, key=lambda e: (e["window"][0], str(e["schedule"].id or ""))): + conflicts = [str(title) for title in (entry.get("conflict_with") or [])] + if conflicts and entry["trigger"] == "due_soon" and not all(title in announced for title in conflicts): + entry["trigger"] = "conflict" + if entry["trigger"] == "conflict": + announced.add(str(entry["schedule"].title or "")) + announced.update(conflicts) + + view: list[dict] = [] + for entry in entries: + schedule = entry["schedule"] + dumped = dump_schedule(schedule) + window = entry["window"] + if window is None: + dumped["next_due"] = "" + elif window[2]: # timed: keep the clock time so same-day reminders sort by hour + dumped["next_due"] = window[0].strftime("%Y-%m-%dT%H:%M") + else: + dumped["next_due"] = window[0].date().isoformat() + dumped["due_in_days"] = entry["due_in_days"] + dumped["trigger"] = entry["trigger"] + dumped["conflict_with"] = entry.get("conflict_with") or [] + view.append(dumped) + return view + + +SCHEDULE_PROMPT_KEYS = ( + "id", + "title", + "detail", + "status", + "due_at", + "due_end_at", + "recurrence", + "lead_days", + "confidence", + "next_due", + "due_in_days", + "trigger", + "conflict_with", + "first_seen", + "last_seen_at", + "evidence", +) + + +def schedule_prompt_entries(view: list[dict], closed_lookback_days: int = 7, reference: str = "") -> list[dict]: + """Slim registry view for prompts: every open entry plus recent closures. + + Recently closed entries stay visible so the model does not re-add a + schedule it already marked done/cancelled. + """ + cutoff = "" + if base := safe_date(reference): + cutoff = (base - dt.timedelta(days=max(int(closed_lookback_days), 0))).isoformat() + entries: list[dict] = [] + for item in view: + status = str(item.get("status") or "open") + if status != "open": + if not cutoff or str(item.get("last_seen_at") or "") < cutoff: + continue + entries.append({key: item.get(key) for key in SCHEDULE_PROMPT_KEYS}) + return entries + + +# --------------------------------------------------------------------------- +# Clock-driven reminder candidates +# --------------------------------------------------------------------------- + + +def _reminder_reason(entry: dict) -> str: + """Human-readable why-now text for a clock-driven reminder candidate.""" + title = str(entry.get("title") or "") + trigger = str(entry.get("trigger") or "") + due = str(entry.get("next_due") or entry.get("due_at") or "") + days = entry.get("due_in_days") + days_text = "" if days is None else str(abs(int(days))) + if trigger == "overdue": + text = f"『{title}』原定 {due},已过去 {days_text} 天仍未看到结果,需要确认是已完成还是要改期" + elif trigger == "due_today": + text = f"『{title}』就在今天({due}),需要提前留出时间并确认准备事项" + elif trigger == "conflict": + text = f"『{title}』安排在 {due}(还有 {days_text} 天),与其它日程时间重叠,需要先协调" + else: + text = f"『{title}』安排在 {due}(还有 {days_text} 天),可以提前准备" + conflicts = [str(item) for item in (entry.get("conflict_with") or []) if str(item).strip()] + if conflicts and trigger != "conflict": + text += ";与" + "、".join(f"『{c}』" for c in conflicts[:3]) + "时间重叠" + if detail := str(entry.get("detail") or "").strip(): + text += f";备注:{detail[:80]}" + return text + + +def reminder_candidates_from_view(view: list[dict], day: str) -> list[dict]: + """Clock-driven reminder candidates: deterministic, zero LLM calls. + + Guarantees the reminder branch still fires when the LLM is absent, times + out, or simply misses a due schedule. Candidates cite the registry entry + (``schedule_id``) and its historical paths, because their evidence is the + clock rather than this round's changed material. + """ + out: list[dict] = [] + for entry in view: + trigger = str(entry.get("trigger") or "") + if trigger not in DUE_TRIGGERS: + continue + title = str(entry.get("title") or "").strip() + if not title: + continue + confidence = clamp_confidence(entry.get("confidence")) + floor = 0.8 if trigger in ("overdue", "due_today", "conflict") else 0.6 + paths = [str(p) for p in (entry.get("paths") or []) if str(p)] + evidence = str(entry.get("evidence") or "")[:120] or (paths[0][:120] if paths else "") + out.append( + { + "id": topic_id(title), + "title": title, + "reason": _reminder_reason(entry), + "kind": "reminder", + "confidence": min(1.0, max(confidence, floor)), + "first_seen": day, + "last_evidence_at": day, + "evidence": evidence, + "paths": paths, + "schedule_id": str(entry.get("id") or "") or topic_id(title), + "due_at": str(entry.get("due_at") or ""), + # Next occurrence (recurring schedules roll forward): the value + # a reader shows and the pin order sorts by. + "next_due": str(entry.get("next_due") or ""), + "trigger": trigger, + "due_in_days": entry.get("due_in_days"), + "conflict_with": [str(c) for c in (entry.get("conflict_with") or [])][:5], + }, + ) + return sort_reminders(out) + + +def _reminder_key(candidate: dict) -> str: + """Merge key for a reminder candidate: its schedule, else its topic id.""" + return str(candidate.get("schedule_id") or candidate.get("id") or "") + + +def merge_reminders(seeded: list[dict], emitted: list[dict]) -> list[dict]: + """Merge clock-driven reminders with model-emitted ones (one per schedule). + + The registry anchor stays authoritative: ``due_at``/``next_due``/ + ``schedule_id`` come from the clock scan, because moving a due date is the + ``schedule_updates`` channel's job (it is evidence-anchored) and must not + happen as a side effect of wording a reminder. The model's ``reason`` wins + when it read fresh material - it can say why *now* better than the + template - and confidence keeps the stronger of the two signals. + """ + merged: dict[str, dict] = {} + for candidate in seeded or []: + if key := _reminder_key(candidate): + merged[key] = dict(candidate) + for candidate in emitted or []: + key = _reminder_key(candidate) + if not key: + continue + base = merged.get(key) + if base is None: + merged[key] = dict(candidate) + continue + base["reason"] = str(candidate.get("reason") or base.get("reason") or "") + base["confidence"] = max( + clamp_confidence(base.get("confidence")), + clamp_confidence(candidate.get("confidence")), + ) + base["trigger"] = str(candidate.get("trigger") or base.get("trigger") or "") + base["due_at"] = str(base.get("due_at") or candidate.get("due_at") or "") + base["next_due"] = str(base.get("next_due") or candidate.get("next_due") or "") + known = [str(p) for p in (base.get("paths") or [])] + base["paths"] = known + [str(p) for p in (candidate.get("paths") or []) if str(p) not in known] + if not base.get("evidence"): + base["evidence"] = str(candidate.get("evidence") or "")[:120] + clashes = [str(c) for c in (base.get("conflict_with") or [])] + base["conflict_with"] = ( + clashes + [str(c) for c in (candidate.get("conflict_with") or []) if str(c) not in clashes] + )[:5] + return sort_reminders(list(merged.values())) + + +# --------------------------------------------------------------------------- +# Topic bridging (reminder candidates -> truth-source topics) +# --------------------------------------------------------------------------- + + +def effective_due(candidate) -> str: + """Due value a consumer should show: next occurrence, else the raw anchor.""" + get = (lambda k: getattr(candidate, k)) if not isinstance(candidate, dict) else candidate.get + return str(get("next_due") or get("due_at") or "") + + +def sort_reminders(candidates: list) -> list: + """Nearest effective due date first, then confidence desc, then id. + + This is the agenda pin order, so a recurring schedule sorts by its *next* + occurrence (``next_due``) rather than by its original anchor date. + """ + + def get(candidate, key): + return getattr(candidate, key) if not isinstance(candidate, dict) else candidate.get(key) + + def due_key(candidate) -> str: + due_date, due_dt = parse_when(effective_due(candidate)) + if due_dt is not None: + return due_dt.isoformat(timespec="minutes") + return due_date.isoformat() if due_date is not None else "9999-12-31" + + out = sorted(candidates, key=lambda c: str(get(c, "id") or "")) + out.sort(key=lambda c: clamp_confidence(get(c, "confidence")), reverse=True) + out.sort(key=due_key) + return out + + +def cap_notice_reminders(candidates: list[dict], cap: int) -> tuple[list[dict], int]: + """Bound the dateless notice/key-event reminders; clock-anchored ones are free. + + A reminder carrying a due value is a clock fact about the user's own + calendar and stays uncapped (it pins today's agenda). A dateless one is a + judgement call about an external notice or key event, so at most ``cap`` of + them survive a round; returns ``(kept, dropped_over_cap)``. + """ + if cap <= 0: + return list(candidates), 0 + kept: list[dict] = [] + notices = dropped = 0 + for item in candidates: + if str(item.get("next_due") or item.get("due_at") or "").strip(): + kept.append(item) + continue + if notices >= cap: + dropped += 1 + continue + notices += 1 + kept.append(item) + return kept, dropped + + +def topic_from_candidate(candidate: dict) -> ProactiveTopic: + """Truth-source topic for a cleaned candidate. + + Reminders publish the *effective* due date on the topic (what a reader or + the agenda wants to show) while the registry keeps the original anchor plus + its recurrence rule, so a weekly schedule never gets rewritten by its own + reminder. + """ + data = dict(candidate) + if str(data.get("kind") or "") == "reminder": + data["due_at"] = effective_due(data) + return ProactiveTopic.model_validate(data) + + +# --------------------------------------------------------------------------- +# Registry maintenance +# --------------------------------------------------------------------------- + + +def _history_entry( + day: str, + action: str, + from_value: str = "", + to_value: str = "", + reason: str = "", + evidence: str = "", +) -> dict: + """One registry change-log record (kept short; capped by ``trim_schedules``).""" + entry: dict = {"at": day, "action": action} + if from_value or to_value: + entry["from"] = str(from_value or "") + entry["to"] = str(to_value or "") + if reason: + entry["reason"] = str(reason)[:200] + if evidence: + entry["evidence"] = str(evidence)[:120] + return entry + + +def apply_schedule_updates( + state_file: ProactiveStateFile, + updates, + day: str, + new_material: set[str] | None = None, +) -> tuple[dict[str, int], set[str]]: + """Maintain the registry from LLM ``schedule_updates``. + + Returns ``(counts, touched)``. ``touched`` holds every schedule id an + accepted update acted on, so ``upsert_schedule_from_reminder`` can stand + down for those: moving a due date belongs to this evidence-anchored + channel, and a reminder candidate may still carry the value the clock scan + read *before* this round's update landed - applying it would revert the + change the material actually reported. + + State-changing actions must anchor this round's material (same hard rule as + topic ``updates``), otherwise they are skipped with a log: a re-read of an + old note must not be able to rewrite a due date. + """ + counts = {"added": 0, "updated": 0, "closed": 0, "skipped": 0} + touched: set[str] = set() + anchored = {norm_path(p) for p in (new_material or set())} + by_id = {s.id: s for s in state_file.schedules if s.id} + reference = safe_date(day) + for raw in updates or []: + if not isinstance(raw, dict): + counts["skipped"] += 1 + continue + action = str(raw.get("action") or "").strip().lower() + title = str(raw.get("title") or "").strip() + sid = str(raw.get("id") or "").strip() or (topic_id(title) if title else "") + if action not in SCHEDULE_ACTIONS or not sid: + counts["skipped"] += 1 + continue + evidence_raw = str(raw.get("evidence") or "").strip() + evidence_path = norm_path(evidence_raw.split("#", 1)[0]) + schedule = by_id.get(sid) + if schedule is None and action != "add": + logger.info(f"schedule update skipped: unknown id {sid!r} for action={action}") + counts["skipped"] += 1 + continue + if action == "add" and schedule is not None: + action = "reschedule" if str(raw.get("due_at") or "").strip() else "confirm" + if evidence_path not in anchored: + logger.info( + f"schedule update skipped: evidence {evidence_path or '<empty>'!r} for {sid!r} " + f"is not in this round's new material (action={action})", + ) + counts["skipped"] += 1 + continue + if action == "add": + if not title: + counts["skipped"] += 1 + continue + created = ProactiveSchedule( + id=sid, + title=title, + detail=str(raw.get("detail") or "")[:200], + due_at=canonical_due(raw.get("due_at"), reference), + due_end_at=canonical_due(raw.get("due_end_at"), reference), + recurrence=str(raw.get("recurrence") or "").strip().lower(), + lead_days=int(raw.get("lead_days") or 0), + status="open", + confidence=clamp_confidence(raw.get("confidence")), + first_seen=day, + last_seen_at=day, + evidence=evidence_raw[:120], + paths=[str(p) for p in (raw.get("paths") or []) if str(p)], + history=[ + _history_entry( + day, + "add", + "", + str(raw.get("due_at") or ""), + str(raw.get("reason") or ""), + evidence_raw, + ), + ], + ) + state_file.schedules.append(created) + by_id[sid] = created + counts["added"] += 1 + touched.add(sid) + continue + changed = _mutate_schedule(schedule, action, raw, day, reference, evidence_raw) + touched.add(sid) + if action in ("done", "cancel"): + counts["closed"] += 1 + elif changed: + counts["updated"] += 1 + return counts, touched + + +def _mutate_schedule( + schedule: ProactiveSchedule, + action: str, + raw: dict, + day: str, + reference, + evidence_raw: str, +) -> bool: + """Apply one non-``add`` registry action; returns whether anything changed.""" + schedule.last_seen_at = day + reason = str(raw.get("reason") or "")[:200] + changed = False + if action in ("reschedule", "confirm"): + new_due = canonical_due(raw.get("due_at"), reference) + if new_due and new_due != schedule.due_at: + schedule.history.append(_history_entry(day, "reschedule", schedule.due_at, new_due, reason, evidence_raw)) + schedule.due_at = new_due + changed = True + new_end = canonical_due(raw.get("due_end_at"), reference) + if new_end and new_end != schedule.due_end_at: + schedule.due_end_at = new_end + changed = True + recurrence = str(raw.get("recurrence") or "").strip().lower() + if recurrence in RECURRENCES and recurrence != schedule.recurrence: + schedule.recurrence = recurrence + changed = True + if raw.get("lead_days") is not None: + lead = int(raw.get("lead_days") or 0) + if lead != schedule.lead_days: + schedule.lead_days = max(0, min(lead, 365)) + changed = True + if raw.get("detail"): + schedule.detail = str(raw["detail"])[:200] + if raw.get("confidence") is not None: + schedule.confidence = clamp_confidence(raw.get("confidence")) + if evidence_raw: + schedule.evidence = evidence_raw[:120] + if raw.get("paths"): + schedule.paths = [str(p) for p in raw["paths"] if str(p)] + if not changed: + schedule.history.append(_history_entry(day, "confirm", "", "", reason, evidence_raw)) + return changed + status = "done" if action == "done" else "cancelled" + if schedule.status != status: + schedule.history.append(_history_entry(day, action, schedule.status, status, reason, evidence_raw)) + schedule.status = status + changed = True + return changed + + +def refresh_reminder_dues(reminders: list[dict], state_file: ProactiveStateFile, touched: set[str], now_dt) -> int: + """Re-anchor reminders on the due value this round's updates just moved. + + The clock scan reads the registry *before* ``schedule_updates`` land, so a + seeded reminder still carries the pre-update value while the registry has + moved on. A reader sorts and notifies by that field, so it is re-read from + the registry - through the same clock view, which also rolls a recurring + schedule to its next occurrence - instead of publishing a time that no + longer holds. Returns how many reminders were re-anchored. + """ + if not touched: + return 0 + moved = [s for s in state_file.schedules if s.id in touched] + entries = {str(entry.get("id") or ""): entry for entry in schedule_view(moved, now_dt)} + refreshed = 0 + for candidate in reminders or []: + entry = entries.get(str(candidate.get("schedule_id") or "")) + if entry is None: + continue + due = str(entry.get("due_at") or "") + next_due = str(entry.get("next_due") or "") + if not due: + continue + if str(candidate.get("due_at") or "") == due and str(candidate.get("next_due") or "") == next_due: + continue + candidate["due_at"] = due + candidate["next_due"] = next_due + refreshed += 1 + return refreshed + + +def upsert_schedule_from_reminder( + state_file: ProactiveStateFile, + candidate: dict, + day: str, + protected: set[str] | None = None, +) -> str: + """Register/refresh the schedule behind a reminder candidate. + + Safety net for the registry: a reminder that names a due date keeps the + schedule alive even when the model forgot the matching + ``schedule_updates`` entry. Returns ``added``/``updated``/``adopted``/````. + + ``protected`` lists the ids an explicit ``schedule_updates`` entry already + acted on this round. For those the reschedule branch stands down, so this + derived channel can never revert the authoritative one; registration of a + brand-new schedule is unaffected - except that it yields to an explicit + entry that already registered the very same matter (see ``_registered_this_round``). + """ + title = str(candidate.get("title") or "").strip() + if not title: + return "" + sid = str(candidate.get("schedule_id") or "").strip() or topic_id(title) + due_raw = str(candidate.get("due_at") or "").strip() + reference = safe_date(day) + existing = next((s for s in state_file.schedules if s.id == sid), None) + if existing is None: + if not due_raw: + return "" + canonical = canonical_due(due_raw, reference) + adopted = _registered_this_round(state_file, protected or set(), candidate, canonical) + if adopted: + # Publish the authoritative id so the topic, the agenda card and any + # later reader point at a schedule that really exists. + candidate["schedule_id"] = adopted + return "adopted" + state_file.schedules.append( + ProactiveSchedule( + id=sid, + title=title, + detail=str(candidate.get("reason") or "")[:200], + due_at=canonical, + status="open", + confidence=clamp_confidence(candidate.get("confidence")), + first_seen=day, + last_seen_at=day, + evidence=str(candidate.get("evidence") or "")[:120], + paths=[str(p) for p in (candidate.get("paths") or []) if str(p)], + history=[_history_entry(day, "add", "", due_raw, "registered from a reminder candidate", "")], + ), + ) + return "added" + existing.last_seen_at = day + canonical = canonical_due(due_raw, reference) + if canonical and canonical != existing.due_at and sid not in (protected or set()): + existing.history.append( + _history_entry( + day, + "reschedule", + existing.due_at, + canonical, + "reminder candidate carries a new due value", + "", + ), + ) + existing.due_at = canonical + return "updated" + return "" + + +def _registered_this_round(state_file: ProactiveStateFile, protected: set[str], candidate: dict, canonical: str) -> str: + """Id of the schedule an explicit update already registered for this matter. + + The two registration channels name a matter independently, so one notice + routinely arrives under two titles - and therefore two ids. Same evidence + *and* same due value is one matter, and registering it twice would pin two + agenda cards for it while the clock scan reported a schedule clashing with + itself. A notice carrying two genuinely distinct matters keeps both, since + their due values differ. Only ids this round's authoritative channel acted + on are considered, so schedules recorded in earlier rounds are never merged + away by this derived one. + """ + evidence = str(candidate.get("evidence") or "").strip() + if not (protected and evidence and canonical): + return "" + for schedule in state_file.schedules: + if schedule.id not in protected or schedule.status != "open": + continue + if str(schedule.evidence or "").strip() == evidence and str(schedule.due_at or "") == canonical: + return schedule.id + return "" + + +def trim_schedules( + state_file: ProactiveStateFile, + day: str, + retention_days: int = 180, + max_schedules: int = 80, + max_history: int = 8, +) -> int: + """Registry lifecycle: expire hopeless overdues, drop aged entries, cap size. + + Deliberately much longer-lived than ``trim_state_file`` (topics): a + schedule mentioned months ago must still fire on its due date. Entries are + dropped only once they are closed (or unscheduled and unevidenced) for + longer than ``retention_days``, and open entries outrank closed ones under + the ``max_schedules`` cap. + """ + base = safe_date(day) + if base is None: + return 0 + retention = max(int(retention_days), 1) + cutoff = base - dt.timedelta(days=retention) + for schedule in state_file.schedules: + if 0 < max_history < len(schedule.history): + schedule.history = schedule.history[-max_history:] + due_date, _ = parse_when(str(schedule.due_at or ""), base) + if schedule.status == "open" and due_date is not None and due_date < cutoff: + schedule.status = "cancelled" + schedule.last_seen_at = day + schedule.history.append( + _history_entry( + day, + "expire", + schedule.due_at, + "", + f"overdue by more than {retention} days; auto-closed", + "", + ), + ) + kept: list[ProactiveSchedule] = [] + dropped = 0 + for schedule in state_file.schedules: + due_date, _ = parse_when(str(schedule.due_at or ""), base) + anchor = safe_date(schedule.last_seen_at) or due_date + if schedule.status != "open": + if anchor is not None and anchor < cutoff: + dropped += 1 + continue + elif due_date is None and anchor is not None and anchor < cutoff: + dropped += 1 + continue + kept.append(schedule) + limit = max(int(max_schedules), 0) + if limit and len(kept) > limit: + # Open entries (nearest due first) always outrank closed ones, which are + # kept newest-first, so the cap drops the least useful history. + open_entries = sorted((s for s in kept if s.status == "open"), key=lambda s: (str(s.due_at or "9999"), s.id)) + closed_entries = sorted( + (s for s in kept if s.status != "open"), + key=lambda s: str(s.last_seen_at or ""), + reverse=True, + ) + ordered = (open_entries + closed_entries)[:limit] + dropped += len(kept) - len(ordered) + kept = ordered + state_file.schedules = kept + return dropped + + +# --------------------------------------------------------------------------- +# Candidate cleaning (extract side, branch C) +# --------------------------------------------------------------------------- + + +def clean_reminder( + raw, + allowed_paths: set[str], + day: str, + schedules_by_id: dict[str, dict] | None = None, + notice_paths: set[str] | None = None, +) -> dict: + """Clean one LLM reminder candidate (branch C). + + Traceability differs from the other kinds on purpose: a reminder about a + schedule mentioned weeks ago has no path in this round's material, so a + registry-linked reminder may cite the schedule's own historical paths. A + reminder that opens a *new* schedule must still be anchored in + ``changed_paths`` and must carry a due value. + + A reminder anchored on an external notice - a path from ``notice_paths``, + i.e. the ``resource/`` layer - may be dateless, because a key event worth + telling the user about need not have a due date. ``cap_notice_reminders`` + bounds that softer sub-type, so the uncapped, agenda-pinning reminder + channel cannot be flooded with dateless judgement calls. + """ + if not isinstance(raw, dict): + return {} + title = str(raw.get("title") or "").strip() + reason = str(raw.get("reason") or "").strip() + if not title or not reason: + return {} + registry = schedules_by_id or {} + schedule_id_value = str(raw.get("schedule_id") or "").strip() + schedule = registry.get(schedule_id_value) or registry.get(topic_id(title)) or {} + reference = safe_date(day) + due_at = canonical_due(raw.get("due_at"), reference) or str(schedule.get("due_at") or "") + paths = clean_paths(raw.get("paths"), allowed_paths) + for path in [str(p) for p in (schedule.get("paths") or []) if str(p)]: + if path not in paths: + paths.append(path) + notice_anchored = bool(set(paths) & set(notice_paths or ())) + if not schedule and (not paths or (not due_at and not notice_anchored)): + return {} + trigger = str(raw.get("trigger") or "").strip().lower() + if trigger not in REMINDER_TRIGGERS: + trigger = "" + conflicts = raw.get("conflict_with") + evidence = str(raw.get("evidence") or "").strip() or str(schedule.get("evidence") or "") + return { + "id": topic_id(title), + "title": title, + "reason": reason, + "kind": "reminder", + "confidence": clamp_confidence(raw.get("confidence")), + "first_seen": day, + "last_evidence_at": day, + "evidence": evidence[:120] or (paths[0][:120] if paths else ""), + "paths": paths, + "schedule_id": schedule_id_value or str(schedule.get("id") or "") or topic_id(title), + "due_at": due_at, + "next_due": due_at, + "trigger": trigger, + "conflict_with": ( + [str(item).strip()[:80] for item in conflicts if str(item).strip()][:5] + if isinstance(conflicts, list) + else [] + ), + } + + +def clean_schedule_update(raw, allowed_paths: set[str], day: str) -> dict: + """Normalize one LLM ``schedule_updates`` entry (registry maintenance).""" + if not isinstance(raw, dict): + return {} + action = str(raw.get("action") or "").strip().lower() + if action not in SCHEDULE_ACTIONS: + return {} + title = str(raw.get("title") or "").strip() + sid = str(raw.get("id") or "").strip() or (topic_id(title) if title else "") + if not sid: + return {} + reference = safe_date(day) + update: dict = { + "id": sid, + "action": action, + "title": title, + "reason": str(raw.get("reason") or "").strip()[:200], + "evidence": str(raw.get("evidence") or "").strip(), + "paths": clean_paths(raw.get("paths"), allowed_paths), + } + if str(raw.get("due_at") or "").strip(): + update["due_at"] = canonical_due(raw.get("due_at"), reference) + if str(raw.get("due_end_at") or "").strip(): + update["due_end_at"] = canonical_due(raw.get("due_end_at"), reference) + recurrence = str(raw.get("recurrence") or "").strip().lower() + if recurrence in RECURRENCES: + update["recurrence"] = recurrence + if raw.get("lead_days") is not None: + try: + update["lead_days"] = max(0, min(int(raw.get("lead_days")), 365)) + except (TypeError, ValueError): + pass + if str(raw.get("detail") or "").strip(): + update["detail"] = str(raw["detail"]).strip()[:200] + if raw.get("confidence") is not None: + update["confidence"] = clamp_confidence(raw.get("confidence")) + return update diff --git a/reme/steps/evolve/proactive/topics.py b/reme/steps/evolve/proactive/topics.py index 9bf5df75..b445fc6f 100644 --- a/reme/steps/evolve/proactive/topics.py +++ b/reme/steps/evolve/proactive/topics.py @@ -23,8 +23,18 @@ from .._evolve import passthrough_response from ....components import R from ....enumeration import ComponentEnum from ....schema import ProactiveState, ProactiveStateFile, ProactiveTopic -from ....schema.proactive import clamp_confidence +from ....schema.proactive import DEFAULT_KIND, clamp_confidence from ..dream.utils import daily_dir, previous_dates, today, workspace_dir +from .schedules import ( + apply_schedule_updates, + effective_due, + refresh_reminder_dues, + slim_schedule, + sort_reminders, + topic_from_candidate, + trim_schedules, + upsert_schedule_from_reminder, +) from .utils import ( current_now, dump_topic, @@ -61,6 +71,10 @@ class ProactiveTopicsStep(BaseStep): dedup_lookback_days: int = 7, digest_compare_limit: int = 500, known_threshold_calibrated_for: str = "text-embedding-v4@1024", + min_reminder_confidence: float = 0.3, + schedule_retention_days: int = 180, + max_schedules: int = 80, + max_schedule_history: int = 8, skip_key: str = "proactive_skip", **kwargs, ): @@ -71,6 +85,10 @@ class ProactiveTopicsStep(BaseStep): self.max_topics = max(int(max_topics), 1) self.dedup_lookback_days = max(int(dedup_lookback_days), 0) self.digest_compare_limit = max(int(digest_compare_limit), 0) + self.min_reminder_confidence = float(min_reminder_confidence) + self.schedule_retention_days = max(int(schedule_retention_days), 1) + self.max_schedules = max(int(max_schedules), 0) + self.max_schedule_history = max(int(max_schedule_history), 0) self.skip_key = skip_key # pylint: disable=too-many-statements @@ -93,7 +111,9 @@ class ProactiveTopicsStep(BaseStep): daily = state.daily_dir or daily_dir(self) self.logger.info( f"[{self.name}] start date={day} follow_ups={len(state.follow_ups)} extends={len(state.extends)} " - f"updates={len(state.updates)} carry_forward={state.carry_forward_count}", + f"reminders={len(state.reminders)} new_ideas={len(state.new_ideas)} " + f"updates={len(state.updates)} schedule_updates={len(state.schedule_updates)} " + f"carry_forward={state.carry_forward_count}", ) state_file, _needs_bootstrap = load_state(ws, daily) @@ -105,6 +125,13 @@ class ProactiveTopicsStep(BaseStep): # check, same shape as INV-8); otherwise it degrades to keep so stale # re-reads cannot rewrite evidence or refresh the freshness sort key. new_material = {norm_path(str(path).split("#", 1)[0]) for path in state.changed_paths} + # The reminder branch is the only one allowed to cite the ``resource/`` + # layer and ``clean_schedule_update`` already accepts those paths, so + # the registry anchor has to agree with it: without the notice paths a + # dated external notice could never be registered through the explicit + # channel and would only reach the registry via the reminder safety + # net, losing its detail/end-time/recurrence and its own reason. + schedule_material = new_material | {norm_path(str(path).split("#", 1)[0]) for path in state.resource_changed} for update in state.updates: topic = open_by_id.get(str(update.get("id") or "")) if topic is None: @@ -138,8 +165,49 @@ class ProactiveTopicsStep(BaseStep): ) state.updates_resolved += 1 + # 1b) Schedule registry (branch C). Applied before the candidates so a + # reminder always sees the registry it just helped maintain. The + # registry has its own, much longer lifecycle than open_topics: a + # schedule mentioned weeks ago must still fire on its due date. + counts, touched_schedules = apply_schedule_updates(state_file, state.schedule_updates, day, schedule_material) + state.schedules_added += counts["added"] + state.schedules_updated += counts["updated"] + state.schedules_closed += counts["closed"] + # The clock scan read the registry before those updates landed, so a + # seeded reminder may still advertise the previous due value; a reader + # sorts and notifies by it, so re-anchor it on the registry. + state.reminders_reanchored = refresh_reminder_dues( + state.reminders, + state_file, + touched_schedules, + current_now(self), + ) + adopted_schedules = 0 + for candidate in state.reminders: + outcome = upsert_schedule_from_reminder(state_file, candidate, day, touched_schedules) + if outcome == "added": + state.schedules_added += 1 + elif outcome == "updated": + state.schedules_updated += 1 + elif outcome == "adopted": + adopted_schedules += 1 + dropped_schedules = trim_schedules( + state_file, + day, + self.schedule_retention_days, + self.max_schedules, + self.max_schedule_history, + ) + state.schedules_out = [slim_schedule(s) for s in state_file.schedules if s.status == "open"] + self.logger.info( + f"[{self.name}] schedule registry added={state.schedules_added} updated={state.schedules_updated} " + f"closed={state.schedules_closed} dropped={dropped_schedules} open={len(state.schedules_out)} " + f"skipped_updates={counts['skipped']} reminders_reanchored={state.reminders_reanchored} " + f"adopted={adopted_schedules}", + ) + # 2) Candidates: same-id merge, tombstone resurrect, dedup the rest. - raw_candidates = list(state.follow_ups) + list(state.extends) + raw_candidates = list(state.reminders) + list(state.follow_ups) + list(state.extends) + list(state.new_ideas) state.candidates_in = len(raw_candidates) merged: list[dict] = [] resurrected: list[dict] = [] @@ -196,6 +264,13 @@ class ProactiveTopicsStep(BaseStep): if normalized in seen_normalized: dropped_duplicate += 1 continue + if str(candidate.get("kind") or "") == "reminder": + # A due schedule must fire even when the same words were + # published recently: the historical "known" gate filters + # discovery noise, it is not a calendar. + seen_normalized.add(normalized) + survivors.append(candidate) + continue if embedder is None: if normalized in comparison_norms: dropped_duplicate += 1 @@ -230,7 +305,7 @@ class ProactiveTopicsStep(BaseStep): # 3) Truth source: add new topics, prune, single atomic write (F1.3). for candidate in survivors: - state_file.open_topics.append(ProactiveTopic.model_validate(candidate)) + state_file.open_topics.append(topic_from_candidate(candidate)) trim_state_file(state_file, day, max(state.carry_forward_days, 1)) await save_state(ws, state_file, daily) @@ -239,9 +314,24 @@ class ProactiveTopicsStep(BaseStep): # rounds because such topics persist in the truth source once added. # The candidate list (sorted deterministically) feeds the plan/agenda # steps; ``push`` is exactly "at least one candidate". - push_candidates = sort_topics( - [t for t in state_file.open_topics if t.first_seen == day and t.confidence >= self.min_push_confidence], - ) + # Reminders are the exception on both counts: one refreshed today pushes + # regardless of ``first_seen`` (a due schedule recurs daily until it is + # closed) and only has to clear the lower ``min_reminder_confidence``. + reminders_today = [ + t + for t in state_file.open_topics + if t.kind == "reminder" + and day in (t.first_seen, t.last_evidence_at) + and t.confidence >= self.min_reminder_confidence + ] + others_today = [ + t + for t in state_file.open_topics + if t.kind != "reminder" and t.first_seen == day and t.confidence >= self.min_push_confidence + ] + push_candidates = sort_reminders([dump_topic(t) for t in reminders_today]) + [ + dump_topic(t) for t in sort_topics(others_today) + ] push = bool(push_candidates) if push: file_skip_reason = "" @@ -286,6 +376,13 @@ class ProactiveTopicsStep(BaseStep): existing.confidence = clamp_confidence(candidate.get("confidence")) if candidate.get("paths"): existing.paths = list(candidate["paths"]) + if existing.kind == "reminder": + # Keep the published due date and registry link current so a + # recurring reminder shows its next occurrence, not the anchor. + if due := effective_due(candidate): + existing.due_at = due + if candidate.get("schedule_id"): + existing.schedule_id = str(candidate["schedule_id"]) @staticmethod def _resurrect(tombstone: dict, candidate: dict, day: str) -> ProactiveTopic: @@ -294,16 +391,19 @@ class ProactiveTopicsStep(BaseStep): The over-age trim still applies to the original ``first_seen``, so a resurrection only extends a lifetime that has not fully elapsed. """ + kind = str(candidate.get("kind") or DEFAULT_KIND) return ProactiveTopic( id=str(tombstone.get("id") or candidate.get("id") or ""), title=str(candidate.get("title") or tombstone.get("title") or ""), reason=str(candidate.get("reason") or ""), - kind=str(candidate.get("kind") or "interest_extend"), + kind=kind, confidence=clamp_confidence(candidate.get("confidence")), first_seen=str(tombstone.get("first_seen") or day), last_evidence_at=day, evidence=str(candidate.get("evidence") or "")[:120], paths=candidate.get("paths") or [], + schedule_id=str(candidate.get("schedule_id") or ""), + due_at=effective_due(candidate) if kind == "reminder" else "", ) async def _comparison_texts( diff --git a/reme/steps/evolve/proactive/utils.py b/reme/steps/evolve/proactive/utils.py index a4a5b0ef..9a771b7f 100644 --- a/reme/steps/evolve/proactive/utils.py +++ b/reme/steps/evolve/proactive/utils.py @@ -17,8 +17,13 @@ from pathlib import Path import yaml from ....enumeration import ComponentEnum -from ....schema import ProactiveStateFile, ProactiveTopic -from ....schema.proactive import clamp_confidence +from ....schema import ProactiveSchedule, ProactiveStateFile, ProactiveTopic +from ....schema.proactive import ( + DEFAULT_KIND, + TOPIC_KINDS, + clamp_confidence, + kind_priority, +) from ....utils import get_logger from ...file_io._file_io import get_path_lock from .._evolve import now @@ -26,9 +31,17 @@ from ..dream.utils import clean_paths, recent_dates, scan_day_files logger = get_logger(log_to_file=False) + PROACTIVE_STATE_NAME = "_proactive.yaml" + + INTERESTS_NAME = "interests.yaml" -EXTRACT_SECTIONS = ("follow_ups", "extends", "updates") + + +EXTRACT_SECTIONS = ("follow_ups", "extends", "reminders", "new_ideas", "updates", "schedule_updates") + +# Registry triggers that deserve a reminder; ``later``/``unscheduled``/``closed`` +# stay in the prompt view but never become candidates. def load_yaml_topics(path: Path, *, strict: bool = False) -> list[dict]: @@ -158,6 +171,75 @@ def scan_material_daily(ws: Path, day: str, daily: str, scan_days: int) -> list[ return sorted(out) +# External source records the reminder branch may read directly. Binary sources +# are deliberately absent: ``auto_resource`` interprets them into daily cards, +# which already reach extraction through ``scan_material_daily``. +RESOURCE_NOTICE_SUFFIXES = ( + ".md", + ".markdown", + ".txt", + ".json", + ".yaml", + ".yml", + ".csv", + ".tsv", + ".html", + ".eml", + ".ics", +) + + +def _mtime_day(path: Path) -> str: + """Local date a file last changed, or ``""`` when it cannot be stat'ed.""" + try: + return dt.date.fromtimestamp(path.stat().st_mtime).isoformat() + except OSError: + return "" + + +def scan_material_resource( + ws: Path, + day: str, + scan_days: int, + resource: str = "resource", +) -> tuple[list[str], int]: + """M_resource: external source records that may carry notices for the user. + + Mirrors the ``resource/`` entry-point semantics of Auto Resource: a dated + directory ``resource/<YYYY-MM-DD>/...`` belongs to that date, while a file + directly under ``resource/`` (or under a non-dated folder) belongs to the + day it landed, i.e. its mtime. Returns ``(paths, skipped_binary)``; only + text-readable suffixes are returned, because a binary source has to be + interpreted by ``auto_resource`` before anyone can quote it. + + This is the reserved hook for future external source interfaces: whatever + writes into ``resource/`` becomes eligible for the reminder review without + any further change to the proactive chain. + """ + window = set(recent_dates(day, scan_days)) + root = ws / resource + if not window or not root.is_dir(): + return [], 0 + out: list[str] = [] + skipped = 0 + for path in sorted(root.rglob("*")): + if not path.is_file(): + continue + rel = norm_path(path.relative_to(ws).as_posix()) + parts = rel.split("/") + if any(part.startswith("_") or part.startswith(".") for part in parts[1:]): + continue + owner = parts[1] if len(parts) > 2 and safe_date(parts[1]) else _mtime_day(path) + if owner not in window: + continue + if path.suffix.lower() not in RESOURCE_NOTICE_SUFFIXES: + skipped += 1 + continue + if rel not in out: + out.append(rel) + return sorted(out), skipped + + # --------------------------------------------------------------------------- # Truth-source state file daily/_proactive.yaml (F1.3) # --------------------------------------------------------------------------- @@ -187,11 +269,56 @@ def load_state(ws: Path, daily: str = "daily") -> tuple[ProactiveStateFile, bool try: state = ProactiveStateFile.model_validate(data) except Exception as e: # noqa: BLE001 - logger.warning(f"proactive state file invalid, rebuilding empty: {path} ({e})") - return ProactiveStateFile(), False + # Salvage entry by entry instead of discarding the file: the schedule + # registry is designed to outlive topics by months, so one malformed + # hand-edited entry must not silently delete every long-lived reminder. + # Still no bootstrap - the file exists, so this is not a first run. + state, dropped = _salvage_state(data) + logger.warning( + f"proactive state file invalid, salvaged entry by entry: {path} " + f"(dropped {dropped} unreadable entr(ies); kept open_topics={len(state.open_topics)} " + f"schedules={len(state.schedules)}; {e})", + ) + return state, False return state, needs_bootstrap +# Truth-source sections whose entries are validated one by one when the whole +# file fails to parse. ``resolved`` is a plain dict list, so it only needs the +# non-mapping items filtered out. +_STATE_SECTIONS = (("open_topics", ProactiveTopic), ("schedules", ProactiveSchedule)) + + +def _salvage_state(data: dict) -> tuple[ProactiveStateFile, int]: + """Rebuild a partly-unreadable truth source, dropping only the bad entries. + + Returns the salvaged model and the number of entries that had to be + dropped. Never raises: a section that is not even a list is treated as + absent, exactly like a file written before that section existed. + """ + sections: dict[str, list] = {} + dropped = 0 + for key, model in _STATE_SECTIONS: + raw = data.get(key) + raw = raw if isinstance(raw, list) else [] + kept = [] + for item in raw: + try: + kept.append(model.model_validate(item)) + except Exception: # noqa: BLE001 + dropped += 1 + sections[key] = kept + raw_resolved = data.get("resolved") + resolved = [item for item in (raw_resolved if isinstance(raw_resolved, list) else []) if isinstance(item, dict)] + version = data.get("version") + state = ProactiveStateFile( + version=version if isinstance(version, int) else 1, + resolved=resolved, + **sections, + ) + return state, dropped + + async def save_state(ws: Path, state_file: ProactiveStateFile, daily: str = "daily") -> None: """Atomically persist the truth source (path lock + tmp file + os.replace).""" path = state_file_path(ws, daily) @@ -210,7 +337,8 @@ async def save_state(ws: Path, state_file: ProactiveStateFile, daily: str = "dai raise -def _safe_date(text: str) -> dt.date | None: +def safe_date(text: str) -> dt.date | None: + """Parse an ISO ``YYYY-MM-DD`` string, returning None when it is not one.""" try: return dt.date.fromisoformat(str(text or "").strip()) except ValueError: @@ -237,13 +365,13 @@ async def load_carry_forward( state_file.open_topics = _bootstrap_from_history(ws, day, days, daily) await save_state(ws, state_file, daily) resolved_ids = {str(r.get("id") or "") for r in state_file.resolved if isinstance(r, dict)} - base = _safe_date(day) + base = safe_date(day) open_topics: list[ProactiveTopic] = [] expired = 0 for topic in state_file.open_topics: if topic.id and topic.id in resolved_ids: continue - first_seen = _safe_date(topic.first_seen) + first_seen = safe_date(topic.first_seen) # Boundary aligned with trim_state_file/_expiry_cutoff: age >= days is # over-age everywhere, so a topic never enters the prompt in the same # round it gets pruned from the truth source (audit item 9). @@ -259,7 +387,7 @@ async def load_carry_forward( def _bootstrap_from_history(ws: Path, day: str, days: int, daily: str) -> list[ProactiveTopic]: """One-time bootstrap: newest record per id wins, first_seen takes the min (F1.4).""" - if _safe_date(day) is None: + if safe_date(day) is None: return [] records: dict[str, ProactiveTopic] = {} first_seen: dict[str, str] = {} @@ -283,7 +411,7 @@ def _bootstrap_from_history(ws: Path, day: str, days: int, daily: str) -> list[P def trim_state_file(state_file: ProactiveStateFile, day: str, days: int) -> None: """Prune budget/exposure/resolved windows and over-age/resolved open topics.""" - base = _safe_date(day) + base = safe_date(day) if base is None: return cutoff = (base - dt.timedelta(days=max(int(days), 0))).isoformat() @@ -295,7 +423,7 @@ def trim_state_file(state_file: ProactiveStateFile, day: str, days: int) -> None for topic in state_file.open_topics: if topic.id and topic.id in resolved_ids: continue - first_seen = _safe_date(topic.first_seen) + first_seen = safe_date(topic.first_seen) if first_seen is not None and first_seen.isoformat() <= cutoff: continue kept.append(topic) @@ -363,23 +491,28 @@ def parse_interests_topics(data: dict, file_date: str) -> tuple[list[ProactiveTo id=str(raw.get("id") or "").strip() or topic_id(title), title=title, reason=reason, - kind=raw.get("kind", "interest_extend"), + kind=raw.get("kind", DEFAULT_KIND), confidence=raw.get("confidence", 0.5), first_seen=str(raw.get("first_seen") or "").strip() or file_date, last_evidence_at=str(raw.get("last_evidence_at") or "").strip() or file_date, evidence=str(raw.get("evidence") or "").strip()[:120], paths=raw.get("paths") or [], + schedule_id=str(raw.get("schedule_id") or "").strip(), + due_at=str(raw.get("due_at") or "").strip(), ), ) return topics, is_v1, push def sort_topics(topics: list) -> list: - """Order: last_evidence_at desc -> follow_up first -> confidence desc -> id asc. + """Order: last_evidence_at desc -> kind priority -> confidence desc -> id asc. Freshness is the primary key (v5 aging fix): stale topics sink below newly evidenced ones of any kind, so long-lived follow_ups cannot permanently crowd out new discoveries. + Kind priority is ``KIND_PRIORITY`` (reminder > follow_up > new_idea > + interest_extend), so a reminder always outranks discovery kinds inside the + same freshness band and survives the ``max_topics`` render cap. """ def get(topic, key): @@ -387,7 +520,7 @@ def sort_topics(topics: list) -> list: out = sorted(topics, key=lambda t: str(get(t, "id") or "")) out.sort(key=lambda t: clamp_confidence(get(t, "confidence")), reverse=True) - out.sort(key=lambda t: 0 if get(t, "kind") == "follow_up" else 1) + out.sort(key=lambda t: kind_priority(get(t, "kind"))) out.sort(key=lambda t: str(get(t, "last_evidence_at") or ""), reverse=True) return out @@ -395,17 +528,24 @@ def sort_topics(topics: list) -> list: def dump_topic(topic) -> dict: """Render one topic as an A2-ordered dict for interests.yaml v2.""" get = (lambda k: getattr(topic, k)) if not isinstance(topic, dict) else topic.get - return { + dumped = { "id": str(get("id") or ""), "title": str(get("title") or ""), "reason": str(get("reason") or ""), - "kind": str(get("kind") or "interest_extend"), + "kind": str(get("kind") or DEFAULT_KIND), "confidence": clamp_confidence(get("confidence")), "first_seen": str(get("first_seen") or ""), "last_evidence_at": str(get("last_evidence_at") or ""), "evidence": str(get("evidence") or "")[:120], "paths": [str(p) for p in (get("paths") or [])], } + # Reminder links are emitted only when present so the v2 topic shape of + # every other kind stays byte-identical. + if schedule_id := str(get("schedule_id") or ""): + dumped["schedule_id"] = schedule_id + if due_at := str(get("due_at") or ""): + dumped["due_at"] = due_at + return dumped def render_interests( @@ -491,7 +631,7 @@ def clean_candidate(raw, allowed_paths: set[str], kind: str, day: str) -> dict: "id": topic_id(title), "title": title, "reason": reason, - "kind": kind, + "kind": kind if kind in TOPIC_KINDS else DEFAULT_KIND, "confidence": clamp_confidence(raw.get("confidence")), "first_seen": day, "last_evidence_at": day, @@ -631,6 +771,57 @@ def load_personal_profile_block( return "(no user profile)" +CAPABILITY_SECTIONS = ( + ("procedure", "procedure (reusable experience, skills, tool/API know-how the agent already has)"), + ("wiki", "wiki (existing knowledge nodes of the personal knowledge base)"), +) + + +def load_capability_block(ws: Path, digest_dir: str, max_chars: int) -> str: + """Inventory of what the agent can already do, used by the ``new_ideas`` branch. + + Reads only rebuildable digest nodes: ``<digest_dir>/procedure/*.md`` + (accumulated experience, skills, tool/API know-how) and + ``<digest_dir>/wiki/*.md`` (knowledge nodes an interest can extend). + Each node contributes its frontmatter ``description`` so proposals stay + grounded in resources that actually exist instead of invented capabilities. + """ + max_chars = int(max_chars) + if max_chars <= 0: + return "(no capability inventory)" + per_section = max(max_chars // max(len(CAPABILITY_SECTIONS), 1), 200) + sections: list[str] = [] + for subdirectory, heading in CAPABILITY_SECTIONS: + lines = _digest_inventory_lines(ws / digest_dir / subdirectory, per_section) + if lines: + sections.append(f"### {heading}\n" + "\n".join(lines)) + if not sections: + return "(no capability inventory)" + return "\n\n".join(sections)[:max_chars] + + +def _digest_inventory_lines(directory: Path, budget: int) -> list[str]: + """``- <title>: <description>`` lines for one digest subdirectory, budget-bounded.""" + if not directory.is_dir(): + return [] + lines: list[str] = [] + used = 0 + for path in sorted(p for p in directory.glob("*.md") if p.is_file()): + try: + raw = path.read_text(encoding="utf-8") + except OSError: + continue + meta, _body = strip_frontmatter(raw) + title = str(meta.get("name") or path.stem) + description = " ".join(str(meta.get("description") or "").split()) + line = f"- {title}" + (f": {description[:160]}" if description else "") + if used + len(line) > budget: + break + lines.append(line) + used += len(line) + 1 + return lines + + def parse_fenced_yaml(text: str) -> dict: """Parse the first fenced YAML/JSON block (or the bare text) into a dict. diff --git a/tests/unit/test_proactive_refresh.py b/tests/unit/test_proactive_refresh.py index 282ab55e..31197c67 100644 --- a/tests/unit/test_proactive_refresh.py +++ b/tests/unit/test_proactive_refresh.py @@ -21,14 +21,24 @@ from reme.components.application_context import ApplicationContext from reme.components.file_catalog import BaseFileCatalog from reme.components.file_store import BaseFileStore from reme.components.runtime_context import RuntimeContext -from reme.schema import FileNode, ProactiveTopic +from reme.schema import FileNode, ProactiveSchedule, ProactiveStateFile, ProactiveTopic from reme.steps.evolve.dream.extract import DreamExtractStep from reme.steps.evolve.dream.utils import pack_paths -from reme.steps.evolve.proactive.agenda import ProactiveAgendaStep +from reme.steps.evolve.proactive.agenda import ProactiveAgendaStep, _pin_reason from reme.steps.evolve.proactive.extract import ProactiveExtractStep from reme.steps.evolve.proactive.finish import ProactiveFinishStep from reme.steps.evolve.proactive.plan import ProactivePlanStep from reme.steps.evolve.proactive.proactive import ProactiveStep +from reme.steps.evolve.proactive.refine import parse_refine_reply +from reme.steps.evolve.proactive.schedules import ( + apply_schedule_updates, + canonical_due, + merge_reminders, + parse_when, + reminder_candidates_from_view, + schedule_view, + trim_schedules, +) from reme.steps.evolve.proactive.topics import ProactiveTopicsStep from reme.steps.evolve.proactive.utils import ( load_carry_forward, @@ -39,12 +49,39 @@ from reme.steps.evolve.proactive.utils import ( parse_interests_topics, parse_extract_reply, scan_material_daily, + scan_material_resource, strip_frontmatter, topic_id, trim_state_file, ) DAY = "2026-08-13" +# Fixed business clock for the reminder branch: 08:00 on DAY (+08:00). +NOW = dt.datetime(2026, 8, 13, 8, 0, tzinfo=dt.timezone(dt.timedelta(hours=8))) +EXTRACT_CLOCK = "reme.steps.evolve.proactive.extract.current_now" +# Stage-2 refine probes: the pass is recognised by its user message, and an +# unscripted refine call answers "keep everything" (fail-open by contract). +REFINE_MARKERS = ("kind under review:", "本次裁决的 kind") +KEEP_ALL_VERDICTS = "```yaml\nverdicts: []\n```" + + +def _is_refine_call(inputs) -> bool: + text = str(inputs or "") + return any(marker in text for marker in REFINE_MARKERS) + + +def _verdicts(*verdicts) -> str: + body = yaml.safe_dump({"verdicts": list(verdicts)}, allow_unicode=True, sort_keys=False) + return f"```yaml\n{body}```" + + +def _verdict(cid, decision="drop", to_kind="", confidence=None, reason="not grounded"): + doc = {"id": cid, "decision": decision, "reason": reason} + if to_kind: + doc["to_kind"] = to_kind + if confidence is not None: + doc["confidence"] = confidence + return doc def _touch(path: Path, text: str = "x") -> Path: @@ -121,13 +158,20 @@ class _FileStore(BaseFileStore): class _AgentWrapper(BaseAgentWrapper): - """Deterministic LLM stub with scripted replies and call accounting.""" + """Deterministic LLM stub with scripted replies and call accounting. - def __init__(self, replies=None, delay: float = 0.0): + Stage-2 refine calls are routed to their own queue and default to "keep + everything", so a test that only cares about stage 1 (or about plan/agenda) + stays aligned while refine is enabled by default. + """ + + def __init__(self, replies=None, delay: float = 0.0, refine_replies=None): super().__init__() self.replies = list(replies or []) + self.refine_replies = list(refine_replies or []) self.delay = delay self.calls = 0 + self.refine_calls = 0 self.last_inputs = [] self.last_system_prompts = [] @@ -135,7 +179,12 @@ class _AgentWrapper(BaseAgentWrapper): self.calls += 1 self.last_inputs.append(inputs) self.last_system_prompts.append(str(kwargs.get("system_prompt") or "")) - item = self.replies.pop(0) if self.replies else "" + if _is_refine_call(inputs): + self.refine_calls += 1 + queue, fallback = self.refine_replies, KEEP_ALL_VERDICTS + else: + queue, fallback = self.replies, "" + item = queue.pop(0) if queue else fallback if isinstance(item, tuple): text, delay = item else: @@ -174,14 +223,20 @@ class _FakeEmbedding: return [list(self.vectors.get(text, [0.0, 1.0])) for text in inputs] -def _reply(follow_ups=None, extends=None, updates=None) -> str: +def _reply(follow_ups=None, extends=None, reminders=None, new_ideas=None, updates=None, schedule_updates=None) -> str: doc = {} if follow_ups is not None: doc["follow_ups"] = follow_ups if extends is not None: doc["extends"] = extends + if reminders is not None: + doc["reminders"] = reminders + if new_ideas is not None: + doc["new_ideas"] = new_ideas if updates is not None: doc["updates"] = updates + if schedule_updates is not None: + doc["schedule_updates"] = schedule_updates body = yaml.safe_dump(doc, allow_unicode=True, sort_keys=False) return f"```yaml\n{body}```" @@ -213,7 +268,37 @@ def _state_topic(title, day=DAY, kind="follow_up", confidence=0.8, topic_id_valu } -def _write_state(ws, budget=None, exposure=None, open_topics=None, resolved=None, omit_open_topics=False): +def _reminder(title, due_at="", reason="快到期了", confidence=0.8, paths=None, **extra): + doc = {"title": title, "reason": reason, "confidence": confidence, "paths": paths or []} + if due_at: + doc["due_at"] = due_at + doc.update(extra) + return doc + + +def _schedule(title, due_at="", schedule_id=None, status="open", **extra): + entry = { + "id": schedule_id or topic_id(title), + "title": title, + "due_at": due_at, + "status": status, + "confidence": 0.8, + "first_seen": DAY, + "last_seen_at": DAY, + } + entry.update(extra) + return entry + + +def _write_state( + ws, + budget=None, + exposure=None, + open_topics=None, + resolved=None, + schedules=None, + omit_open_topics=False, +): data = {"version": 1} if budget is not None: data["budget"] = budget @@ -223,6 +308,8 @@ def _write_state(ws, budget=None, exposure=None, open_topics=None, resolved=None data["open_topics"] = open_topics or [] if resolved is not None: data["resolved"] = resolved + if schedules is not None: + data["schedules"] = schedules _touch(ws / "daily" / "_proactive.yaml", yaml.safe_dump(data, allow_unicode=True, sort_keys=False)) @@ -641,7 +728,7 @@ def test_refresh_chain_e2e(tmp_path): assert resp_extract.success and resp_topics.success and resp_finish.success state = context.get("proactive") - assert state["llm_calls"] == 1 + assert state["llm_calls"] == 1 + state["refine_calls"] # stage 1 + one pass per non-empty kind assert state["push"] is True assert state["file_skip_reason"] == "" assert state["interests_written"] is True @@ -1145,9 +1232,10 @@ def test_schema_error_reply_retries(tmp_path): good = _reply(follow_ups=[_topic("补救话题", paths=[f"daily/{DAY}/session.md"])]) wrapper = _AgentWrapper([bad, good]) _, wrapper, _, context, responses = await _run_chain(ws, wrapper=wrapper) - assert wrapper.calls == 2 + state = context.get("proactive") + assert wrapper.calls == 2 + state["refine_calls"] # 2 stage-1 attempts + refine assert all(r.success for r in responses) - assert [t["title"] for t in context.get("proactive")["follow_ups"]] == ["补救话题"] + assert [t["title"] for t in state["follow_ups"]] == ["补救话题"] asyncio.run(run()) @@ -1452,7 +1540,7 @@ def test_semantic_dedup(tmp_path): assert state["dropped_duplicate"] == 0 assert [c["title"] for c in state["candidates"]] == ["Candidate Topic"] assert fake.calls >= 1 - assert wrapper.calls == 1 # topics step is pure computation now + assert wrapper.calls == 1 + state["refine_calls"] # extract (+refine); topics is pure computation asyncio.run(run()) @@ -1579,9 +1667,9 @@ def test_plan_agenda_chain_e2e(tmp_path): ) _, wrapper, _, context, resps = await _run_full_chain(ws, [extract_reply, plan_reply, agenda_text]) assert all(r.success for r in resps) - assert wrapper.calls == 3 state = context.get("proactive") - assert state["llm_calls"] == 1 # extract only + assert wrapper.calls == 3 + state["refine_calls"] # extract + plan + agenda + assert state["llm_calls"] == 1 + state["refine_calls"] # extract only assert state["plan_llm_calls"] == 2 # plan + agenda assert state["push"] is True @@ -1622,8 +1710,8 @@ def test_plan_agenda_fallback_without_llm(tmp_path): # Only the extract reply is scripted; plan/agenda receive "" and must degrade. _, wrapper, _, context, resps = await _run_full_chain(ws, [extract_reply]) assert all(r.success for r in resps) - assert wrapper.calls == 3 state = context.get("proactive") + assert wrapper.calls == 3 + state["refine_calls"] assert state["plan_llm_calls"] == 2 # attempted but unusable assert len(state["scenario_cards"]) == 2 @@ -1649,8 +1737,8 @@ def test_agenda_no_candidates_keeps_topics_render(tmp_path): ) _, wrapper, _, context, resps = await _run_full_chain(ws, [extract_reply]) assert all(r.success for r in resps) - assert wrapper.calls == 1 # extract only; plan/agenda made zero LLM calls state = context.get("proactive") + assert wrapper.calls == 1 + state["refine_calls"] # extract only; plan/agenda made zero calls assert state["push"] is False assert state["push_candidates"] == [] assert state["scenario_cards"] == [] @@ -1676,8 +1764,8 @@ def test_agenda_single_candidate_skips_llm(tmp_path): fid = topic_id("唯一候选") plan_reply = _plan_reply([_card(fid, scenario="answer_pending")]) _, wrapper, _, context, _ = await _run_full_chain(ws, [extract_reply, plan_reply]) - assert wrapper.calls == 2 # extract + plan only state = context.get("proactive") + assert wrapper.calls == 2 + state["refine_calls"] # extract + plan only assert state["plan_llm_calls"] == 1 data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8")) @@ -1984,3 +2072,1101 @@ def test_carry_forward_expiry_boundary_consistent(tmp_path): assert state_file.open_topics == [] asyncio.run(run()) + + +# --------------------------------------------------------------------------- +# Four-kind extraction: follow_up / interest_extend / reminder / new_idea +# --------------------------------------------------------------------------- + + +def test_extract_prompt_covers_the_four_branches(): + """All four branches render by default; disabling one removes its whole contract.""" + + async def run_once(**extract_kwargs): + with tempfile.TemporaryDirectory() as tmp: + ws = Path(tmp) + _touch(ws / "daily" / DAY / "s1.md", "material") + _, wrapper, _, _context, _ = await _run_chain(ws, [_reply(follow_ups=[])], extract_kwargs=extract_kwargs) + return wrapper.last_system_prompts[0], wrapper.last_inputs[0] + + async def run(): + system_on, user_on = await run_once() + for heading in ( + "Branch A - follow_ups", + "Branch B - extends", + "Branch C - reminders", + "Branch D - new_ideas", + ): + assert heading in system_on + for section in ("follow_ups:", "extends:", "reminders:", "new_ideas:", "updates:", "schedule_updates:"): + assert section in system_on # output shape lists every active section + assert "now (current clock" in user_on + assert "schedules (long-lived registry" in user_on + assert "capability_inventory" in user_on + + system_off, user_off = await run_once( + extends_enabled=False, + reminders_enabled=False, + new_ideas_enabled=False, + ) + for heading in ("Branch B", "Branch C", "Branch D"): + assert heading not in system_off + for section in ("extends:", "reminders:", "new_ideas:", "schedule_updates:"): + assert section not in system_off + assert "[" not in system_off.split("## Output format")[-1] # no unrendered flag leaked + assert "schedules" not in user_off + assert "capability_inventory" not in user_off + + asyncio.run(run()) + + +def test_kind_caps_and_new_idea_kind(tmp_path): + """Discovery kinds are capped and tagged; reminders are not capped.""" + + async def run(): + ws = tmp_path + _touch(ws / "daily" / DAY / "session.md", "material") + path = f"daily/{DAY}/session.md" + reply = _reply( + follow_ups=[_topic(f"任务{i}", paths=[path]) for i in range(4)], + new_ideas=[_topic(f"可以委托的事{i}", paths=[path]) for i in range(3)], + reminders=[_reminder(f"日程{i}", due_at="2026-08-14", paths=[path]) for i in range(4)], + ) + with patch(EXTRACT_CLOCK, return_value=NOW): + _, _, _, context, _ = await _run_chain( + ws, + [reply], + extract_kwargs={"max_follow_ups": 2, "max_new_ideas": 1}, + ) + state = context.get("proactive") + assert [t["title"] for t in state["follow_ups"]] == ["任务0", "任务1"] + assert all(t["kind"] == "follow_up" for t in state["follow_ups"]) + assert [t["title"] for t in state["new_ideas"]] == ["可以委托的事0"] + assert state["new_ideas"][0]["kind"] == "new_idea" + assert len(state["reminders"]) == 4 # uncapped + assert all(t["kind"] == "reminder" for t in state["reminders"]) + assert state["dropped_over_cap"] == 2 + 2 # follow_ups + new_ideas + + asyncio.run(run()) + + +def test_reminder_clock_only_round(tmp_path): + """A due schedule reaches today's agenda with zero extract LLM calls and no material.""" + + async def run(): + ws = tmp_path + _write_state(ws, schedules=[_schedule("提交季度报告", "2026-08-14T09:00")]) + with patch(EXTRACT_CLOCK, return_value=NOW): + _, wrapper, catalog, context, resps = await _run_full_chain(ws, []) + assert all(r.success for r in resps) + state = context.get("proactive") + assert state["reminder_only"] is True + assert state["llm_calls"] == 0 # extract made no call; the single call is plan's + assert wrapper.calls == 1 + assert [r["title"] for r in state["reminders"]] == ["提交季度报告"] + assert state["reminders"][0]["trigger"] == "due_soon" + assert state["push"] is True + assert catalog.upserts == [] and catalog.dumps == 0 # no material to checkpoint + + data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8")) + assert data["push"] is True + assert [item["title"] for item in data["agenda"]] == ["提交季度报告"] + item = data["agenda"][0] + assert item["kind"] == "reminder" + assert item["scenario_type"] == "prepare_upcoming" + assert item["due_at"] == "2026-08-14T09:00" # timed schedules keep their clock time + assert item["schedule_id"] == topic_id("提交季度报告") + assert item["order_reason"].startswith("reminder pinned") + assert data["topics"][0]["kind"] == "reminder" + + asyncio.run(run()) + + +def test_pin_reason_names_the_actual_cause(): + """A pinned reminder's audit trail says whether the clock or freshness pinned it.""" + day = "2026-10-12" + overdue = _pin_reason({"due_at": "2026-10-06", "first_seen": "2026-09-19"}, day) + assert overdue == "reminder pinned: a due schedule always enters today's agenda (due 2026-10-06)" + due_today = _pin_reason({"due_at": "2026-10-12T18:00", "first_seen": day}, day) + assert due_today.startswith("reminder pinned: a due schedule") + fresh = _pin_reason({"due_at": "2026-11-06", "first_seen": day}, day) + assert fresh == "reminder pinned: a new reminder always enters today's agenda (due 2026-11-06)" + carried = _pin_reason({"due_at": "2026-11-06", "first_seen": "2026-09-19"}, day) + assert carried.startswith("reminder pinned: an open reminder") + undated = _pin_reason({"first_seen": day}, day) + assert undated.endswith("today's agenda") and "(due" not in undated + # Without an agenda date nothing can be claimed about the clock. + assert _pin_reason({"due_at": "2026-11-06"}, "").startswith("reminder pinned: an open reminder") + + +def test_reminder_registry_maintenance_and_anchoring(tmp_path): + """schedule_updates maintain the registry; unanchored changes are rejected.""" + + async def run(): + ws = tmp_path + _touch(ws / "daily" / DAY / "session.md", "用户说评审会改期,下周三要牙医复诊") + _write_state( + ws, + schedules=[ + _schedule("季度评审会", "2026-08-20T14:00", schedule_id="sch-1"), + _schedule("旧体检", "2026-08-18", schedule_id="sch-2"), + ], + ) + path = f"daily/{DAY}/session.md" + reply = _reply( + reminders=[_reminder("牙医复诊", due_at="2026年8月19日 15:00", paths=[path])], + schedule_updates=[ + { + "id": "sch-1", + "action": "reschedule", + "due_at": "2026-08-21T10:00", + "reason": "改期", + "evidence": path, + }, + {"id": "sch-2", "action": "done", "reason": "已完成", "evidence": "daily/2026-01-01/old.md"}, + {"id": "", "action": "add", "title": "牙医复诊", "due_at": "2026-08-19T15:00", "evidence": path}, + ], + ) + with patch(EXTRACT_CLOCK, return_value=NOW): + _, _, _, context, _ = await _run_full_chain(ws, [reply]) + state = context.get("proactive") + assert state["schedules_added"] == 1 + assert state["schedules_updated"] == 1 + assert state["schedules_closed"] == 0 # the unanchored `done` was rejected + assert state["reminders"][0]["due_at"] == "2026-08-19T15:00" # CJK due normalized + + registry = {entry["id"]: entry for entry in _read_state(ws)["schedules"]} + assert registry["sch-1"]["due_at"] == "2026-08-21T10:00" + assert registry["sch-1"]["history"][-1]["action"] == "reschedule" + assert registry["sch-1"]["history"][-1]["from"] == "2026-08-20T14:00" + assert registry["sch-2"]["status"] == "open" # untouched + added = registry[topic_id("牙医复诊")] + assert added["due_at"] == "2026-08-19T15:00" + assert added["history"][-1]["action"] == "add" + assert added["evidence"] == path + # ``schedules_out`` is the slim open-registry view published in metadata. + assert {entry["id"] for entry in state["schedules_out"]} == set(registry) + assert all("history" not in entry and "paths" not in entry for entry in state["schedules_out"]) + + asyncio.run(run()) + + +def test_reminder_registration_adopts_the_explicit_entry(tmp_path): + """One notice under two titles registers once: the derived channel adopts the explicit id.""" + + async def run(): + ws = tmp_path + _touch(ws / "daily" / DAY / "session.md", "航司通知 MU5137 起飞时间变更") + path = f"daily/{DAY}/session.md" + reply = _reply( + reminders=[_reminder("MU5137 航班起飞时间调整", due_at="2026-08-19T15:30", paths=[path])], + schedule_updates=[ + { + "id": "", + "action": "add", + "title": "MU5137 上海虹桥-北京首都航班", + "due_at": "2026-08-19T15:30", + "evidence": path, + }, + ], + ) + with patch(EXTRACT_CLOCK, return_value=NOW): + _, _, _, context, _ = await _run_full_chain(ws, [reply]) + state = context.get("proactive") + assert state["schedules_added"] == 1 # the explicit one; no twin behind a second title + registry = {entry["id"]: entry for entry in _read_state(ws)["schedules"]} + assert len(registry) == 1 + kept = registry[topic_id("MU5137 上海虹桥-北京首都航班")] + assert kept["title"] == "MU5137 上海虹桥-北京首都航班" + assert [h["action"] for h in kept["history"]] == ["add"] + # The reminder republishes the adopted id, so the topic and the agenda + # card cannot point at a schedule that was never registered. + assert state["reminders"][0]["schedule_id"] == kept["id"] + agenda = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))["agenda"] + assert [item["schedule_id"] for item in agenda] == [kept["id"]] + + asyncio.run(run()) + + +def test_one_notice_two_matters_keeps_both_schedules(tmp_path): + """Distinct dues from one file are distinct matters, so adoption must not merge them.""" + + async def run(): + ws = tmp_path + _touch(ws / "daily" / DAY / "session.md", "航司通知 MU5137 起飞时间变更,改签需今天前办理") + path = f"daily/{DAY}/session.md" + reply = _reply( + reminders=[ + _reminder("MU5137 航班起飞时间调整", due_at="2026-08-19T15:30", paths=[path]), + _reminder("MU5137 改签决策截止", due_at="2026-08-14T18:00", paths=[path]), + ], + schedule_updates=[ + { + "id": "", + "action": "add", + "title": "MU5137 上海虹桥-北京首都航班", + "due_at": "2026-08-19T15:30", + "evidence": path, + }, + ], + ) + with patch(EXTRACT_CLOCK, return_value=NOW): + _, _, _, context, _ = await _run_full_chain(ws, [reply]) + state = context.get("proactive") + assert state["schedules_added"] == 2 # the flight plus the decision deadline + registry = {entry["id"]: entry for entry in _read_state(ws)["schedules"]} + assert len(registry) == 2 + assert registry[topic_id("MU5137 改签决策截止")]["due_at"] == "2026-08-14T18:00" + # Reminders are sorted by due date, so look them up by title. + published = {r["title"]: r["schedule_id"] for r in state["reminders"]} + assert published["MU5137 航班起飞时间调整"] == topic_id("MU5137 上海虹桥-北京首都航班") + assert published["MU5137 改签决策截止"] == topic_id("MU5137 改签决策截止") + + asyncio.run(run()) + + +def test_reminder_pins_agenda_ahead_of_higher_confidence(tmp_path): + """A low-confidence reminder outranks a 0.95 follow-up on today's agenda.""" + + async def run(): + ws = tmp_path + _touch(ws / "daily" / DAY / "session.md", "material") + path = f"daily/{DAY}/session.md" + reply = _reply( + follow_ups=[_topic("高置信任务", confidence=0.95, paths=[path])], + reminders=[_reminder("明天到期的报告", due_at="2026-08-14", confidence=0.4, paths=[path])], + ) + with patch(EXTRACT_CLOCK, return_value=NOW): + _, _, _, context, _ = await _run_full_chain(ws, [reply]) + state = context.get("proactive") + assert [c["kind"] for c in state["push_candidates"]] == ["reminder", "follow_up"] + + data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8")) + assert [item["kind"] for item in data["agenda"]] == ["reminder", "follow_up"] + assert data["agenda"][0]["due_at"] == "2026-08-14" + assert data["agenda"][1]["order_reason"] == "single push candidate; auto-agenda without LLM" + assert data["suppressed"] == [] + + asyncio.run(run()) + + +def test_llm_timeout_still_delivers_due_reminder(tmp_path): + """An extract timeout degrades to a clock-only round and never consumes material.""" + + async def run(): + ws = tmp_path + _touch(ws / "daily" / DAY / "session.md", "unread material") + _write_state(ws, schedules=[_schedule("提交季度报告", "2026-08-14T09:00")]) + wrapper = _AgentWrapper(["never returned"], delay=0.5) + with patch(EXTRACT_CLOCK, return_value=NOW): + _, _, catalog, context, resps = await _run_full_chain( + ws, + wrapper=wrapper, + extract_kwargs={"llm_timeout_seconds": 0.05}, + ) + assert all(r.success for r in resps) + assert context.get("proactive_skip") is None # the chain was not short-circuited + state = context.get("proactive") + assert state["reminder_only"] is True + assert state["changed_paths"] == [] + assert catalog.upserts == [] and catalog.dumps == 0 # material stays changed for next round + + data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8")) + assert [item["title"] for item in data["agenda"]] == ["提交季度报告"] + + asyncio.run(run()) + + +def test_recurring_reminder_keeps_registry_anchor(tmp_path): + """A weekly schedule reminds on its next occurrence without rewriting the anchor.""" + + async def run(): + ws = tmp_path + _write_state(ws, schedules=[_schedule("周报", "2026-08-06", recurrence="weekly", schedule_id="sch-w")]) + with patch(EXTRACT_CLOCK, return_value=NOW): + _, _, _, context, _ = await _run_full_chain(ws, []) + state = context.get("proactive") + assert [r["title"] for r in state["reminders"]] == ["周报"] + assert state["reminders"][0]["trigger"] == "due_today" + assert state["schedules_added"] == 0 and state["schedules_updated"] == 0 + + registry = _read_state(ws)["schedules"][0] + assert registry["due_at"] == "2026-08-06" # anchor untouched + assert registry["history"] == [] + topic = yaml.safe_load(_interests(ws).read_text(encoding="utf-8"))["topics"][0] + assert topic["due_at"] == "2026-08-13" # published as the next occurrence + assert topic["schedule_id"] == "sch-w" + + asyncio.run(run()) + + +def test_reminder_bypasses_known_gate(tmp_path): + """A due reminder fires even when the same title was published recently.""" + + async def run(): + ws = tmp_path + title = "提交季度报告" + _write_interests_v2( + ws, + "2026-08-12", + [{"id": topic_id(title), "title": title, "reason": "旧提醒", "kind": "reminder", "confidence": 0.8}], + ) + _write_state( + ws, + schedules=[_schedule(title, "2026-08-14T09:00", schedule_id="sch-1")], + ) + embedder = _FakeEmbedding({}) # every text maps to the same vector -> sim 1.0 >= threshold + with patch(EXTRACT_CLOCK, return_value=NOW): + _, _, _, context, _ = await _run_chain(ws, [], extra_ctx={"as_embedding": embedder}) + state = context.get("proactive") + assert state["dropped_known"] == 0 + assert [t["title"] for t in state["topics_out"]] == [title] + assert state["push"] is True + + asyncio.run(run()) + + +def test_read_exempts_reminders_from_min_confidence(tmp_path): + """proactive_read keeps a low-confidence reminder but filters a weak discovery topic.""" + + async def run(): + ws = tmp_path + topics = [ + { + "id": topic_id("低置信提醒"), + "title": "低置信提醒", + "reason": "明天到期", + "kind": "reminder", + "confidence": 0.35, + "first_seen": DAY, + "last_evidence_at": DAY, + "evidence": "", + "paths": [], + "due_at": "2026-08-14", + }, + { + "id": topic_id("低置信延伸"), + "title": "低置信延伸", + "reason": "弱联想", + "kind": "interest_extend", + "confidence": 0.35, + "first_seen": DAY, + "last_evidence_at": DAY, + "evidence": "", + "paths": [], + }, + { + # A dateless reminder is an external notice / key event, i.e. a + # judgement call rather than a clock fact: the floor applies. + "id": topic_id("无日期关键事件"), + "title": "无日期关键事件", + "reason": "外部信源关键事件", + "kind": "reminder", + "confidence": 0.35, + "first_seen": DAY, + "last_evidence_at": DAY, + "evidence": f"resource/{DAY}/feed.md", + "paths": [f"resource/{DAY}/feed.md"], + }, + ] + _write_interests_v2(ws, DAY, topics) + step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws))) + response = await step(RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws))) + assert response.success is True + assert [t["title"] for t in response.answer["topics"]] == ["低置信提醒"] + assert response.answer["topics"][0]["due_at"] == "2026-08-14" + + asyncio.run(run()) + + +# --------------------------------------------------------------------------- +# Schedule registry helpers (clock maths, conflicts, lifecycle) +# --------------------------------------------------------------------------- + + +def test_parse_when_and_canonical_due(): + """Due parsing tolerates ISO and CJK wording; year-less values roll forward.""" + assert parse_when("2026-08-14") == (dt.date(2026, 8, 14), None) + assert parse_when("2026-08-14T09:30") == (dt.date(2026, 8, 14), dt.datetime(2026, 8, 14, 9, 30)) + assert parse_when("2026-08-14 09:30:00+08:00")[1] == dt.datetime(2026, 8, 14, 9, 30) + assert parse_when("2026年8月14日 9点")[1] == dt.datetime(2026, 8, 14, 9, 0) + assert parse_when("8月14日", dt.date(2026, 8, 13))[0] == dt.date(2026, 8, 14) + assert parse_when("8月14日", dt.date(2026, 9, 1))[0] == dt.date(2027, 8, 14) + assert parse_when(" soon ") == (None, None) + assert canonical_due("2026年8月14日 9点") == "2026-08-14T09:00" + assert canonical_due("无法解析") == "无法解析" + + +def test_schedule_view_conflicts_and_triggers(): + """Overlaps need a timed side; the earliest clash announces the conflict.""" + schedules = [ + ProactiveSchedule(id="a", title="评审(全天)", due_at="2026-08-14"), + ProactiveSchedule(id="b", title="客户会议", due_at="2026-08-14T10:00", due_end_at="2026-08-14T11:00"), + ProactiveSchedule(id="c", title="午餐", due_at="2026-08-14T12:00"), + ProactiveSchedule(id="d", title="远期限期", due_at="2026-12-01"), + ProactiveSchedule(id="e", title="逾期项", due_at="2026-08-01"), + ProactiveSchedule(id="f", title="无日期项", due_at=""), + ProactiveSchedule(id="g", title="已完成", due_at="2026-08-14", status="done"), + ] + view = {entry["id"]: entry for entry in schedule_view(schedules, NOW, 3)} + assert view["a"]["trigger"] == "conflict" # all-day vs timed meeting on the same date + assert view["b"]["trigger"] == "due_soon" # the clash is already announced by "a" + assert view["b"]["conflict_with"] == ["评审(全天)"] + # A timed appointment inside an all-day block is still a possible clash; it + # keeps ``due_soon`` only because the clash was announced once already. + assert view["c"]["trigger"] == "due_soon" and view["c"]["conflict_with"] == ["评审(全天)"] + assert view["d"]["trigger"] == "later" + assert view["e"]["trigger"] == "overdue" and view["e"]["due_in_days"] < 0 + assert view["f"]["trigger"] == "unscheduled" + assert view["g"]["trigger"] == "closed" + + titles = [c["title"] for c in reminder_candidates_from_view(list(view.values()), DAY)] + assert titles == ["逾期项", "评审(全天)", "客户会议", "午餐"] # nearest due first, then by hour + assert "已完成" not in titles and "远期限期" not in titles and "无日期项" not in titles + + +def test_merge_reminders_keeps_clock_facts(): + """The model may reword a reminder but cannot move the registry anchor.""" + seeded = [ + { + "id": topic_id("评审会"), + "title": "评审会", + "reason": "模板理由", + "kind": "reminder", + "confidence": 0.6, + "schedule_id": "sch-1", + "due_at": "2026-08-20T14:00", + "next_due": "2026-08-20", + "paths": ["daily/2026-08-01/a.md"], + "trigger": "due_soon", + }, + ] + emitted = [ + { + "id": topic_id("评审会"), + "title": "评审会", + "reason": "素材显示材料还没齐", + "kind": "reminder", + "confidence": 0.9, + "schedule_id": "sch-1", + "due_at": "2026-09-01", + "next_due": "", + "paths": [f"daily/{DAY}/session.md"], + }, + ] + merged = merge_reminders(seeded, emitted) + assert len(merged) == 1 + assert merged[0]["reason"] == "素材显示材料还没齐" + assert merged[0]["confidence"] == 0.9 + assert merged[0]["due_at"] == "2026-08-20T14:00" # anchor kept + assert merged[0]["next_due"] == "2026-08-20" + assert merged[0]["paths"] == ["daily/2026-08-01/a.md", f"daily/{DAY}/session.md"] + assert merged[0]["trigger"] == "due_soon" + + +def test_apply_schedule_updates_requires_anchor(): + """Registry changes need evidence inside this round's material.""" + state_file = ProactiveStateFile() + updates = [ + {"id": "", "action": "add", "title": "新日程", "due_at": "2026-08-20", "evidence": "daily/2026-08-13/s.md"}, + {"id": "", "action": "add", "title": "旧证据日程", "due_at": "2026-08-21", "evidence": "daily/2026-01-01/o.md"}, + {"id": "unknown", "action": "cancel", "evidence": "daily/2026-08-13/s.md"}, + {"id": "", "action": "nonsense", "title": "x"}, + ] + counts, touched = apply_schedule_updates(state_file, updates, DAY, {"daily/2026-08-13/s.md"}) + assert counts == {"added": 1, "updated": 0, "closed": 0, "skipped": 3} + # Only the accepted update is protected from the reminder safety net; the + # three skipped ones never touched the registry. + assert touched == {topic_id("新日程")} + assert [s.title for s in state_file.schedules] == ["新日程"] + + +def test_trim_schedules_lifecycle(): + """The registry outlives topics: only aged closures and hopeless overdues go.""" + state_file = ProactiveStateFile( + schedules=[ + ProactiveSchedule(id="open-future", title="未来", due_at="2027-01-01", status="open", last_seen_at=DAY), + ProactiveSchedule( + id="open-overdue", + title="逾期半年", + due_at="2026-01-01", + status="open", + last_seen_at=DAY, + ), + ProactiveSchedule( + id="closed-old", + title="旧完成", + due_at="2025-01-01", + status="done", + last_seen_at="2025-01-02", + ), + ProactiveSchedule( + id="chatty", + title="多次改期", + due_at="2026-09-01", + status="open", + last_seen_at=DAY, + history=[{"at": DAY, "action": "reschedule"} for _ in range(20)], + ), + ], + ) + dropped = trim_schedules(state_file, DAY, retention_days=180, max_schedules=80, max_history=8) + kept = {s.id: s for s in state_file.schedules} + assert dropped == 1 + assert "closed-old" not in kept + assert kept["open-overdue"].status == "cancelled" # hopeless overdue auto-closed + assert kept["open-overdue"].history[-1]["action"] == "expire" + assert kept["open-future"].status == "open" + assert len(kept["chatty"].history) == 8 # change log capped, newest kept + + +def test_scan_material_resource_window_and_filters(tmp_path): + """Only in-window, text-readable external records join the notice set.""" + ws = tmp_path + _touch(ws / "resource" / DAY / "flight-change.eml", "MU5137 改期") + _touch(ws / "resource" / DAY / "scan.pdf", "binary") + _touch(ws / "resource" / DAY / "_internal.md", "not material") + _touch(ws / "resource" / "2026-01-02" / "old.md", "out of window") + inbox = _touch(ws / "resource" / "inbox.md", "undated root input") + fresh = dt.datetime(2026, 8, 13, 9, 0).timestamp() + os.utime(inbox, (fresh, fresh)) + stale = _touch(ws / "resource" / "stale.md", "landed long ago") + ancient = dt.datetime(2026, 1, 2, 9, 0).timestamp() + os.utime(stale, (ancient, ancient)) + + paths, skipped = scan_material_resource(ws, DAY, 2) + assert paths == [f"resource/{DAY}/flight-change.eml", "resource/inbox.md"] + assert skipped == 1 # the pdf: auto_resource already interprets binaries into daily cards + + with tempfile.TemporaryDirectory() as empty: + assert scan_material_resource(Path(empty), DAY, 2) == ([], 0) + + +def test_resource_notice_feeds_reminder_branch_only(tmp_path): + """A notice alone keeps the round alive; only reminders may cite the resource layer.""" + + async def run(): + ws = tmp_path + notice = f"resource/{DAY}/airline-notice.md" + _touch(ws / notice, "MU5137 改期到 8 月 15 日") + reply = _reply( + follow_ups=[_topic("跟进改签", paths=[notice])], + reminders=[_reminder("航班改期通知", due_at="2026-08-15", paths=[notice])], + ) + with patch(EXTRACT_CLOCK, return_value=NOW): + _, wrapper, catalog, context, resps = await _run_chain(ws, [reply]) + assert all(r.success for r in resps) + state = context.get("proactive") + assert (state["resource_scanned"], state["resource_skipped"]) == (1, 0) + assert state["resource_changed"] == [notice] + assert state["changed_paths"] == [] # no daily note changed this round + # the external notice alone triggered the round: stage 1 + its refine pass + assert state["llm_calls"] == 1 + state["refine_calls"] + assert [t["title"] for t in state["reminders"]] == ["航班改期通知"] + assert state["reminders"][0]["paths"] == [notice] + assert state["follow_ups"] == [] and state["dropped_missing"] == 1 # untraceable kind + assert [n.path for n in catalog.upserts] == [notice] # shared watermark checkpoint + assert "resource_paths" in wrapper.last_inputs[0] + assert "# External notices" in wrapper.last_inputs[0] + assert "3. Notices and key events - scan `resource_notices`" in wrapper.last_system_prompts[0] + + asyncio.run(run()) + + +def test_resources_disabled_keeps_notice_layer_out(tmp_path): + """``resources_enabled=False`` removes the scan, the prompt block and the trigger.""" + + async def run(): + ws = tmp_path + _touch(ws / "resource" / DAY / "airline-notice.md", "MU5137 改期") + with patch(EXTRACT_CLOCK, return_value=NOW): + _, wrapper, catalog, context, resps = await _run_chain( + ws, + [_reply(follow_ups=[])], + extract_kwargs={"resources_enabled": False}, + ) + assert all(r.success for r in resps) + state = context.get("proactive") + assert (state["resource_scanned"], state["resource_changed"]) == (0, []) + assert state["early_exit"] == "no_new_evidence" # the notice is not material + assert wrapper.calls == 0 + assert catalog.upserts == [] and catalog.dumps == 0 + + _touch(ws / "daily" / DAY / "session.md", "material") + _, wrapper2, _, _, _ = await _run_chain( + ws, + [_reply(follow_ups=[])], + extract_kwargs={"resources_enabled": False}, + ) + assert "resource_paths" not in wrapper2.last_inputs[0] + assert "# External notices" not in wrapper2.last_inputs[0] + assert "resource_notices" not in wrapper2.last_system_prompts[0] # branch C job 3 gone too + assert "[" not in wrapper2.last_inputs[0].split("# Material")[-1] # no unrendered flag leaked + + asyncio.run(run()) + + +def test_degraded_round_does_not_consume_notices(tmp_path): + """A timeout still delivers due reminders and leaves the unread notice for next round.""" + + async def run(): + ws = tmp_path + _touch(ws / "resource" / DAY / "airline-notice.md", "MU5137 改期") + _write_state(ws, schedules=[_schedule("提交季度报告", "2026-08-14T09:00")]) + wrapper = _AgentWrapper(["never returned"], delay=0.5) + with patch(EXTRACT_CLOCK, return_value=NOW): + _, _, catalog, context, resps = await _run_full_chain( + ws, + wrapper=wrapper, + extract_kwargs={"llm_timeout_seconds": 0.05}, + ) + assert all(r.success for r in resps) + state = context.get("proactive") + assert state["reminder_only"] is True + assert state["changed_paths"] == [] and state["resource_changed"] == [] + assert catalog.upserts == [] and catalog.dumps == 0 + data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8")) + assert [item["title"] for item in data["agenda"]] == ["提交季度报告"] + + asyncio.run(run()) + + +# --------------------------------------------------------------------------- +# Stage 2: per-kind adjudication (precision pass) +# --------------------------------------------------------------------------- + + +def test_refine_stage_drops_moves_and_rescores(tmp_path): + """Stage 2 adjudicates each kind's own candidates: drop, move and rescore.""" + + async def run(): + ws = tmp_path + path = f"daily/{DAY}/session.md" + _touch(ws / path, "material") + titles = ("值得跟进的事", "其实是可委托的活", "站不住脚的猜测") + reply = _reply(follow_ups=[_topic(title, paths=[path]) for title in titles]) + refine = _verdicts( + _verdict(topic_id("站不住脚的猜测"), "drop", reason="no visible open loop"), + _verdict(topic_id("其实是可委托的活"), "move", to_kind="new_idea", reason="delegatable work"), + _verdict(topic_id("值得跟进的事"), "keep", confidence=0.9, reason=""), + ) + wrapper = _AgentWrapper([reply], refine_replies=[refine]) + _, _, _, context, _ = await _run_chain(ws, wrapper=wrapper) + state = context.get("proactive") + assert (state["refine_calls"], state["refine_degraded"]) == (1, 0) + assert wrapper.refine_calls == 1 # only follow_up produced candidates + assert [t["title"] for t in state["follow_ups"]] == ["值得跟进的事"] + assert state["follow_ups"][0]["confidence"] == 0.9 # rescored per rubric + assert [t["title"] for t in state["new_ideas"]] == ["其实是可委托的活"] + assert state["new_ideas"][0]["kind"] == "new_idea" # re-bucketed by the move + assert [d["title"] for d in state["refine_dropped"]] == ["站不住脚的猜测"] + assert state["refine_moved"][0]["to_kind"] == "new_idea" + assert "kind under review: follow_up" in wrapper.last_inputs[-1] + + asyncio.run(run()) + + +def test_refine_stage_is_fail_open(tmp_path): + """An unusable or timed-out refine pass keeps the stage-1 candidates.""" + + async def run(): + ws = tmp_path + path = f"daily/{DAY}/session.md" + _touch(ws / path, "material") + reply = _reply(follow_ups=[_topic("候选一", paths=[path])]) + + _, _, _, context, _ = await _run_chain( + ws, + wrapper=_AgentWrapper([reply], refine_replies=["not a verdict block"]), + ) + state = context.get("proactive") + assert state["refine_degraded"] == 1 and state["refine_calls"] == 1 + assert [t["title"] for t in state["follow_ups"]] == ["候选一"] + + ws2 = tmp_path / "second" + _touch(ws2 / "daily" / DAY / "session.md", "material") + _, _, _, context2, _ = await _run_chain( + ws2, + wrapper=_AgentWrapper([reply], refine_replies=[(KEEP_ALL_VERDICTS, 0.5)]), + extract_kwargs={"refine_timeout_seconds": 0.05}, + ) + state2 = context2.get("proactive") + assert state2["refine_degraded"] == 1 + assert [t["title"] for t in state2["follow_ups"]] == ["候选一"] + + asyncio.run(run()) + + +def test_parse_refine_reply_shapes(): + """Only a genuinely unparseable reply is unusable; an empty verdict list is not.""" + assert parse_refine_reply("```yaml\nverdicts:\n```") == [] # YAML's spelling of [] + assert parse_refine_reply(KEEP_ALL_VERDICTS) == [] + assert parse_refine_reply("```yaml\nfoo: 1\n```") is None # format not followed + assert parse_refine_reply("no fenced block here") is None + assert parse_refine_reply("```yaml\nverdicts: {id: x}\n```") is None # not a list + assert parse_refine_reply(_verdicts({"id": "x1", "decision": "drop", "reason": "weak"})) == [ + {"id": "x1", "decision": "drop", "to_kind": "", "reason": "weak"}, + ] + + +def test_refine_empty_verdicts_is_not_a_degrade(tmp_path): + """ "Nothing to change" is the expected clean answer, so it must not count as degraded.""" + + async def run(): + ws = tmp_path + path = f"daily/{DAY}/session.md" + _touch(ws / path, "material") + reply = _reply(follow_ups=[_topic("候选一", paths=[path])]) + _, _, _, context, _ = await _run_chain( + ws, + wrapper=_AgentWrapper([reply], refine_replies=["```yaml\nverdicts:\n```"]), + ) + state = context.get("proactive") + assert (state["refine_calls"], state["refine_degraded"]) == (1, 0) + assert [t["title"] for t in state["follow_ups"]] == ["候选一"] + + asyncio.run(run()) + + +def test_refine_disabled_keeps_stage1_output(tmp_path): + """``refine_enabled=False`` restores the single-call round.""" + + async def run(): + ws = tmp_path + path = f"daily/{DAY}/session.md" + _touch(ws / path, "material") + reply = _reply(follow_ups=[_topic("候选一", paths=[path])]) + wrapper = _AgentWrapper([reply]) + _, _, _, context, _ = await _run_chain(ws, wrapper=wrapper, extract_kwargs={"refine_enabled": False}) + state = context.get("proactive") + assert (state["refine_calls"], wrapper.refine_calls) == (0, 0) + assert state["llm_calls"] == 1 + assert [t["title"] for t in state["follow_ups"]] == ["候选一"] + + asyncio.run(run()) + + +def test_refine_never_touches_clock_reminders(tmp_path): + """Seeded clock reminders are merged after stage 2, so no pass can drop them.""" + + async def run(): + ws = tmp_path + path = f"daily/{DAY}/session.md" + _touch(ws / path, "material") + _write_state(ws, schedules=[_schedule("提交季度报告", "2026-08-14T09:00")]) + reply = _reply(follow_ups=[_topic("候选一", paths=[path])]) + hostile = _verdicts(_verdict(topic_id("候选一"), "drop", reason="rejected")) + wrapper = _AgentWrapper([reply], refine_replies=[hostile]) + with patch(EXTRACT_CLOCK, return_value=NOW): + _, _, _, context, resps = await _run_full_chain(ws, wrapper=wrapper) + assert all(r.success for r in resps) + state = context.get("proactive") + assert state["follow_ups"] == [] # the drop was applied + assert [r["title"] for r in state["reminders"]] == ["提交季度报告"] + assert wrapper.refine_calls == 1 # the reminder bucket was empty at stage 2 + data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8")) + assert [item["title"] for item in data["agenda"]] == ["提交季度报告"] + + asyncio.run(run()) + + +def test_key_event_reminder_without_due_date(tmp_path): + """A resource key event may be dateless; it is capped and never registered.""" + + async def run(): + ws = tmp_path + notice = f"resource/{DAY}/policy-feed.md" + _touch(ws / notice, "监管新规发布,影响用户持续跟踪的品种") + reply = _reply( + reminders=[ + _reminder(f"关键事件{i}", reason="外部信源关键事件", confidence=0.8, paths=[notice]) for i in range(4) + ], + ) + with patch(EXTRACT_CLOCK, return_value=NOW): + _, wrapper, _, context, _ = await _run_chain(ws, [reply]) + state = context.get("proactive") + assert len(state["reminders"]) == 3 # max_notice_reminders default + assert state["dropped_over_cap"] == 1 + assert all(not r["due_at"] for r in state["reminders"]) + assert state["schedules_added"] == 0 # dateless events do not enter the registry + assert "key event" in wrapper.last_system_prompts[0] + + # A reminder anchored on daily material still needs a due date: only the + # external-notice sub-type may be dateless. + daily_note = f"daily/{DAY}/session.md" + daily_only = _reply(reminders=[_reminder("无日期日程", reason="x", paths=[daily_note])]) + ws2 = tmp_path / "second" + _touch(ws2 / daily_note, "material") + with patch(EXTRACT_CLOCK, return_value=NOW): + _, _, _, context2, _ = await _run_chain(ws2, [daily_only]) + assert context2.get("proactive")["reminders"] == [] + assert context2.get("proactive")["dropped_missing"] == 1 + + asyncio.run(run()) + + +def test_state_file_bare_yaml_dates_load(tmp_path): + """Unquoted YAML dates must not invalidate the long-lived registry. + + ``yaml.safe_load`` reads a bare ``2026-08-05`` as ``datetime.date``. The + truth source is hand-editable and the registry has to outlive topics by + months, so those scalars are coerced back to ISO text instead of failing + validation and taking every schedule down with them. + """ + raw = ( + "version: 1\n" + "open_topics:\n" + " - id: t1\n" + " title: 历史主题\n" + " kind: follow_up\n" + " first_seen: 2026-07-01\n" + " last_evidence_at: 2026-07-02\n" + "resolved: []\n" + "schedules:\n" + " - id: sch-tax\n" + " title: 个税补充申报\n" + " due_at: 2026-08-05\n" + " status: open\n" + " first_seen: 2026-07-01\n" + " last_seen_at: 2026-07-01\n" + " history:\n" + " - at: 2026-07-01\n" + " action: add\n" + " to: 2026-08-05T09:00:00\n" + ) + _touch(tmp_path / "daily" / "_proactive.yaml", raw) + + state_file, needs_bootstrap = load_state(tmp_path) + + assert needs_bootstrap is False + topic = state_file.open_topics[0] + assert (topic.first_seen, topic.last_evidence_at) == ("2026-07-01", "2026-07-02") + schedule = state_file.schedules[0] + assert schedule.id == "sch-tax" and schedule.due_at == "2026-08-05" + assert (schedule.first_seen, schedule.last_seen_at) == ("2026-07-01", "2026-07-01") + assert schedule.history[0]["at"] == "2026-07-01" + assert schedule.history[0]["to"] == "2026-08-05T09:00:00" + + +def test_state_file_salvages_unreadable_entries(tmp_path): + """One malformed entry costs that entry, never the whole truth source.""" + raw = ( + "version: 1\n" + "open_topics:\n" + " - id: good\n" + " title: 正常主题\n" + " kind: follow_up\n" + " - id: bad\n" + " title:\n" + " nested: mapping\n" + "schedules:\n" + " - id: sch-bad\n" + " title:\n" + " nested: mapping\n" + " due_at: 2026-08-14\n" + " - id: sch-earnings-call\n" + " title: 财报电话会\n" + " due_at: 2026-08-14T20:00\n" + " first_seen: 2026-07-01\n" + "resolved:\n" + " - id: tomb\n" + " - not-a-mapping\n" + ) + _touch(tmp_path / "daily" / "_proactive.yaml", raw) + + state_file, needs_bootstrap = load_state(tmp_path) + + # Salvage is not a first run, so the interests.yaml bootstrap stays off. + assert needs_bootstrap is False + assert [t.id for t in state_file.open_topics] == ["good"] + assert [s.id for s in state_file.schedules] == ["sch-earnings-call"] + assert state_file.schedules[0].due_at == "2026-08-14T20:00" + assert [r["id"] for r in state_file.resolved] == ["tomb"] + + +def test_refine_cannot_demote_a_notice_reminder(tmp_path): + """Stage 2 may not move a resource-anchored reminder into a kind that cannot quote it.""" + + async def run(): + ws = tmp_path + notice = f"resource/{DAY}/policy-feed.md" + daily = f"daily/{DAY}/session.md" + _touch(ws / notice, "监管新规发布,实质影响用户跟踪的品种") + _touch(ws / daily, "material") + reply = _reply( + reminders=[ + _reminder("监管新规", reason="外部信源关键事件", paths=[notice]), + _reminder("内部评审", reason="临近到期", due_at="2026-08-14T09:00", paths=[daily]), + ], + follow_ups=[_topic("待跟进", paths=[daily])], + ) + hostile = _verdicts( + _verdict(topic_id("监管新规"), "move", to_kind="interest_extend", reason="只是背景阅读"), + _verdict(topic_id("内部评审"), "move", to_kind="follow_up", reason="其实是未闭环事项"), + ) + wrapper = _AgentWrapper([reply], refine_replies=[hostile]) + with patch(EXTRACT_CLOCK, return_value=NOW): + _, _, _, context, _ = await _run_chain(ws, wrapper=wrapper) + state = context.get("proactive") + + # The notice stays a reminder: only that branch may cite resource/. + assert [r["title"] for r in state["reminders"]] == ["监管新规"] + assert [t["title"] for t in state["extends"]] == [] + # The daily-anchored move is legal, and leaves no dangling registry link. + moved = next(t for t in state["follow_ups"] if t["title"] == "内部评审") + assert moved["kind"] == "follow_up" + leaked = [key for key in ("schedule_id", "due_at", "next_due", "trigger", "conflict_with") if key in moved] + assert leaked == [] + assert [m["to_kind"] for m in state["refine_moved"]] == ["follow_up"] + + asyncio.run(run()) + + +def test_refine_refuses_an_impossible_move_into_reminder(tmp_path): + """Stage 2 cannot attach a due date, so a dateless daily candidate stays put.""" + + async def run(): + ws = tmp_path + path = f"daily/{DAY}/session.md" + _touch(ws / path, "material") + reply = _reply(follow_ups=[_topic("带日期的承诺", paths=[path])]) + refine = _verdicts(_verdict(topic_id("带日期的承诺"), "move", to_kind="reminder", reason="其实是日程")) + wrapper = _AgentWrapper([reply], refine_replies=[refine]) + _, _, _, context, _ = await _run_chain(ws, wrapper=wrapper) + state = context.get("proactive") + + assert [t["title"] for t in state["follow_ups"]] == ["带日期的承诺"] + assert state["reminders"] == [] + assert state["refine_moved"] == [] # refused outright, not recorded as a move + + asyncio.run(run()) + + +def test_reminder_cannot_revert_an_explicit_reschedule(tmp_path): + """``schedule_updates`` is the only channel that may move a due date. + + The clock scan reads the registry *before* the LLM reports a reschedule, so + the reminder it seeds still carries the old due value. Writing that back + would silently undo the change the material actually reported. + """ + + async def run(): + ws = tmp_path + path = f"daily/{DAY}/session.md" + _touch(ws / path, "投顾通知复盘延后半小时") + _write_state(ws, schedules=[_schedule("季度持仓复盘", "2026-08-13T21:00", schedule_id="sch-review")]) + reply = _reply( + reminders=[ + _reminder( + "季度持仓复盘", + due_at="2026-08-13T21:00", + reason="今晚复盘,投顾通知可能延后半小时", + paths=[path], + schedule_id="sch-review", + ), + ], + schedule_updates=[ + { + "action": "reschedule", + "id": "sch-review", + "title": "季度持仓复盘", + "due_at": "2026-08-13T21:30", + "evidence": path, + "reason": "投顾通知延后半小时开始", + }, + ], + ) + with patch(EXTRACT_CLOCK, return_value=NOW): + _, _, _, context, resps = await _run_full_chain(ws, [reply]) + assert all(r.success for r in resps) + state = context.get("proactive") + assert (state["schedules_added"], state["schedules_updated"]) == (0, 1) + # The clock scan seeded this reminder with the pre-update value; the + # published due date a reader sorts by has to follow the registry. + assert state["reminders_reanchored"] == 1 + assert [r["due_at"] for r in state["reminders"]] == ["2026-08-13T21:30"] + + entry = next(s for s in _read_state(ws)["schedules"] if s["id"] == "sch-review") + assert entry["due_at"] == "2026-08-13T21:30" # the stale reminder did not revert it + assert [(h["action"], h["to"]) for h in entry["history"]] == [("reschedule", "2026-08-13T21:30")] + published = yaml.safe_load(_interests(ws).read_text(encoding="utf-8")) + assert [t["due_at"] for t in published["topics"] if t["kind"] == "reminder"] == ["2026-08-13T21:30"] + + asyncio.run(run()) + + +def test_reminder_heavy_day_keeps_discovery_slots(tmp_path): + """Pinned reminders never starve the discovery kinds of cards and slots.""" + + async def run(): + ws = tmp_path + path = f"daily/{DAY}/session.md" + _touch(ws / path, "material") + # Seven schedules due today: more than max_agenda_items (6), which used + # to leave zero discovery slots and card-less, suppressed candidates. + _write_state( + ws, + schedules=[_schedule(f"今日日程{i}", "2026-08-13T09:00", schedule_id=f"sch-{i}") for i in range(7)], + ) + reply = _reply(follow_ups=[_topic("待跟进事项", paths=[path])]) + with patch(EXTRACT_CLOCK, return_value=NOW): + _, _, _, context, resps = await _run_full_chain(ws, [reply]) + assert all(r.success for r in resps) + state = context.get("proactive") + assert len(state["reminders"]) == 7 + + carded = {c["topic_id"]: c["kind"] for c in state["scenario_cards"]} + assert list(carded.values()).count("follow_up") == 1 # expanded, not left card-less + data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8")) + kinds = [item["kind"] for item in data["agenda"]] + assert kinds.count("reminder") == 7 and kinds.count("follow_up") == 1 + assert all(item["kind"] != "follow_up" for item in data["suppressed"] if "kind" in item) + assert not any(s["title"] == "待跟进事项" for s in data["suppressed"]) + + asyncio.run(run()) + + +def test_schedule_update_may_anchor_on_a_notice(tmp_path): + """The registry accepts notice-anchored updates; discovery topics still may not. + + Only the reminder branch may cite the ``resource/`` layer, and + ``clean_schedule_update`` already accepts those paths - so the registry + anchor has to agree, or a dated external notice could never be registered + through the explicit channel and would lose its detail/end-time/recurrence. + """ + + async def run(): + ws = tmp_path + notice = f"resource/{DAY}/airline.md" + daily = f"daily/{DAY}/session.md" + _touch(ws / notice, "MU5137 改期,改签截止 2026-08-14 18:00") + _touch(ws / daily, "material") + _write_state(ws, open_topics=[_state_topic("既有主题")]) + reply = _reply( + schedule_updates=[ + { + "action": "add", + "title": "改签截止", + "due_at": "2026-08-14T18:00", + "due_end_at": "", + "detail": "免手续费", + "evidence": notice, + "reason": "航司通知改期", + }, + ], + updates=[{"id": topic_id("既有主题"), "action": "update", "evidence": notice, "confidence": 0.9}], + ) + with patch(EXTRACT_CLOCK, return_value=NOW): + _, _, _, context, resps = await _run_full_chain(ws, [reply]) + assert all(r.success for r in resps) + state = context.get("proactive") + + schedules = _read_state(ws)["schedules"] + assert [s["title"] for s in schedules] == ["改签截止"] + assert schedules[0]["due_at"] == "2026-08-14T18:00" + assert schedules[0]["detail"] == "免手续费" # only the explicit channel carries this + assert state["schedules_added"] == 1 + # A discovery topic still may not take its evidence from the notice layer. + assert state["updates_applied"] == 0 + + asyncio.run(run())