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:
+57
-3
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user