Recover the first Unix day's naive local ISO and bracketed timestamps through a bounded, round-trip-checked mktime fallback. Preserve successful timezone/DST conversions and omit unrepresentable timestamp prefixes rather than aborting gateway history replay. Add regressions for parsing, rendering, replay with injection on/off, microseconds, and unsupported values. Canonical focused validation: 56 passed; original production code restored as a negative control: 21 regression failures.
137 lines
5.3 KiB
Python
137 lines
5.3 KiB
Python
"""Helpers for rendering gateway message timestamps exactly once.
|
|
|
|
Gateway messages need timestamps in the LLM context for temporal awareness, but
|
|
persisted message content should stay clean so replay does not accumulate
|
|
``[timestamp] [timestamp] ...`` prefixes across turns.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import errno
|
|
import re
|
|
import sys
|
|
import time
|
|
from datetime import datetime, timezone
|
|
from typing import Any, Optional, Tuple
|
|
|
|
from hermes_time import safe_strftime
|
|
|
|
|
|
# Leading timestamp prefix, either the current human format
|
|
# ``[Tue 2026-04-28 13:40:53 CEST]`` or the older ISO one
|
|
# ``[2026-04-13T17:02:06+0200]`` / ``[...+02:00]`` (human tried first).
|
|
_TIMESTAMP_PREFIX_RE = re.compile(
|
|
r"^\[(?:"
|
|
r"(?P<dow>[A-Z][a-z]{2}) "
|
|
r"(?P<date>\d{4}-\d{2}-\d{2}) "
|
|
r"(?P<time>\d{2}:\d{2}:\d{2})"
|
|
r"(?: (?P<tz>[A-Za-z0-9_+\-/:]+))?"
|
|
r"|(?P<iso>\d{4}-\d{2}-\d{2}T[^\]]+)"
|
|
r")\]\s*"
|
|
)
|
|
|
|
|
|
def _localize(dt: datetime, tz) -> Optional[float]:
|
|
"""Epoch for ``dt`` in ``tz`` or the local zone; None outside platform limits."""
|
|
naive_local = dt.tzinfo is None and tz is None
|
|
try:
|
|
if dt.tzinfo is None:
|
|
dt = dt.replace(tzinfo=tz) if tz is not None else dt.astimezone()
|
|
return float(dt.timestamp())
|
|
except (OverflowError, ValueError):
|
|
return None
|
|
except OSError as exc:
|
|
if not (sys.platform == "win32" and naive_local and exc.errno == errno.EINVAL):
|
|
return None
|
|
# Windows' fold probe can reject the first Unix day. mktime can
|
|
# resolve those local wall times without changing their meaning.
|
|
# Limit recovery to that fold-free interval and reject normalization.
|
|
wall_time = dt.timetuple()
|
|
try:
|
|
epoch = time.mktime(wall_time)
|
|
if 0 <= epoch < 86_400 and time.localtime(epoch)[:6] == wall_time[:6]:
|
|
return float(epoch + dt.microsecond / 1_000_000)
|
|
except (OSError, OverflowError, ValueError):
|
|
pass
|
|
return None
|
|
|
|
|
|
def _parse_iso(text: str, tz=None) -> Optional[float]:
|
|
"""Parse an ISO-8601 string (incl. ``+0200`` offsets fromisoformat rejects)."""
|
|
for parse in (datetime.fromisoformat, lambda t: datetime.strptime(t, "%Y-%m-%dT%H:%M:%S%z")):
|
|
try:
|
|
return _localize(parse(text), tz)
|
|
except (TypeError, ValueError):
|
|
continue
|
|
return None
|
|
|
|
|
|
def _parse_timestamp_match(match: re.Match, tz=None) -> Optional[float]:
|
|
if match.group("iso"):
|
|
return _parse_iso(match.group("iso"), tz)
|
|
try:
|
|
dt = datetime.strptime(f"{match.group('date')} {match.group('time')}", "%Y-%m-%d %H:%M:%S")
|
|
except ValueError:
|
|
return None
|
|
return _localize(dt, tz)
|
|
|
|
|
|
def coerce_message_timestamp(ts_value: Any, tz=None) -> Optional[float]:
|
|
"""Epoch seconds from a number, datetime, ISO string, or the gateway's bracketed
|
|
format; ``None`` when uninterpretable."""
|
|
if isinstance(ts_value, (int, float)):
|
|
return float(ts_value)
|
|
if hasattr(ts_value, "timestamp"):
|
|
try:
|
|
return float(ts_value.timestamp())
|
|
except Exception:
|
|
return None
|
|
text = ts_value.strip() if isinstance(ts_value, str) else ""
|
|
if not text:
|
|
return None
|
|
match = _TIMESTAMP_PREFIX_RE.match(text)
|
|
parsed = _parse_timestamp_match(match, tz=tz) if match is not None else None
|
|
if parsed is not None:
|
|
return parsed
|
|
try:
|
|
return float(text)
|
|
except (TypeError, ValueError):
|
|
return _parse_iso(text, tz)
|
|
|
|
|
|
def format_message_timestamp(ts_value: Any, tz=None) -> str:
|
|
"""Format a timestamp value as ``[Tue 2026-04-28 13:40:53 CEST]``."""
|
|
epoch = coerce_message_timestamp(ts_value, tz=tz)
|
|
if epoch is None:
|
|
return ""
|
|
try:
|
|
# An early positive epoch can fall in 1969 locally. Starting from aware
|
|
# UTC avoids Windows' negative-time fold probe in naive astimezone().
|
|
dt = datetime.fromtimestamp(epoch, tz=tz) if tz is not None else datetime.fromtimestamp(epoch, tz=timezone.utc).astimezone()
|
|
except (OSError, OverflowError, ValueError):
|
|
return ""
|
|
return f"[{safe_strftime(dt, '%a %Y-%m-%d %H:%M:%S %Z')}]"
|
|
|
|
|
|
def strip_leading_message_timestamps(content: str, tz=None) -> Tuple[str, Optional[float]]:
|
|
"""Strip leading gateway timestamp prefixes → ``(clean_content, embedded_epoch)``.
|
|
With several prefixes the one closest to the text wins, preserving the platform-send
|
|
time of legacy rows like ``[processing time] [platform time] [sender] message``."""
|
|
if not isinstance(content, str) or not content:
|
|
return content, None
|
|
text, embedded_epoch = content, None
|
|
while (match := _TIMESTAMP_PREFIX_RE.match(text)) is not None:
|
|
parsed = _parse_timestamp_match(match, tz=tz)
|
|
if parsed is not None:
|
|
embedded_epoch = parsed
|
|
text = text[match.end():]
|
|
return text, embedded_epoch
|
|
|
|
|
|
def render_user_content_with_timestamp(content: str, ts_value: Any = None, tz=None) -> str:
|
|
"""Render a user message for LLM context with exactly one timestamp prefix; an
|
|
existing prefix is stripped and its parsed time wins over ``ts_value``."""
|
|
clean_content, embedded_epoch = strip_leading_message_timestamps(content, tz=tz)
|
|
prefix = format_message_timestamp(ts_value if embedded_epoch is None else embedded_epoch, tz=tz)
|
|
return f"{prefix} {clean_content}" if prefix and clean_content else (prefix or clean_content)
|