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.
This commit is contained in:
Joseph Doherty
2026-04-27 14:39:13 -04:00
parent f775eb7e92
commit 6d57fe88b4
+57 -3
View File
@@ -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,