Fix calendar routing and user-local time context (#408)

* fix(chat): add user-local time context

* fix(chat): route calendar follow-up phrasing

* refactor(chat): log tool intent routing reasons

* test(chat): align user time prompt shim

---------

Co-authored-by: Alex Kenley <Alex.Kenley@threatvectorsecurity.com>
This commit is contained in:
Alexander Kenley
2026-06-04 22:20:04 +10:00
committed by GitHub
parent f59edee611
commit 7b45a94b6d
12 changed files with 463 additions and 106 deletions
+35 -27
View File
@@ -161,26 +161,18 @@ def _ensure_default_calendar(db, owner: str = None) -> CalendarCal:
return cal
# Per-request user UTC offset (in minutes east of UTC). chat_routes sets this
# from the `X-Tz-Offset` header so naive natural-language times the LLM
# emits ("today at 9pm") are parsed in the USER's timezone, not the server's
# clock. None = unknown, fall back to legacy server-local behavior.
from contextvars import ContextVar
_USER_TZ_OFFSET_MIN: ContextVar = ContextVar("user_tz_offset_min", default=None)
def set_user_tz_offset(offset_min):
"""Set the current user's UTC offset for this async context."""
try:
v = int(offset_min)
except (TypeError, ValueError):
return
_USER_TZ_OFFSET_MIN.set(v)
def get_user_tz_offset():
"""Read the current user's UTC offset (minutes east of UTC), or None."""
return _USER_TZ_OFFSET_MIN.get()
# Per-request user time context. chat_routes sets this from browser timezone
# headers so natural-language times the LLM emits ("today at 9pm") are parsed
# in the user's timezone, not the server's clock. None = unknown, fall back to
# legacy server-local behavior.
from src.user_time import (
get_user_tz_name,
get_user_tz_offset,
now_user_local,
set_user_tz_name,
set_user_tz_offset,
user_timezone,
)
def parse_due_for_user(s: str) -> str:
@@ -199,6 +191,7 @@ def parse_due_for_user(s: str) -> str:
"""
from datetime import timezone as _tz, timedelta as _td
offset = get_user_tz_offset()
tz_name = get_user_tz_name()
s = (s or "").strip()
if not s:
return s
@@ -212,11 +205,11 @@ def parse_due_for_user(s: str) -> str:
except ValueError:
parsed = None
if offset is None:
if offset is None and not tz_name:
# No user tz known — preserve legacy behavior (naive server-local).
return _parse_dt(s).isoformat()
user_tz = _tz(_td(minutes=offset))
user_tz = user_timezone()
# Naive ISO → tag with user tz.
if parsed is not None and parsed.tzinfo is None:
@@ -224,7 +217,7 @@ def parse_due_for_user(s: str) -> str:
# Natural language — evaluate against user's "now".
server_now_utc = datetime.now(_tz.utc)
user_now = server_now_utc.astimezone(user_tz)
user_now = now_user_local(server_now_utc)
# Patch datetime.now() inside _parse_dt by leveraging the user's clock:
# we re-implement the small natural-language phrases here against user_now
# so the result is naturally in the user's tz.
@@ -232,6 +225,7 @@ def parse_due_for_user(s: str) -> str:
lower = s.lower().strip()
def _parse_time(t):
t = _re.sub(r'\b([ap])\s*\.?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE)
m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE)
if not m: return None
h = int(m.group(1)); mn = int(m.group(2) or 0); ampm = (m.group(3) or "").lower()
@@ -341,6 +335,7 @@ def _parse_dt(s: str) -> datetime:
def _parse_time(t: str):
"""Return (hour, minute) from '1pm', '1:30 PM', '13:00', etc., or None."""
t = _re.sub(r'\b([ap])\s*\.?\s*m\.?\b', r'\1m', t.strip(), flags=_re.IGNORECASE)
m = _re.match(r'^\s*(\d{1,2})(?::(\d{2}))?\s*(am|pm)?\s*$', t, _re.IGNORECASE)
if not m:
return None
@@ -1210,7 +1205,20 @@ def setup_calendar_routes() -> APIRouter:
text = (body.get("text") or "").strip()
if not text:
raise HTTPException(400, "text is required")
from src.user_time import (
clear_user_time_context,
current_datetime_prompt,
now_user_local,
set_user_tz_name,
set_user_tz_offset,
)
clear_user_time_context()
tz_hint = (body.get("tz") or "").strip()
if body.get("tz_offset") is not None:
set_user_tz_offset(body.get("tz_offset"))
if tz_hint:
set_user_tz_name(tz_hint)
url, model, headers = resolve_endpoint("utility")
if not url:
@@ -1218,15 +1226,15 @@ def setup_calendar_routes() -> APIRouter:
if not url or not model:
return {"ok": False, "error": "No LLM endpoint configured"}
now = datetime.now()
now = now_user_local()
now_iso = now.strftime("%Y-%m-%dT%H:%M:%S")
# The model gets only the schema it needs to fill out; we re-validate
# everything client-side too.
system_prompt = (
"You are a calendar event parser. Read the user's one-line "
current_datetime_prompt()
+ "You are a calendar event parser. Read the user's one-line "
"description and emit STRICT JSON describing the event. "
f"Today is {now.strftime('%A, %Y-%m-%d')} ({now_iso}). "
+ (f"User timezone: {tz_hint}. " if tz_hint else "")
f"The current user-local timestamp is {now_iso}. "
+ "Resolve relative dates (\"tomorrow\", \"friday\", \"next monday\", "
"\"in 30 minutes\") against today. Default duration is 60 minutes "
"when no end time is given. If the text mentions a date with no "