Skip to content

Commit 0556042

Browse files
annawiewerCopilot
andcommitted
fix: persistent alert state, push retry, structured follow-up
1. Persistent alert/dedupe state: - _notified_evening_slots and _slot_alerts saved to data/alert_state.json - Loaded on startup via _load_alert_state() - Survives server restarts — no duplicate notifications 2. Push notification retry: - Failed push notifications retry up to 3 attempts - Exponential backoff (0s, 1s, 2s) - Only retries on failed/error status, stops on sent/partial 3. Structured follow-up detection: - Bilingual keyword lists for form and notify follow-ups - Extracted to _detect_followup() method - Supports both German and English assistant responses - Follow-up confirmation logged explicitly Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
1 parent 59f3941 commit 0556042

2 files changed

Lines changed: 90 additions & 25 deletions

File tree

src/wiesn_agent/api.py

Lines changed: 64 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@
4949
WEB_DIST = next((p for p in _WEB_DIST_CANDIDATES if p.exists()), _WEB_DIST_CANDIDATES[0])
5050
CHAT_LOG_FILE = DATA_DIR / "chat_history.json"
5151
ACTIVITY_LOG_FILE = DATA_DIR / "activity_log.json"
52+
ALERT_STATE_FILE = DATA_DIR / "alert_state.json"
5253

5354
# Track background scanner state
5455
_scanner_task: asyncio.Task | None = None
@@ -335,8 +336,42 @@ async def _scan_portals(portals: list, config: WiesnConfig) -> list[dict]:
335336
# Track already-notified evening slots to avoid repeated push notifications.
336337
# Key: "portal_name|datum_text|time_text"
337338
_notified_evening_slots: set[str] = set()
338-
# Track slots where push delivery succeeded (separate from detection)
339-
_push_delivered_slots: set[str] = set()
339+
340+
341+
def _save_alert_state() -> None:
342+
"""Persist dedupe set and recent alerts to disk."""
343+
try:
344+
DATA_DIR.mkdir(parents=True, exist_ok=True)
345+
state = {
346+
"notified_slots": list(_notified_evening_slots),
347+
"alerts": list(_slot_alerts),
348+
"alert_id": _slot_alert_id,
349+
}
350+
ALERT_STATE_FILE.write_text(
351+
json.dumps(state, ensure_ascii=False, indent=2), encoding="utf-8"
352+
)
353+
except Exception as e:
354+
logger.warning("Failed to save alert state: %s", e)
355+
356+
357+
def _load_alert_state() -> None:
358+
"""Restore dedupe set and recent alerts from disk on startup."""
359+
global _slot_alert_id
360+
if not ALERT_STATE_FILE.exists():
361+
return
362+
try:
363+
data = json.loads(ALERT_STATE_FILE.read_text(encoding="utf-8"))
364+
if isinstance(data.get("notified_slots"), list):
365+
_notified_evening_slots.update(data["notified_slots"])
366+
logger.info("Restored %d notified slot keys from disk", len(_notified_evening_slots))
367+
if isinstance(data.get("alerts"), list):
368+
for a in data["alerts"][-50:]:
369+
_slot_alerts.append(a)
370+
logger.info("Restored %d slot alerts from disk", len(_slot_alerts))
371+
if isinstance(data.get("alert_id"), int):
372+
_slot_alert_id = max(_slot_alert_id, data["alert_id"])
373+
except Exception as e:
374+
logger.warning("Failed to load alert state: %s", e)
340375

341376

342377
async def _notify_new_evening_slots(results: list[dict], config: WiesnConfig) -> None:
@@ -380,7 +415,7 @@ async def _notify_new_evening_slots(results: list[dict], config: WiesnConfig) ->
380415
datum_text_val = ds.get("datum_text", ds.get("datum_value", ""))
381416
_push_slot_alert(portal_name, datum_text_val, times, booking_url)
382417

