mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-06-30 00:22:10 -04:00
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:
+35
-27
@@ -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 "
|
||||
|
||||
Reference in New Issue
Block a user