Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 939b910372 | |||
| 70cea78c2f | |||
| 4e4dbb8783 | |||
| e4e1d1da49 |
@@ -330,10 +330,13 @@ async def reset_today_briefing():
|
|||||||
driven from the MCP when iterating on prompts. Pair with
|
driven from the MCP when iterating on prompts. Pair with
|
||||||
``POST /api/briefing/trigger`` to immediately regenerate.
|
``POST /api/briefing/trigger`` to immediately regenerate.
|
||||||
"""
|
"""
|
||||||
from datetime import date as _date
|
|
||||||
from sqlalchemy import delete as _delete
|
from sqlalchemy import delete as _delete
|
||||||
|
|
||||||
today = _date.today()
|
from fabledassistant.services.tz import user_briefing_date
|
||||||
|
|
||||||
|
# User-local briefing day (flips at 4am local), not ``date.today()`` —
|
||||||
|
# see services/tz.py for rationale.
|
||||||
|
today = await user_briefing_date(g.user.id)
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Conversation).where(
|
select(Conversation).where(
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
"""Create and manage briefing conversations."""
|
"""Create and manage briefing conversations."""
|
||||||
|
|
||||||
import logging
|
import logging
|
||||||
from datetime import date, datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
|
|
||||||
from fabledassistant.models import async_session
|
from fabledassistant.models import async_session
|
||||||
from fabledassistant.models.conversation import Conversation, Message
|
from fabledassistant.models.conversation import Conversation, Message
|
||||||
|
from fabledassistant.services.tz import user_briefing_date
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -15,7 +16,10 @@ async def get_or_create_today_conversation(user_id: int, model: str) -> Conversa
|
|||||||
"""
|
"""
|
||||||
Return today's briefing conversation, creating it if it doesn't exist.
|
Return today's briefing conversation, creating it if it doesn't exist.
|
||||||
"""
|
"""
|
||||||
today = date.today()
|
# "Today" is the user-local briefing day (flips at 4am local), not
|
||||||
|
# ``date.today()`` — in a UTC container the latter rolls over at
|
||||||
|
# 19:00 NY local and makes the in-progress briefing disappear.
|
||||||
|
today = await user_briefing_date(user_id)
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Conversation).where(
|
select(Conversation).where(
|
||||||
|
|||||||
@@ -445,9 +445,12 @@ async def _run_profile_closeout(user_id: int, model: str) -> None:
|
|||||||
"""
|
"""
|
||||||
from fabledassistant.services.user_profile import append_observations
|
from fabledassistant.services.user_profile import append_observations
|
||||||
from fabledassistant.services.llm import generate_completion
|
from fabledassistant.services.llm import generate_completion
|
||||||
|
from fabledassistant.services.tz import user_today
|
||||||
from fabledassistant.models.conversation import Conversation, Message
|
from fabledassistant.models.conversation import Conversation, Message
|
||||||
|
|
||||||
yesterday = (date.today() - timedelta(days=1))
|
# User-local "yesterday" so closeout always targets the day that just
|
||||||
|
# ended in the user's timezone, regardless of container TZ.
|
||||||
|
yesterday = (await user_today(user_id)) - timedelta(days=1)
|
||||||
async with async_session() as session:
|
async with async_session() as session:
|
||||||
result = await session.execute(
|
result = await session.execute(
|
||||||
select(Conversation).where(
|
select(Conversation).where(
|
||||||
|
|||||||
@@ -527,9 +527,22 @@ async def run_generation(
|
|||||||
should_gen_title = not conv_title or (msg_count > 0 and msg_count % 10 == 0)
|
should_gen_title = not conv_title or (msg_count > 0 and msg_count % 10 == 0)
|
||||||
|
|
||||||
if should_gen_title:
|
if should_gen_title:
|
||||||
title_messages = messages + [
|
# Feed the title model the *raw* conversation turns only — never
|
||||||
{"role": "assistant", "content": buf.content_so_far}
|
# the post-build_context ``messages`` list. ``build_context``
|
||||||
|
# prepends RAG snippets, RSS excerpts, URL content, and briefing
|
||||||
|
# article dumps INTO the user message string itself, so filtering
|
||||||
|
# by role="user" downstream still surfaces that noise as the
|
||||||
|
# "user's message". That pollution caused wildly-wrong titles
|
||||||
|
# (bug #109) — the small background model was staring at article
|
||||||
|
# excerpts instead of what the user actually typed. Pass the
|
||||||
|
# original history + the raw user_content + the assistant reply.
|
||||||
|
title_messages: list[dict] = [
|
||||||
|
{"role": m["role"], "content": m.get("content") or ""}
|
||||||
|
for m in history
|
||||||
|
if m.get("role") in ("user", "assistant")
|
||||||
]
|
]
|
||||||
|
title_messages.append({"role": "user", "content": user_content})
|
||||||
|
title_messages.append({"role": "assistant", "content": buf.content_so_far})
|
||||||
|
|
||||||
async def _bg_title() -> None:
|
async def _bg_title() -> None:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -317,9 +317,17 @@ async def generate_completion(
|
|||||||
num_ctx overrides the model's context window for this call only.
|
num_ctx overrides the model's context window for this call only.
|
||||||
"""
|
"""
|
||||||
last_exc: Exception | None = None
|
last_exc: Exception | None = None
|
||||||
options: dict = {"num_predict": max_tokens}
|
# Default num_ctx to Config.OLLAMA_NUM_CTX (matching stream_chat /
|
||||||
if num_ctx is not None:
|
# stream_chat_with_tools). Without this, Ollama silently uses the model's
|
||||||
options["num_ctx"] = num_ctx
|
# default window (~4k on qwen3) and truncates anything longer. That is
|
||||||
|
# how the research pipeline's outline step kept falling back to a single
|
||||||
|
# monolith note: its 12-source prompt is ~6k tokens and was being chopped
|
||||||
|
# before the model ever saw it. Non-streaming callers must not inherit
|
||||||
|
# that footgun — if you truly want the model default, pass num_ctx=0.
|
||||||
|
options: dict = {
|
||||||
|
"num_predict": max_tokens,
|
||||||
|
"num_ctx": num_ctx if num_ctx is not None else Config.OLLAMA_NUM_CTX,
|
||||||
|
}
|
||||||
for attempt in range(3):
|
for attempt in range(3):
|
||||||
if attempt > 0:
|
if attempt > 0:
|
||||||
delay = 3.0 * attempt
|
delay = 3.0 * attempt
|
||||||
|
|||||||
@@ -63,7 +63,17 @@ async def _generate_outline(topic: str, sources: list[dict], model: str) -> list
|
|||||||
]
|
]
|
||||||
for attempt in range(2):
|
for attempt in range(2):
|
||||||
try:
|
try:
|
||||||
raw = await generate_completion(messages, model, max_tokens=400)
|
# Pin num_ctx explicitly. The prompt carries up to 12 sources at
|
||||||
|
# 2000 chars each (~6k tokens of source material alone) plus the
|
||||||
|
# system prompt — well over Ollama's default model window on
|
||||||
|
# qwen3. Without this, Ollama silently truncates the prompt, the
|
||||||
|
# model can't see most of the sources, JSON parsing fails twice,
|
||||||
|
# and the pipeline falls back to a single monolith note
|
||||||
|
# (`research.py:251`). Do not remove even if `generate_completion`
|
||||||
|
# appears to default this — see the comment there.
|
||||||
|
raw = await generate_completion(
|
||||||
|
messages, model, max_tokens=400, num_ctx=16384
|
||||||
|
)
|
||||||
raw = raw.strip()
|
raw = raw.strip()
|
||||||
raw = re.sub(r"^```(?:json)?\s*", "", raw)
|
raw = re.sub(r"^```(?:json)?\s*", "", raw)
|
||||||
raw = re.sub(r"\s*```$", "", raw)
|
raw = re.sub(r"\s*```$", "", raw)
|
||||||
@@ -161,7 +171,13 @@ async def _generate_executive_summary(
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
try:
|
try:
|
||||||
raw = await generate_completion(messages, model, max_tokens=600)
|
# Pin num_ctx explicitly — see `_generate_outline` comment for the
|
||||||
|
# rationale. This prompt carries N sections × 1500 chars of section
|
||||||
|
# prose, which can easily exceed the default model window. Don't
|
||||||
|
# trust the `generate_completion` default to stick.
|
||||||
|
raw = await generate_completion(
|
||||||
|
messages, model, max_tokens=600, num_ctx=16384
|
||||||
|
)
|
||||||
return raw.strip()
|
return raw.strip()
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.warning("Executive summary generation failed for '%s'", topic, exc_info=True)
|
logger.warning("Executive summary generation failed for '%s'", topic, exc_info=True)
|
||||||
|
|||||||
@@ -14,15 +14,37 @@ from fabledassistant.services.events import (
|
|||||||
)
|
)
|
||||||
from fabledassistant.services.tools._helpers import resolve_project
|
from fabledassistant.services.tools._helpers import resolve_project
|
||||||
from fabledassistant.services.tools._registry import tool
|
from fabledassistant.services.tools._registry import tool
|
||||||
|
from fabledassistant.services.tz import get_user_tz
|
||||||
|
|
||||||
|
|
||||||
|
async def _parse_datetime_in_user_tz(
|
||||||
|
user_id: int, value: str
|
||||||
|
) -> tuple[datetime, bool]:
|
||||||
|
"""Parse a date/datetime string from the model into a UTC-aware datetime.
|
||||||
|
|
||||||
|
Naive inputs are interpreted in the **user's local timezone** and then
|
||||||
|
converted to UTC for storage. Never default to UTC for naive inputs —
|
||||||
|
that's how all-day events landed on the wrong day for non-UTC users.
|
||||||
|
|
||||||
|
Returns ``(utc_datetime, was_date_only)``.
|
||||||
|
"""
|
||||||
|
was_date_only = "T" not in value and " " not in value
|
||||||
|
if was_date_only:
|
||||||
|
value = f"{value}T00:00:00"
|
||||||
|
dt = datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||||
|
if dt.tzinfo is None:
|
||||||
|
user_tz = await get_user_tz(user_id)
|
||||||
|
dt = dt.replace(tzinfo=user_tz)
|
||||||
|
return dt.astimezone(timezone.utc), was_date_only
|
||||||
|
|
||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
name="create_event",
|
name="create_event",
|
||||||
description="Create a calendar event for the user. Use this when the user asks to schedule, add, or create a meeting, appointment, or event.",
|
description="Create a calendar event for the user. Use this when the user asks to schedule, add, or create a meeting, appointment, or event. Pass dates and datetimes in the user's local time — a bare date like '2026-09-30' is interpreted as that day in the user's configured timezone.",
|
||||||
parameters={
|
parameters={
|
||||||
"title": {"type": "string", "description": "A descriptive event title"},
|
"title": {"type": "string", "description": "A descriptive event title"},
|
||||||
"start": {"type": "string", "description": "Start date (YYYY-MM-DD) or datetime (ISO 8601 with timezone offset)"},
|
"start": {"type": "string", "description": "Start date (YYYY-MM-DD) or datetime (YYYY-MM-DDTHH:MM) in the user's local time. Include a timezone offset only if the user explicitly mentions one."},
|
||||||
"end": {"type": "string", "description": "Optional end date or datetime"},
|
"end": {"type": "string", "description": "Optional end date or datetime in the user's local time"},
|
||||||
"duration": {"type": "integer", "description": "Optional duration in minutes (default 60, ignored if end is set or all_day is true)"},
|
"duration": {"type": "integer", "description": "Optional duration in minutes (default 60, ignored if end is set or all_day is true)"},
|
||||||
"description": {"type": "string", "description": "Optional event description"},
|
"description": {"type": "string", "description": "Optional event description"},
|
||||||
"location": {"type": "string", "description": "Optional event location"},
|
"location": {"type": "string", "description": "Optional event location"},
|
||||||
@@ -40,23 +62,21 @@ async def create_event_tool(*, user_id, arguments, **_ctx):
|
|||||||
start_str = arguments["start"]
|
start_str = arguments["start"]
|
||||||
end_str = arguments.get("end")
|
end_str = arguments.get("end")
|
||||||
all_day = arguments.get("all_day", False)
|
all_day = arguments.get("all_day", False)
|
||||||
if "T" not in start_str and " " not in start_str:
|
|
||||||
all_day = True
|
|
||||||
start_str = f"{start_str}T00:00:00"
|
|
||||||
try:
|
try:
|
||||||
start_dt = datetime.fromisoformat(start_str)
|
# Naive dates/datetimes are interpreted in the user's local
|
||||||
if start_dt.tzinfo is None:
|
# timezone, not UTC. Storing UTC for naive inputs caused all-day
|
||||||
start_dt = start_dt.replace(tzinfo=timezone.utc)
|
# events to land on the previous day for negative-offset users.
|
||||||
|
start_dt, start_was_date_only = await _parse_datetime_in_user_tz(
|
||||||
|
user_id, start_str
|
||||||
|
)
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return {"success": False, "error": f"Invalid start datetime: {start_str!r}"}
|
return {"success": False, "error": f"Invalid start datetime: {start_str!r}"}
|
||||||
|
if start_was_date_only:
|
||||||
|
all_day = True
|
||||||
end_dt = None
|
end_dt = None
|
||||||
if end_str:
|
if end_str:
|
||||||
if "T" not in end_str and " " not in end_str:
|
|
||||||
end_str = f"{end_str}T00:00:00"
|
|
||||||
try:
|
try:
|
||||||
end_dt = datetime.fromisoformat(end_str)
|
end_dt, _ = await _parse_datetime_in_user_tz(user_id, end_str)
|
||||||
if end_dt.tzinfo is None:
|
|
||||||
end_dt = end_dt.replace(tzinfo=timezone.utc)
|
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return {"success": False, "error": f"Invalid end datetime: {end_str!r}"}
|
return {"success": False, "error": f"Invalid end datetime: {end_str!r}"}
|
||||||
project_id = None
|
project_id = None
|
||||||
@@ -86,25 +106,30 @@ async def create_event_tool(*, user_id, arguments, **_ctx):
|
|||||||
|
|
||||||
@tool(
|
@tool(
|
||||||
name="list_events",
|
name="list_events",
|
||||||
description="List calendar events in a date range. Use this when the user asks what events or meetings they have. Always use full-day UTC ranges: date_from at T00:00:00Z and date_to at T23:59:59Z for the days of interest so events stored in UTC are not missed.",
|
description="List calendar events in a date range. Use this when the user asks what events or meetings they have. Pass plain local dates (YYYY-MM-DD) — the server interprets them in the user's timezone and expands to a full local day.",
|
||||||
parameters={
|
parameters={
|
||||||
"date_from": {"type": "string", "description": "Start of range in ISO 8601 UTC format (e.g. 2025-01-15T00:00:00Z). Use T00:00:00Z for the start of the day."},
|
"date_from": {"type": "string", "description": "Start of range as a local date (YYYY-MM-DD) or local datetime. Interpreted in the user's timezone."},
|
||||||
"date_to": {"type": "string", "description": "End of range in ISO 8601 UTC format (e.g. 2025-01-22T23:59:59Z). Use T23:59:59Z for the end of the day."},
|
"date_to": {"type": "string", "description": "End of range as a local date (YYYY-MM-DD) or local datetime. A bare date is expanded to 23:59:59 local."},
|
||||||
},
|
},
|
||||||
required=["date_from", "date_to"],
|
required=["date_from", "date_to"],
|
||||||
read_only=True,
|
read_only=True,
|
||||||
briefing=True,
|
briefing=True,
|
||||||
)
|
)
|
||||||
async def list_events_tool(*, user_id, arguments, **_ctx):
|
async def list_events_tool(*, user_id, arguments, **_ctx):
|
||||||
|
# Bare local dates are expanded to a full local day in the user's TZ:
|
||||||
|
# date_from → 00:00 local, date_to → 23:59:59 local, both converted to
|
||||||
|
# UTC before the DB query. Previously the tool description told the
|
||||||
|
# model to pass UTC ranges, which missed events for non-UTC users.
|
||||||
try:
|
try:
|
||||||
date_from = datetime.fromisoformat(arguments["date_from"].replace("Z", "+00:00"))
|
date_from, _ = await _parse_datetime_in_user_tz(
|
||||||
date_to = datetime.fromisoformat(arguments["date_to"].replace("Z", "+00:00"))
|
user_id, arguments["date_from"]
|
||||||
|
)
|
||||||
|
date_to_str = arguments["date_to"]
|
||||||
|
if "T" not in date_to_str and " " not in date_to_str:
|
||||||
|
date_to_str = f"{date_to_str}T23:59:59"
|
||||||
|
date_to, _ = await _parse_datetime_in_user_tz(user_id, date_to_str)
|
||||||
except (ValueError, TypeError, KeyError) as exc:
|
except (ValueError, TypeError, KeyError) as exc:
|
||||||
return {"success": False, "error": f"Invalid date range: {exc}"}
|
return {"success": False, "error": f"Invalid date range: {exc}"}
|
||||||
if date_from.tzinfo is None:
|
|
||||||
date_from = date_from.replace(tzinfo=timezone.utc)
|
|
||||||
if date_to.tzinfo is None:
|
|
||||||
date_to = date_to.replace(tzinfo=timezone.utc)
|
|
||||||
events = await events_list_events(user_id=user_id, date_from=date_from, date_to=date_to)
|
events = await events_list_events(user_id=user_id, date_from=date_from, date_to=date_to)
|
||||||
return {
|
return {
|
||||||
"success": True,
|
"success": True,
|
||||||
@@ -177,9 +202,9 @@ async def update_event_tool(*, user_id, arguments, **_ctx):
|
|||||||
val = arguments.get(key)
|
val = arguments.get(key)
|
||||||
if val:
|
if val:
|
||||||
try:
|
try:
|
||||||
dt = datetime.fromisoformat(val.replace("Z", "+00:00"))
|
# Naive datetimes are user-local, not UTC — see
|
||||||
if dt.tzinfo is None:
|
# ``_parse_datetime_in_user_tz`` docstring.
|
||||||
dt = dt.replace(tzinfo=timezone.utc)
|
dt, _ = await _parse_datetime_in_user_tz(user_id, val)
|
||||||
fields[dt_field] = dt
|
fields[dt_field] = dt
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
return {"success": False, "error": f"Invalid datetime for {key}: {val!r}"}
|
return {"success": False, "error": f"Invalid datetime for {key}: {val!r}"}
|
||||||
|
|||||||
@@ -0,0 +1,47 @@
|
|||||||
|
"""User-timezone helpers.
|
||||||
|
|
||||||
|
All datetimes in the DB are stored as UTC. The helpers here bridge between
|
||||||
|
that UTC storage and the user's configured local timezone (IANA string in
|
||||||
|
the ``user_timezone`` setting). Use these anywhere the model or UI talks
|
||||||
|
in terms of "today", "tomorrow", or a bare calendar date — never
|
||||||
|
``date.today()`` or ``datetime.now(timezone.utc)`` inside a per-user flow.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||||
|
|
||||||
|
from fabledassistant.services.settings import get_setting
|
||||||
|
|
||||||
|
# Briefing day boundary — kept in sync with the compilation slot in
|
||||||
|
# ``briefing_scheduler.SLOTS``. The briefing day flips at this local hour
|
||||||
|
# (not midnight) so the 00:00–04:00 local window still shows yesterday's
|
||||||
|
# briefing until the 4am compilation generates the new one.
|
||||||
|
BRIEFING_DAY_START_HOUR = 4
|
||||||
|
|
||||||
|
|
||||||
|
async def get_user_tz(user_id: int) -> ZoneInfo:
|
||||||
|
"""Return the user's IANA ``ZoneInfo``, falling back to UTC."""
|
||||||
|
tz_str = await get_setting(user_id, "user_timezone") or "UTC"
|
||||||
|
try:
|
||||||
|
return ZoneInfo(tz_str)
|
||||||
|
except (ZoneInfoNotFoundError, KeyError):
|
||||||
|
return ZoneInfo("UTC")
|
||||||
|
|
||||||
|
|
||||||
|
async def user_today(user_id: int) -> date:
|
||||||
|
"""Return today's calendar date in the user's local timezone."""
|
||||||
|
tz = await get_user_tz(user_id)
|
||||||
|
return datetime.now(tz).date()
|
||||||
|
|
||||||
|
|
||||||
|
async def user_briefing_date(user_id: int) -> date:
|
||||||
|
"""Return the current "briefing day" in the user's local timezone.
|
||||||
|
|
||||||
|
The briefing day flips at ``BRIEFING_DAY_START_HOUR`` (4am local),
|
||||||
|
aligned with the compilation slot that generates the day's briefing.
|
||||||
|
Between 00:00 and 04:00 local this still returns *yesterday*, so the
|
||||||
|
UI keeps showing the in-progress briefing until the new one is built.
|
||||||
|
"""
|
||||||
|
tz = await get_user_tz(user_id)
|
||||||
|
return (datetime.now(tz) - timedelta(hours=BRIEFING_DAY_START_HOUR)).date()
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
"""Regression tests: calendar tool respects the user's configured timezone."""
|
||||||
|
|
||||||
|
from datetime import timezone
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_event_all_day_bare_date_interprets_local_midnight():
|
||||||
|
"""Bare date like '2026-09-30' for a NY user = 2026-09-30 00:00 NY,
|
||||||
|
stored as 2026-09-30 04:00 UTC — NOT 2026-09-30 00:00 UTC (which would
|
||||||
|
be 2026-09-29 19:00 NY and land on the wrong day)."""
|
||||||
|
from fabledassistant.services.tools.calendar import create_event_tool
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_create_event(**kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
event = AsyncMock()
|
||||||
|
event.to_dict.return_value = {"id": 1, **{k: v for k, v in kwargs.items() if not callable(v)}}
|
||||||
|
return event
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||||
|
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||||
|
), patch(
|
||||||
|
"fabledassistant.services.tools.calendar.events_create_event",
|
||||||
|
side_effect=fake_create_event,
|
||||||
|
):
|
||||||
|
result = await create_event_tool(
|
||||||
|
user_id=1,
|
||||||
|
arguments={"title": "Birthday", "start": "2026-09-30"},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert result["success"] is True
|
||||||
|
start_dt = captured["start_dt"]
|
||||||
|
assert start_dt.tzinfo is not None
|
||||||
|
# Converted to UTC: 00:00 NY (EDT, UTC-4) == 04:00 UTC on the same day
|
||||||
|
utc = start_dt.astimezone(timezone.utc)
|
||||||
|
assert (utc.year, utc.month, utc.day) == (2026, 9, 30)
|
||||||
|
assert utc.hour == 4 # EDT offset; would be 5 in EST
|
||||||
|
assert captured["all_day"] is True
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_event_naive_datetime_is_user_local():
|
||||||
|
"""'2026-03-15T14:30' for a NY user means 2:30pm NY, not 2:30pm UTC."""
|
||||||
|
from fabledassistant.services.tools.calendar import create_event_tool
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_create_event(**kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
event = AsyncMock()
|
||||||
|
event.to_dict.return_value = {"id": 1}
|
||||||
|
return event
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||||
|
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||||
|
), patch(
|
||||||
|
"fabledassistant.services.tools.calendar.events_create_event",
|
||||||
|
side_effect=fake_create_event,
|
||||||
|
):
|
||||||
|
await create_event_tool(
|
||||||
|
user_id=1,
|
||||||
|
arguments={"title": "Meeting", "start": "2026-03-15T14:30"},
|
||||||
|
)
|
||||||
|
|
||||||
|
utc = captured["start_dt"].astimezone(timezone.utc)
|
||||||
|
# 14:30 NY on 2026-03-15 (after DST start) = 18:30 UTC
|
||||||
|
assert (utc.year, utc.month, utc.day, utc.hour, utc.minute) == (2026, 3, 15, 18, 30)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_create_event_explicit_offset_is_respected():
|
||||||
|
"""Model-supplied timezone offsets must not be overridden by user TZ."""
|
||||||
|
from fabledassistant.services.tools.calendar import create_event_tool
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_create_event(**kwargs):
|
||||||
|
captured.update(kwargs)
|
||||||
|
event = AsyncMock()
|
||||||
|
event.to_dict.return_value = {"id": 1}
|
||||||
|
return event
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||||
|
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||||
|
), patch(
|
||||||
|
"fabledassistant.services.tools.calendar.events_create_event",
|
||||||
|
side_effect=fake_create_event,
|
||||||
|
):
|
||||||
|
await create_event_tool(
|
||||||
|
user_id=1,
|
||||||
|
arguments={"title": "Meeting", "start": "2026-03-15T14:30+00:00"},
|
||||||
|
)
|
||||||
|
|
||||||
|
utc = captured["start_dt"].astimezone(timezone.utc)
|
||||||
|
assert (utc.hour, utc.minute) == (14, 30)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_list_events_bare_date_range_covers_local_day():
|
||||||
|
"""date_from/date_to as bare dates should cover the full LOCAL day."""
|
||||||
|
from fabledassistant.services.tools.calendar import list_events_tool
|
||||||
|
|
||||||
|
captured = {}
|
||||||
|
|
||||||
|
async def fake_list_events(*, user_id, date_from, date_to):
|
||||||
|
captured["date_from"] = date_from
|
||||||
|
captured["date_to"] = date_to
|
||||||
|
return []
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"fabledassistant.services.tools.calendar.get_user_tz",
|
||||||
|
AsyncMock(return_value=__import__("zoneinfo").ZoneInfo("America/New_York")),
|
||||||
|
), patch(
|
||||||
|
"fabledassistant.services.tools.calendar.events_list_events",
|
||||||
|
side_effect=fake_list_events,
|
||||||
|
):
|
||||||
|
await list_events_tool(
|
||||||
|
user_id=1,
|
||||||
|
arguments={"date_from": "2026-09-30", "date_to": "2026-09-30"},
|
||||||
|
)
|
||||||
|
|
||||||
|
df = captured["date_from"].astimezone(timezone.utc)
|
||||||
|
dt = captured["date_to"].astimezone(timezone.utc)
|
||||||
|
# 2026-09-30 00:00 NY (EDT) = 04:00 UTC same day
|
||||||
|
assert (df.year, df.month, df.day, df.hour) == (2026, 9, 30, 4)
|
||||||
|
# 2026-09-30 23:59:59 NY (EDT) = 03:59:59 UTC next day
|
||||||
|
assert (dt.year, dt.month, dt.day, dt.hour) == (2026, 10, 1, 3)
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
"""Tests for user-timezone helpers (services/tz.py)."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
from zoneinfo import ZoneInfo
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_user_tz_returns_configured_zone():
|
||||||
|
from fabledassistant.services import tz as tz_mod
|
||||||
|
|
||||||
|
with patch.object(tz_mod, "get_setting", AsyncMock(return_value="America/New_York")):
|
||||||
|
zone = await tz_mod.get_user_tz(1)
|
||||||
|
assert zone == ZoneInfo("America/New_York")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_get_user_tz_falls_back_to_utc_on_bad_input():
|
||||||
|
from fabledassistant.services import tz as tz_mod
|
||||||
|
|
||||||
|
with patch.object(tz_mod, "get_setting", AsyncMock(return_value="Not/AZone")):
|
||||||
|
zone = await tz_mod.get_user_tz(1)
|
||||||
|
assert zone == ZoneInfo("UTC")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_user_briefing_date_before_4am_returns_yesterday():
|
||||||
|
"""00:00–03:59 local still shows yesterday's briefing day."""
|
||||||
|
from fabledassistant.services import tz as tz_mod
|
||||||
|
|
||||||
|
ny = ZoneInfo("America/New_York")
|
||||||
|
# 02:00 NY on 2026-04-13 → briefing day = 2026-04-12
|
||||||
|
fake_now = datetime(2026, 4, 13, 2, 0, tzinfo=ny)
|
||||||
|
|
||||||
|
class _FakeDatetime(datetime):
|
||||||
|
@classmethod
|
||||||
|
def now(cls, tz=None):
|
||||||
|
return fake_now
|
||||||
|
|
||||||
|
with patch.object(tz_mod, "get_user_tz", AsyncMock(return_value=ny)), \
|
||||||
|
patch.object(tz_mod, "datetime", _FakeDatetime):
|
||||||
|
day = await tz_mod.user_briefing_date(1)
|
||||||
|
assert day.isoformat() == "2026-04-12"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_user_briefing_date_after_4am_returns_today():
|
||||||
|
from fabledassistant.services import tz as tz_mod
|
||||||
|
|
||||||
|
ny = ZoneInfo("America/New_York")
|
||||||
|
fake_now = datetime(2026, 4, 13, 4, 30, tzinfo=ny)
|
||||||
|
|
||||||
|
class _FakeDatetime(datetime):
|
||||||
|
@classmethod
|
||||||
|
def now(cls, tz=None):
|
||||||
|
return fake_now
|
||||||
|
|
||||||
|
with patch.object(tz_mod, "get_user_tz", AsyncMock(return_value=ny)), \
|
||||||
|
patch.object(tz_mod, "datetime", _FakeDatetime):
|
||||||
|
day = await tz_mod.user_briefing_date(1)
|
||||||
|
assert day.isoformat() == "2026-04-13"
|
||||||
Reference in New Issue
Block a user