From 6d57fe88b479f3d4708f1636c4e0ddf4427676b3 Mon Sep 17 00:00:00 2001 From: Joseph Doherty Date: Mon, 27 Apr 2026 14:39:13 -0400 Subject: [PATCH] fix: kickoff form accepts loose datetime formats from the classifier The kickoff classifier emits initial_time_iso as a free-form string (KickoffParse declares it str, no schema constraint), and Cydonia in practice produces things like 'Sun 2024-05-12 07:00:00' or 'Tuesday, May 14, 2024 7:00 AM'. The POST handler used datetime.fromisoformat which only accepts strict ISO, so the confirm-form submit 400'd. Adds _coerce_iso_time() that tries fromisoformat first, then a sequence of common classifier-emitted formats via strptime, returning a canonical 'YYYY-MM-DDTHH:MM:SS+00:00' string. Naive datetimes assumed UTC. The POST handler now stores the canonical form (so downstream code sees consistent ISO regardless of what the classifier emitted), and only 400s when nothing parses. --- chat/web/kickoff.py | 60 ++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 57 insertions(+), 3 deletions(-) diff --git a/chat/web/kickoff.py b/chat/web/kickoff.py index 9e85944..4359bb6 100644 --- a/chat/web/kickoff.py +++ b/chat/web/kickoff.py @@ -73,6 +73,58 @@ def get_llm_client(request: Request) -> LLMClient: ) +def _coerce_iso_time(value: str) -> str: + """Permissive parser that returns a canonical ISO 8601 datetime. + + The kickoff classifier (chat/services/kickoff.py) returns + ``initial_time_iso`` as a free-form string; in practice it emits + things like ``"Sun 2024-05-12 07:00:00"``, + ``"Tuesday, May 14, 2024 7:00 AM"``, or proper ISO. The strict + ``datetime.fromisoformat`` would 400 on those, so this helper + tries a sequence of common classifier-emitted formats and + returns a canonical ``YYYY-MM-DDTHH:MM:SS+00:00`` form. + + Raises ``ValueError`` when nothing parses, so the caller can 400 + cleanly. + """ + from datetime import datetime, timezone + + s = (value or "").strip() + if not s: + return s + # Strict ISO first (covers "2026-04-26T20:00:00+00:00" and friends). + try: + dt = datetime.fromisoformat(s) + except ValueError: + dt = None + if dt is None: + # Common classifier-emitted formats, in rough frequency order. + formats = [ + "%a %Y-%m-%d %H:%M:%S", # Sun 2024-05-12 07:00:00 + "%A %Y-%m-%d %H:%M:%S", # Sunday 2024-05-12 07:00:00 + "%Y-%m-%d %H:%M:%S", # 2024-05-12 07:00:00 + "%Y-%m-%d %H:%M", # 2024-05-12 07:00 + "%Y-%m-%d", # 2024-05-12 (date only) + "%a %b %d %Y %H:%M:%S", # Sun May 12 2024 07:00:00 + "%A, %B %d, %Y %I:%M %p", # Tuesday, May 14, 2024 7:00 AM + "%B %d, %Y %I:%M %p", # May 14, 2024 7:00 AM + "%a %b %d %H:%M:%S %Y", # Sun May 12 07:00:00 2024 (asctime-ish) + ] + for fmt in formats: + try: + dt = datetime.strptime(s, fmt) + break + except ValueError: + continue + if dt is None: + raise ValueError(f"could not parse {value!r} as a datetime") + # Naive datetimes assumed UTC (the v1 model is single-user, single + # timezone — keeping it consistent with chat_state.time defaults). + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.isoformat(timespec="seconds") + + def _parse_holding(text: str) -> list[str]: if not text or not text.strip(): return [] @@ -188,11 +240,13 @@ async def kickoff_post( if bot is None: raise HTTPException(status_code=404, detail=f"bot not found: {bot_id}") - # Loose ISO 8601 validation. ``datetime.fromisoformat`` accepts the offset - # form ``2026-04-26T20:00:00+00:00`` we use; reject anything it can't parse. + # Permissive datetime parsing — the classifier emits a variety of + # human-readable formats ("Sun 2024-05-12 07:00:00", + # "Tuesday, May 14, 2024 7:00 AM", proper ISO, etc.). We coerce + # to canonical ISO and only 400 if NOTHING parses. if initial_time_iso.strip(): try: - datetime.fromisoformat(initial_time_iso.strip()) + initial_time_iso = _coerce_iso_time(initial_time_iso) except ValueError: raise HTTPException( status_code=400,