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:
@@ -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 "
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user