Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions nerve/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -907,6 +907,11 @@ class NotificationsConfig:
"high": "⚠️ ",
"urgent": "🚨 ",
})
# Output language for <YYYY-MM-DD> / <dow:> placeholders rendered into
# notification text: "en" (default), "ru", "de". Placeholder *parsing*
# stays multilingual regardless, since the source a weekday was copied
# from may be in any language. Unknown values fall back to English.
date_locale: str = "en"

@classmethod
def from_dict(cls, d: dict) -> NotificationsConfig:
Expand All @@ -919,6 +924,7 @@ def from_dict(cls, d: dict) -> NotificationsConfig:
"high": "⚠️ ",
"urgent": "🚨 ",
}),
date_locale=str(d.get("date_locale", "en")),
)


Expand Down
233 changes: 233 additions & 0 deletions nerve/notifications/date_render.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,233 @@
"""Render ISO date placeholders in notification text.

Models — especially in unattended cron sessions, where nobody is reading
along — regularly miscompute weekdays for absolute dates, e.g. writing
"24 June (Tue)" when 24 June is a Wednesday. Asking the model to "be more
careful" or embedding a 14-day lookup table in the system prompt both fail:
the first is unreliable, the second doesn't scale beyond two weeks.

Instead, this module follows ``print(f"{x:.2f}")`` logic: the model
declares semantic intent ("this is an event date") via a placeholder,
and the code formats it deterministically.

Syntax accepted in any notification ``title`` or ``body``:

<YYYY-MM-DD> → "24 June (Wed)"
<YYYY-MM-DD HH:MM> → "24 June (Wed), 19:00"

If the placeholder date matches today / tomorrow / yesterday relative
to the rendering ``now``, a relative label is prepended:

<2026-06-24> (today) → "today, 24 June (Wed)"
<2026-06-25 19:00> (tomorrow) → "tomorrow, 25 June (Thu), 19:00"
<2026-06-23> (yesterday) → "yesterday, 23 June (Tue)"

The relative-label arithmetic is done in Python from a known ``now``, so
the failure mode that originally motivated this module — the model
miscounting day deltas — cannot recur here.

Weekday-name placeholder
------------------------

Source material often states a date as a bare weekday name with no calendar
date — a shipping notice that says only "arriving Tuesday". Resolving that
to an absolute date is the same day-delta arithmetic the model gets wrong,
and it has a second failure mode: rather than compute the weekday, the model
reuses whatever date it saw earlier in the thread. So the model may instead
copy the weekday name verbatim into a placeholder and let the code resolve
it:

<dow:Tuesday> → nearest upcoming Tuesday, e.g. "21 July (Tue)"

Resolution is deterministic: the nearest date whose weekday matches,
counting forward from ``now`` (today itself counts if it matches). Weekday
names in every supported locale are accepted, full or common short forms,
independent of the output locale — the source the model copied from may be
in any language. Combines with the relative label exactly like an absolute
date, so a "Tuesday" that lands on tomorrow renders "tomorrow, 21 July
(Tue)". Unrecognized names are left as-is.

Malformed placeholders (impossible dates like ``<2026-02-31>``,
out-of-range hours, wrong digits) are left untouched so the model sees
its own broken output rather than a silent miscarriage.
"""

from __future__ import annotations

import re
from datetime import date, datetime, timedelta
from typing import Optional


# Output locales. Month order matters: index = month - 1. Weekday order is
# Python's: Mon=0 .. Sun=6.
#
# Only the *rendered* side is localized. Placeholder parsing below
# (``_WEEKDAY_NAMES``) stays multilingual regardless of this setting, because
# the source whose weekday the model copied may be in any language.
#
# English is the default; pick another with ``notifications.date_locale``.
_LOCALES: dict[str, dict[str, object]] = {
"en": {
"months": (
"January", "February", "March", "April", "May", "June",
"July", "August", "September", "October", "November", "December",
),
"weekdays": ("Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"),
"today": "today, ",
"tomorrow": "tomorrow, ",
"yesterday": "yesterday, ",
},
"ru": {
# Genitive: the day number governs the month name ("24 июня").
"months": (
"января", "февраля", "марта", "апреля", "мая", "июня",
"июля", "августа", "сентября", "октября", "ноября", "декабря",
),
"weekdays": ("пн", "вт", "ср", "чт", "пт", "сб", "вс"),
"today": "сегодня, ",
"tomorrow": "завтра, ",
"yesterday": "вчера, ",
},
"de": {
"months": (
"Januar", "Februar", "März", "April", "Mai", "Juni",
"Juli", "August", "September", "Oktober", "November", "Dezember",
),
"weekdays": ("Mo", "Di", "Mi", "Do", "Fr", "Sa", "So"),
"today": "heute, ",
"tomorrow": "morgen, ",
"yesterday": "gestern, ",
},
}

DEFAULT_LOCALE = "en"


def _locale(name: str | None) -> dict[str, object]:
"""Locale table for ``name``, falling back to English for anything unknown."""
return _LOCALES.get((name or DEFAULT_LOCALE).lower(), _LOCALES[DEFAULT_LOCALE])

