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

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 "

View File

@@ -37,7 +37,7 @@ from routes.chat_helpers import (
clean_thinking_for_save,
_enforce_chat_privileges,
)
from src.action_intents import message_needs_tools as _message_needs_tools
from src.action_intents import classify_tool_intent as _classify_tool_intent
logger = logging.getLogger(__name__)
@@ -229,6 +229,26 @@ def _recover_empty_session_model(sess, session_id: str, owner: str | None = None
db.close()
def _set_user_time_from_request(request: Request) -> None:
"""Copy browser timezone headers into the per-request context.
This is intentionally ephemeral: it is used only while building prompts
and running tools for this request. It is not persisted or logged.
"""
try:
tz_offset = request.headers.get("x-tz-offset")
tz_name = request.headers.get("x-tz-name")
from src.user_time import clear_user_time_context, set_user_tz_name, set_user_tz_offset
clear_user_time_context()
if tz_offset is not None:
set_user_tz_offset(tz_offset)
if tz_name:
set_user_tz_name(tz_name)
except Exception:
pass
def setup_chat_routes(
session_manager,
chat_handler,
@@ -247,6 +267,8 @@ def setup_chat_routes(
# ------------------------------------------------------------------ #
@router.post("/api/chat", response_model=Dict[str, str])
async def chat_endpoint(request: Request, chat_request: ChatRequest) -> Dict[str, str]:
_set_user_time_from_request(request)
message = chat_request.message
session = chat_request.session
att_ids = chat_request.attachments or []
@@ -355,16 +377,7 @@ def setup_chat_routes(
except Exception as e:
raise HTTPException(400, f"Request parsing error: {e}")
# Stash the user's UTC offset (in minutes east of UTC) from the
# frontend so tools like manage_notes interpret natural-language
# times in the USER's tz, not the server's. See calendar_routes.
try:
_tz_hdr = request.headers.get("x-tz-offset")
if _tz_hdr is not None:
from routes.calendar_routes import set_user_tz_offset
set_user_tz_offset(_tz_hdr)
except Exception:
pass
_set_user_time_from_request(request)
form_data = await request.form()
message = form_data.get("message")
@@ -393,10 +406,15 @@ def setup_chat_routes(
# its way through a plain chat request (and fail, especially with the
# shell disabled).
auto_escalated = False
if chat_mode == "chat" and isinstance(message, str) and _message_needs_tools(message):
_tool_intent = _classify_tool_intent(message) if isinstance(message, str) else None
if chat_mode == "chat" and _tool_intent and _tool_intent.needs_tools:
chat_mode = "agent"
auto_escalated = True
logger.info("chat→agent auto-escalation: message matched tool-intent pattern")
logger.info(
"chat→agent auto-escalation: category=%s reason=%s",
_tool_intent.category,
_tool_intent.reason,
)
active_doc_id = form_data.get("active_doc_id", "").strip()
logger.info(f"[doc-inject] chat_mode={chat_mode}, active_doc_id={active_doc_id!r}")