mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-10-23 05:35:30 +00:00
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.
This commit is contained in:
parent
084c02e43a
commit
c0bad3d30e
16 changed files with 4231 additions and 225 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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` 里出现未加引号的
|
||||
冒号会让整份回复无法解析,议程随之退化为确定性排序。两类理由都请控制在一
|
||||
句话以内。
|
||||
|
||||
## 输出格式
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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}/<date>/...`);
|
||||
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}/<date>/...`); 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: <specific unresolved matter>
|
||||
reason: <why it is still open>
|
||||
confidence: 0.7
|
||||
paths: [<changed path>, ...]
|
||||
[extends]extends:
|
||||
[extends] - title: <related-but-unfocused topic>
|
||||
[extends] reason: <why it matters, grounded in the material>
|
||||
[extends] confidence: 0.5
|
||||
[extends] paths: [<changed path>, ...]
|
||||
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>
|
||||
{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}/<date>/...`),越界条目会被丢弃。
|
||||
- 上限是天花板而不是目标,超出上限的条目会在下游被丢弃:follow_ups 至多
|
||||
{max_follow_ups} 条。
|
||||
[extends]- 上限:extends 至多 {max_extends} 条。
|
||||
[ideas]- 上限:new_ideas 至多 {max_new_ideas} 条。
|
||||
[reminders]- reminders 不设上限,但每条都必须来自真实的日程检查结论,
|
||||
[reminders]绝不为了凑提醒编造日程。
|
||||
- 每个主题必须可追溯:`paths` 只能使用 changed_paths 中的值(路径形如
|
||||
`{daily_dir}/<date>/...`),越界条目会被丢弃。
|
||||
[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: <specific unresolved matter>
|
||||
reason: <latest progress seen in the material, and what to go fetch and report>
|
||||
confidence: 0.7
|
||||
paths:
|
||||
- <changed path>
|
||||
|
||||
extract_shape_extends: |
|
||||
extends:
|
||||
- title: <related-but-unfocused topic>
|
||||
reason: <which cared-about topic it refines or extends, and the wiki node it would add>
|
||||
confidence: 0.5
|
||||
paths:
|
||||
- <changed path>
|
||||
|
||||
extract_shape_reminders: |
|
||||
reminders:
|
||||
- title: <the scheduled matter>
|
||||
reason: <why now: due soon / due today / overdue / clashes with what>
|
||||
due_at: <YYYY-MM-DD or YYYY-MM-DDTHH:MM>
|
||||
schedule_id: <registry id; omit only for a brand-new schedule>
|
||||
conflict_with: [<title or schedule_id it clashes 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。
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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: [<条件>, ...]
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
196
reme/steps/evolve/proactive/refine.py
Normal file
196
reme/steps/evolve/proactive/refine.py
Normal file
|
|
@ -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"],
|
||||
}
|
||||
1008
reme/steps/evolve/proactive/schedules.py
Normal file
1008
reme/steps/evolve/proactive/schedules.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
Loading…
Add table
Reference in a new issue