383-
# Push notification only if outside quiet hours
418+
# Push notification only if outside quiet hours (with retry)
384419
push_status = "skipped_quiet_hours"
385420
if push_allowed:
386421
title = f"Evening slots: {portal_name}"
@@ -390,18 +425,28 @@ async def _notify_new_evening_slots(results: list[dict], config: WiesnConfig) ->
390425
f"🌙 {times}\n"
391426
f"\n→ Book now: {booking_url}"
392427
)
393-
result_json = await send_notification(
394-
title=title,
395-
message=message,
396-
config=config.notifications,
397-
notify_type="success",
398-
event_type="evening_slot",
399-
)
400-
try:
401-
result_data = _json.loads(result_json)
402-
push_status = result_data.get("status", "unknown")
403-
except (ValueError, TypeError):
404-
push_status = "error"
428+
# Retry up to 2 times on failure
429+
for attempt in range(3):
430+
result_json = await send_notification(
431+
title=title,
432+
message=message,
433+
config=config.notifications,
434+
notify_type="success",
435+
event_type="evening_slot",
436+
)
437+
try:
438+
result_data = _json.loads(result_json)
439+
push_status = result_data.get("status", "unknown")
440+
except (ValueError, TypeError):
441+
push_status = "error"
442+
if push_status in ("sent", "partial"):
443+
break
444+
if attempt < 2:
445+
logger.warning(
446+
"Push notification failed (attempt %d/3) for %s, retrying...",
447+
attempt + 1, portal_name,
448+
)
449+
await asyncio.sleep(2 ** attempt)
405450

406451
# Log truthfully
407452
_log_activity(
@@ -414,6 +459,9 @@ async def _notify_new_evening_slots(results: list[dict], config: WiesnConfig) ->
414459
portal=portal_name, datum=datum_text_val,
415460
push_status=push_status)
416461

462+
# Persist alert state after processing
463+
_save_alert_state()
464+
417465

418466
async def _background_scanner() -> None:
419467
"""Periodically scan all enabled portals in the background."""
@@ -461,6 +509,7 @@ async def lifespan(app: FastAPI):
461509
# Restore persisted state
462510
_load_chat_log()
463511
_load_activity_log()
512+
_load_alert_state()
464513

465514
_scanner_task = asyncio.create_task(_background_scanner())
466515
logger.info("[Scanner] Background scanner started")

src/wiesn_agent/chat_agent.py

Lines changed: 26 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -275,6 +275,16 @@ class TriageExecutor(Executor):
275275
"ja bitte", "ja gerne", "mach das", "ja mach das",
276276
))
277277

278+
# Structured follow-up detection keywords by language
279+
_FOLLOWUP_FORM_KW = [
280+
"formular", "ausfüllen", "fill out", "fill in", "reservation form",
281+
"reservierungsformular", "book for you", "shall i fill",
282+
]
283+
_FOLLOWUP_NOTIFY_KW = [
284+
"benachrichtig", "notify", "alert you", "test-benachrichtigung",
285+
"notification channels", "let you know", "inform you",
286+
]
287+
278288
def __init__(self) -> None:
279289
super().__init__(id="triage")
280290
self._last_intent = "chat"
@@ -300,23 +310,29 @@ async def handle(
300310
lower = user_text.lower().strip()
301311
if lower in self._CONFIRM_WORDS:
302312
intent = self._pending_followup
313+
logger.info("[Triage] Follow-up confirmed: %s → %s", lower, intent)
303314

304-
# Detect what the last assistant message offered as follow-up
305-
self._pending_followup = None
306-
for m in reversed(messages):
307-
if m.role == "assistant" and m.text:
308-
lower_reply = m.text.lower()
309-
if "formular" in lower_reply or "ausfüllen" in lower_reply:
310-
self._pending_followup = "form"
311-
elif "benachrichtig" in lower_reply or "notify" in lower_reply:
312-
self._pending_followup = "notify"
313-
break
315+
# Detect structured follow-up from last assistant message
316+
self._pending_followup = self._detect_followup(messages)
314317

315318
self._last_intent = intent
316319
target = self.INTENT_TO_EXECUTOR[intent]
317320
logger.info("[Triage] '%s' → %s", user_text[:60], intent)
318321
await ctx.send_message(list(messages), target_id=target) # type: ignore[arg-type]
319322

323+
def _detect_followup(self, messages: list[Message]) -> str | None:
324+
"""Detect what the last assistant message offered as a follow-up action."""
325+
for m in reversed(messages):
326+
if m.role == "assistant" and m.text:
327+
lower = m.text.lower()
328+
# Check form follow-up first (more specific)
329+
if any(kw in lower for kw in self._FOLLOWUP_FORM_KW):
330+
return "form"
331+
if any(kw in lower for kw in self._FOLLOWUP_NOTIFY_KW):
332+
return "notify"
333+
break
334+
return None
335+
320336
def _classify(self, text: str) -> str:
321337
lower = text.lower().strip()
322338

0 commit comments

Comments
 (0)