From 480451696b91a0636e23f1a73d07f71f1a773fd7 Mon Sep 17 00:00:00 2001 From: imrewce Date: Tue, 13 Oct 2026 10:59:41 +0800 Subject: [PATCH] fix(proactive): exempt reminders from the redelivery cooldown A reminder's topic_id derives from a stable schedule title, so blocking it like a discovery item silenced a recurring reminder for the whole cooldown window after a single delivery. A schedule that is due has to be announced every time it comes due. Both layers that apply the cooldown now let kind == "reminder" through: * the agenda step dropped already-delivered candidates *before* pinning reminders, so a reported reminder never reached the pinning guarantee; * the reader's _select_agenda withheld it again on the way out, and counted it in delivered_ids. The other three kinds keep the existing behaviour and `failed` deliveries still never block. Each site gets a regression test that fails without the exemption. --- reme/steps/evolve/proactive/agenda.py | 10 +++ reme/steps/evolve/proactive/proactive.py | 4 +- tests/unit/test_proactive_feedback.py | 94 ++++++++++++++++++++++++ 3 files changed, 107 insertions(+), 1 deletion(-) diff --git a/reme/steps/evolve/proactive/agenda.py b/reme/steps/evolve/proactive/agenda.py index a4edda68..f51eaa7c 100644 --- a/reme/steps/evolve/proactive/agenda.py +++ b/reme/steps/evolve/proactive/agenda.py @@ -150,6 +150,16 @@ class ProactiveAgendaStep(BaseStep): # so the cooldown cannot be argued away by narrative coherence. daily = state.daily_dir or daily_dir(self) blocked = self._suppressed_deliveries(ws, daily, state.date) + # Reminders are the one exemption: a due schedule has to be announced + # every time it comes due, and reminder ids derive from a stable title, + # so blocking them would silence a recurring reminder for the whole + # cooldown window after a single delivery. + reminder_ids = {str(c.get("id")) for c in candidates if str(c.get("kind")) == "reminder"} + blocked = { + blocked_id: record + for blocked_id, record in blocked.items() + if blocked_id not in reminder_ids + } delivered = [c for c in candidates if str(c.get("id")) in blocked] if delivered: carded = [c for c in carded if str(c.get("id")) not in blocked] diff --git a/reme/steps/evolve/proactive/proactive.py b/reme/steps/evolve/proactive/proactive.py index bcbeaff7..7f86227f 100644 --- a/reme/steps/evolve/proactive/proactive.py +++ b/reme/steps/evolve/proactive/proactive.py @@ -260,7 +260,9 @@ class ProactiveStep(BaseStep): item_id = str(item.get("topic_id") or "") if item_id not in kept_ids: continue - if item_id in blocked: + # Reminders stay exempt from the cooldown, matching the agenda + # step: a due schedule is announced every time it comes due. + if item_id in blocked and str(item.get("kind") or "") != "reminder": withheld.append(item_id) continue agenda.append(item) diff --git a/tests/unit/test_proactive_feedback.py b/tests/unit/test_proactive_feedback.py index a79eb2a7..b80db951 100644 --- a/tests/unit/test_proactive_feedback.py +++ b/tests/unit/test_proactive_feedback.py @@ -9,6 +9,7 @@ empty ``deliveries`` section changes nothing. import asyncio import datetime as dt +from unittest.mock import patch import yaml @@ -29,9 +30,11 @@ from reme.steps.evolve.proactive.utils import ( from .test_proactive_refresh import ( DAY, + EXTRACT_CLOCK, _FileStore, _interests, _reply, + _reminder, _run_full_chain, _state_topic, _topic, @@ -452,6 +455,46 @@ def test_reader_reminder_still_outranks_and_survives(tmp_path): asyncio.run(run()) +def test_reader_reminder_is_exempt_from_the_cooldown(tmp_path): + """A reminder delivered inside the window is served again: due means due. + + Reminder ids derive from a stable schedule title, so withholding them like + discovery items would silence a recurring reminder for the whole cooldown + after a single delivery. + """ + + async def run(): + ws = tmp_path + rid = topic_id("牙医预约") + reminder = _state_topic("牙医预约", topic_id_value=rid, kind="reminder", confidence=0.9) + _write_state_with_deliveries( + ws, + [ + { + "topic_id": rid, + "agenda_date": DAY, + "delivered_at": f"{DAY}T09:00:00+08:00", + "channel": "inbox", + "status": "shown", + }, + ], + open_topics=[reminder], + ) + _write_interests_with_agenda( + ws, + [reminder], + [_agenda_item(rid, "牙医预约", kind="reminder", scenario_type="prepare_upcoming")], + ) + step = ProactiveStep(app_context=ApplicationContext(workspace_dir=str(ws))) + response = await step(RuntimeContext(date=DAY, include_content=False, file_store=_FileStore(ws))) + assert [i["topic_id"] for i in response.answer["agenda"]] == [rid] + assert response.answer["agenda"][0]["kind"] == "reminder" + assert "delivered_ids" not in response.answer + assert "withheld" not in response.answer["summary"] + + asyncio.run(run()) + + # --------------------------------------------------------------------------- # proactive_agenda_step (writer) # --------------------------------------------------------------------------- @@ -497,6 +540,57 @@ def test_agenda_step_suppresses_delivered_candidate(tmp_path): asyncio.run(run()) +def test_agenda_step_reminder_is_exempt_from_the_cooldown(tmp_path): + """A reported reminder is pinned again while a reported discovery item is not.""" + + async def run(): + ws = tmp_path + _touch(ws / "daily" / DAY / "session.md", "牙医复诊在即,评审材料还差一版") + path = f"daily/{DAY}/session.md" + rid = topic_id("牙医复诊") + _write_state_with_deliveries( + ws, + [ + { + "topic_id": rid, + "agenda_date": DAY, + "delivered_at": f"{DAY}T09:00:00+08:00", + "channel": "inbox", + "status": "shown", + }, + { + "topic_id": topic_id("任务一"), + "agenda_date": DAY, + "delivered_at": f"{DAY}T09:00:00+08:00", + "channel": "inbox", + "status": "shown", + }, + ], + ) + extract_reply = _reply( + reminders=[_reminder("牙医复诊", due_at="2026-08-14T15:00", paths=[path])], + follow_ups=[ + _topic("任务一", confidence=0.9, paths=[path]), + _topic("任务二", confidence=0.8, paths=[path]), + ], + ) + with patch(EXTRACT_CLOCK, return_value=NOW): + _, _, _, context, resps = await _run_full_chain(ws, [extract_reply]) + assert all(r.success for r in resps) + + # only the discovery candidate counts as suppressed by the cooldown + assert context.get("proactive")["suppressed_delivered"] == 1 + data = yaml.safe_load(_interests(ws).read_text(encoding="utf-8")) + titles = [i["title"] for i in data["agenda"]] + assert "牙医复诊" in titles + assert "任务一" not in titles + assert data["agenda"][0]["kind"] == "reminder" # still pinned first + suppressed = {s["topic_id"] for s in data["suppressed"]} + assert rid not in suppressed + + asyncio.run(run()) + + def test_agenda_step_without_feedback_is_unchanged(tmp_path): """No delivery log means the deterministic fallback agenda stays intact."""