# Weekday name → Python weekday index (Mon=0 .. Sun=6). Every locale above,
# full and common short forms, since the source the model copied from may be
# in any of them. All keys are lowercase; lookup lowercases its input.
_WEEKDAY_NAMES = {
# Monday
"monday": 0, "mon": 0,
"понедельник": 0, "пн": 0,
"montag": 0, "mo": 0,
# Tuesday
"tuesday": 1, "tue": 1, "tues": 1,
"вторник": 1, "вт": 1,
"dienstag": 1, "di": 1,
# Wednesday
"wednesday": 2, "wed": 2,
"среда": 2, "ср": 2,
"mittwoch": 2, "mi": 2,
# Thursday
"thursday": 3, "thu": 3, "thur": 3, "thurs": 3,
"четверг": 3, "чт": 3,
"donnerstag": 3, "do": 3,
# Friday
"friday": 4, "fri": 4,
"пятница": 4, "пт": 4,
"freitag": 4, "fr": 4,
# Saturday
"saturday": 5, "sat": 5,
"суббота": 5, "сб": 5,
"samstag": 5, "sonnabend": 5, "sa": 5,
# Sunday
"sunday": 6, "sun": 6,
"воскресенье": 6, "вс": 6,
"sonntag": 6, "so": 6,
}

# Strict: 4-digit year, 2-digit month, 2-digit day, optional HH:MM time.
# Wrapped in angle brackets so it never collides with markdown / HTML.
_ISO_DATE_RE = re.compile(
r"<(\d{4})-(\d{2})-(\d{2})(?:[ T](\d{2}):(\d{2}))?>"
)

# Weekday-name placeholder: <dow:Tuesday>. The name is captured loosely and
# validated against _WEEKDAY_NAMES so unrecognized text is left untouched.
_DOW_RE = re.compile(r"<dow:\s*([^\s>]+)\s*>", re.IGNORECASE)


def render_iso_dates(
text: str,
now: Optional[datetime] = None,
locale: str = DEFAULT_LOCALE,
) -> str:
"""Replace ``<YYYY-MM-DD[ HH:MM]>`` placeholders with localized strings.

Args:
text: Notification title or body. Any string is safe — non-matching
text passes through untouched.
now: Reference time for the today/tomorrow/yesterday label. Defaults
to the current local time. Tests inject a fixed value.
locale: Output language for month names, weekday abbreviations and
the relative label. Unknown values fall back to English.

Returns:
Text with every well-formed placeholder rendered. Malformed
placeholders (impossible dates, bad hours) are left as-is.
"""
if not text or "<" not in text:
return text

today = (now or datetime.now()).date()
loc = _locale(locale)
months: tuple = loc["months"] # type: ignore[assignment]
weekdays: tuple = loc["weekdays"] # type: ignore[assignment]

def _format(event: date, time_part: str = "") -> str:
"""One absolute date, e.g. "today, 21 July (Tue), 19:00"."""
absolute = (
f"{event.day} {months[event.month - 1]} ({weekdays[event.weekday()]})"
)

delta_days = (event - today).days
relative = ""
if delta_days == 0:
relative = str(loc["today"])
elif delta_days == 1:
relative = str(loc["tomorrow"])
elif delta_days == -1:
relative = str(loc["yesterday"])

return f"{relative}{absolute}{time_part}"

def _replace_iso(match: re.Match) -> str:
year_s, month_s, day_s, hour_s, minute_s = match.groups()
try:
event = date(int(year_s), int(month_s), int(day_s))
except ValueError:
# Impossible date like 31 February — leave the placeholder
# visible so the model sees its own malformed output.
return match.group(0)

time_part = ""
if hour_s is not None:
try:
h, m = int(hour_s), int(minute_s)
if not (0 <= h <= 23 and 0 <= m <= 59):
return match.group(0)
time_part = f", {h:02d}:{m:02d}"
except ValueError:
return match.group(0)

return _format(event, time_part)

def _replace_dow(match: re.Match) -> str:
target = _WEEKDAY_NAMES.get(match.group(1).strip().lower())
if target is None:
# Unrecognized weekday name — leave it visible.
return match.group(0)
# Nearest date whose weekday matches, counting forward from today
# (today itself counts if it already is that weekday).
days_ahead = (target - today.weekday()) % 7
return _format(today + timedelta(days=days_ahead))

text = _ISO_DATE_RE.sub(_replace_iso, text)
text = _DOW_RE.sub(_replace_dow, text)
return text
13 changes: 13 additions & 0 deletions nerve/notifications/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
from typing import TYPE_CHECKING, Any

from nerve.notifications import handlers as _handlers
from nerve.notifications.date_render import render_iso_dates

if TYPE_CHECKING:
from nerve.agent.engine import AgentEngine
Expand Down Expand Up @@ -229,6 +230,12 @@ async def send_notification(
stamped + counted as an override for audit.
"""
notification_id = f"notif-{uuid.uuid4().hex[:8]}"
# Render <YYYY-MM-DD[ HH:MM]> placeholders before silence matching
# so rules can target the human-readable form ("24 June") and so
# persisted/delivered text matches what the user sees.
locale = self.config.notifications.date_locale
title = render_iso_dates(title, locale=locale)
body = render_iso_dates(body, locale=locale)
match = await self._match_silence(title, body)

if match and not force:
Expand Down Expand Up @@ -296,6 +303,9 @@ async def ask_question(
the answer is injected as a user message into the originating session.
"""
notification_id = f"ask-{uuid.uuid4().hex[:8]}"
locale = self.config.notifications.date_locale
title = render_iso_dates(title, locale=locale)
body = render_iso_dates(body, locale=locale)
hours = expiry_hours or self.config.notifications.default_expiry_hours
expires_at = (
datetime.now(timezone.utc) + timedelta(hours=hours)
Expand Down Expand Up @@ -343,6 +353,9 @@ async def propose_action(
Returns ``{"notification_id": <id>, "status": "sent"}``.
"""
notification_id = f"approval-{uuid.uuid4().hex[:8]}"
locale = self.config.notifications.date_locale
title = render_iso_dates(title, locale=locale)
body = render_iso_dates(body, locale=locale)

# Resolve options. Default to the registered dispatcher's
# canonical set when none was passed. Falling back to the
Expand Down
Loading
Loading