feat(briefing): hard-cut tear-down
Backend: - Delete briefing services (pipeline, scheduler, conversations, profile, tools) - Delete routes/briefing.py + remove blueprint registration - Move _get_temp_unit into services/weather.get_temp_unit (reads top-level temp_unit setting) - Rename briefing_preferences.py → rss_filtering.py (functions are RSS-specific) - Strip briefing scheduler hooks from app.py - Strip briefing scheduler call from routes/settings.py - Update test imports (test_rss_service, test_tz_helpers) Frontend: - Delete BriefingView, BriefingSetupWizard, BriefingToolStatusRow - Strip /briefing route + nav links (AppHeader, KnowledgeView) - Strip Settings → Briefing tab + state + functions + imports - Strip briefing-intermediate handling from ChatMessage - Hide /news route + nav links (NewsView depended on briefing endpoints; orphaned in tree) - Drop unused useSettingsStore from AppHeader The Android BriefingScreen lives in a separate repo and is not touched here. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -1,92 +0,0 @@
|
||||
"""Create and manage briefing conversations."""
|
||||
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.conversation import Conversation, Message
|
||||
from fabledassistant.services.tz import user_briefing_date
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_or_create_today_conversation(user_id: int, model: str) -> Conversation:
|
||||
"""
|
||||
Return today's briefing conversation, creating it if it doesn't exist.
|
||||
"""
|
||||
# "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:
|
||||
result = await session.execute(
|
||||
select(Conversation).where(
|
||||
Conversation.user_id == user_id,
|
||||
Conversation.conversation_type == "briefing",
|
||||
Conversation.briefing_date == today,
|
||||
)
|
||||
)
|
||||
conv = result.scalars().first()
|
||||
if conv is not None:
|
||||
return conv
|
||||
|
||||
conv = Conversation(
|
||||
user_id=user_id,
|
||||
title=f"Briefing — {today.isoformat()}",
|
||||
model=model,
|
||||
conversation_type="briefing",
|
||||
briefing_date=today,
|
||||
)
|
||||
session.add(conv)
|
||||
await session.commit()
|
||||
await session.refresh(conv)
|
||||
return conv
|
||||
|
||||
|
||||
async def post_message(
|
||||
conversation_id: int,
|
||||
role: str,
|
||||
content: str,
|
||||
metadata: dict | None = None,
|
||||
tool_calls: list | None = None,
|
||||
) -> Message:
|
||||
"""Append a message to a briefing conversation.
|
||||
|
||||
``tool_calls`` is accepted on assistant-role messages so the full
|
||||
agentic briefing sequence (assistant tool-call turns and tool-role
|
||||
results) can be persisted as real conversation rows, keeping the
|
||||
receipts in context on chat follow-ups.
|
||||
"""
|
||||
async with async_session() as session:
|
||||
msg = Message(
|
||||
conversation_id=conversation_id,
|
||||
role=role,
|
||||
content=content,
|
||||
status="complete",
|
||||
msg_metadata=metadata,
|
||||
tool_calls=tool_calls,
|
||||
)
|
||||
session.add(msg)
|
||||
# Bump conversation updated_at
|
||||
conv = await session.get(Conversation, conversation_id)
|
||||
if conv:
|
||||
conv.updated_at = datetime.now(timezone.utc)
|
||||
await session.commit()
|
||||
await session.refresh(msg)
|
||||
return msg
|
||||
|
||||
|
||||
async def list_briefing_conversations(user_id: int) -> list[dict]:
|
||||
"""Return briefing conversations newest first."""
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Conversation).where(
|
||||
Conversation.user_id == user_id,
|
||||
Conversation.conversation_type == "briefing",
|
||||
).order_by(Conversation.briefing_date.desc().nullslast())
|
||||
.limit(30)
|
||||
)
|
||||
convs = list(result.scalars().all())
|
||||
return [c.to_dict() for c in convs]
|
||||
@@ -1,429 +0,0 @@
|
||||
"""
|
||||
Briefing pipeline: agentic tool-use loop + UI metadata gather.
|
||||
|
||||
Slot names: 'compilation' (4am), 'morning' (8am), 'midday' (12pm), 'afternoon' (4pm)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from fabledassistant.config import Config
|
||||
from fabledassistant.services.settings import get_setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
SLOT_NAMES = ("compilation", "morning", "midday", "afternoon")
|
||||
|
||||
|
||||
# ── External data gather ──────────────────────────────────────────────────────
|
||||
|
||||
async def _gather_external(user_id: int) -> dict:
|
||||
"""Collect RSS items (when enabled) and weather."""
|
||||
from fabledassistant.services.weather import get_cached_weather
|
||||
|
||||
rss_on = (await get_setting(user_id, "rss_enabled", "false")).lower() == "true"
|
||||
rss_items: list = []
|
||||
if rss_on:
|
||||
from fabledassistant.services.rss import get_recent_items
|
||||
try:
|
||||
rss_items = await get_recent_items(user_id, limit=20)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
weather = await get_cached_weather(user_id)
|
||||
except Exception:
|
||||
weather = []
|
||||
|
||||
return {
|
||||
"rss_items": rss_items,
|
||||
"weather": weather,
|
||||
}
|
||||
|
||||
|
||||
# ── Agentic briefing (tool-use loop) ──────────────────────────────────────────
|
||||
|
||||
_BRIEFING_AGENT_MAX_ROUNDS = 8
|
||||
_BRIEFING_AGENT_NUM_CTX = 8192
|
||||
|
||||
|
||||
def _agentic_system_prompt(
|
||||
profile_body: str,
|
||||
slot: str,
|
||||
today_iso: str,
|
||||
tz_name: str,
|
||||
day_from_iso: str,
|
||||
day_to_iso: str,
|
||||
) -> str:
|
||||
"""System prompt for the agentic briefing path.
|
||||
|
||||
Pushes the model to ground every factual claim in a tool result and
|
||||
to be honest when tools return nothing, rather than fabricating
|
||||
content to fill the narrative.
|
||||
|
||||
Pre-computes today's window in the user's local timezone so the
|
||||
model can call date-sensitive tools (list_events, list_tasks filters)
|
||||
without having to do any timezone math itself — eliminating a whole
|
||||
class of "wrong day" bugs.
|
||||
"""
|
||||
tz_block = (
|
||||
f"Today is {today_iso} ({tz_name}). "
|
||||
f"When calling list_events for today, use:\n"
|
||||
f" date_from = {day_from_iso}\n"
|
||||
f" date_to = {day_to_iso}\n"
|
||||
f"These are already the correct local-day boundaries — do not convert "
|
||||
f"them to UTC or any other timezone. For other date ranges, compute in "
|
||||
f"the same timezone.\n\n"
|
||||
)
|
||||
|
||||
if slot == "compilation":
|
||||
base = (
|
||||
"You are the user's personal assistant giving their full morning briefing. "
|
||||
"Weave real data from tool calls into a warm, natural-sounding summary.\n\n"
|
||||
"Tools to call every compilation (skip only if you already know a category is empty):\n"
|
||||
"- list_tasks — what's due today, overdue, or in progress\n"
|
||||
"- list_events — what's on the calendar today\n"
|
||||
"- get_weather — today's forecast\n"
|
||||
"- get_rss_items — recent news/blog items from the user's feeds\n"
|
||||
"- list_projects (optional) — active project context for narrative continuity\n\n"
|
||||
"Rules:\n"
|
||||
"- Call tools to see the data. Never assert facts you didn't learn from a tool.\n"
|
||||
"- If a tool returns nothing (no events today, no overdue tasks, no news items), "
|
||||
"say so honestly. Don't fabricate items to fill space.\n"
|
||||
"- For news, pick one or two items worth mentioning — surface the theme, not a laundry list.\n"
|
||||
"- Write flowing prose. No markdown, no headers, no bullet points.\n"
|
||||
"- Aim for 6 to 10 sentences. Skip topics that have nothing interesting.\n"
|
||||
"- Close on one or two concrete, actionable suggestions.\n\n"
|
||||
)
|
||||
elif slot == "weekly_review":
|
||||
base = (
|
||||
"You are the user's personal assistant delivering a weekly review. "
|
||||
"Use the tools available to see what was accomplished this week, what's still "
|
||||
"overdue, how many notes were captured, and what's coming up in the next seven days. "
|
||||
"Write a reflective recap that celebrates real progress and gently flags what's stuck.\n\n"
|
||||
"Rules:\n"
|
||||
"- Call tools to see the data. Never assert facts you didn't learn from a tool.\n"
|
||||
"- If a category is empty, say so honestly rather than inventing items.\n"
|
||||
"- Write flowing prose. No markdown, no bullet points.\n"
|
||||
"- Aim for 5 to 8 sentences. Reflective and encouraging tone.\n\n"
|
||||
)
|
||||
else: # morning, midday, afternoon check-ins
|
||||
base = (
|
||||
f"You are the user's personal assistant giving a brief {slot} check-in. "
|
||||
"Use tools to see what's changed since this morning. Focus on progress and "
|
||||
"what's still unaddressed.\n\n"
|
||||
"When checking tasks, call list_tasks at least twice:\n"
|
||||
"- once with status=\"in_progress\" to see anything already being worked on "
|
||||
"(regardless of due date — these can quietly drag past their due dates)\n"
|
||||
"- once filtered by due date for what's coming up or overdue today\n\n"
|
||||
"Rules:\n"
|
||||
"- Call tools to see current state. Never assert facts without tool results.\n"
|
||||
"- If nothing meaningful has changed, say so briefly — don't invent progress.\n"
|
||||
"- 3 to 5 sentences, natural prose, no markdown.\n\n"
|
||||
)
|
||||
|
||||
base = tz_block + base
|
||||
if profile_body:
|
||||
base += f"User profile (tone and preferences):\n{profile_body}\n"
|
||||
return base
|
||||
|
||||
|
||||
def _agentic_user_trigger(slot: str, date_str: str) -> str:
|
||||
"""Seed user-role message that kicks off the agentic run."""
|
||||
labels = {
|
||||
"compilation": "morning briefing",
|
||||
"morning": "morning check-in",
|
||||
"midday": "midday check-in",
|
||||
"afternoon": "afternoon wrap-up",
|
||||
"weekly_review": "weekly review",
|
||||
}
|
||||
label = labels.get(slot, f"{slot} briefing")
|
||||
return f"Generate my {label} for {date_str}."
|
||||
|
||||
|
||||
async def run_agentic_briefing(
|
||||
user_id: int,
|
||||
slot: str,
|
||||
model: str,
|
||||
conv_id: int | None = None,
|
||||
rss_override: list[dict] | None = None,
|
||||
) -> tuple[str, list[dict]]:
|
||||
"""
|
||||
Run the agentic briefing loop for a user and slot.
|
||||
|
||||
Uses the chat pipeline's tool-use loop with a curated read-only tool
|
||||
subset and a slot-specific system prompt. Every fact the model states
|
||||
is either derived from a tool result visible in the returned message
|
||||
list or it's the model hallucinating — so follow-up chat in the same
|
||||
conversation can hold the model to what the tool results actually show.
|
||||
|
||||
Returns ``(final_prose, message_list)`` where ``message_list`` is the
|
||||
full sequence including system, user trigger, tool calls, and tool
|
||||
results. Callers are expected to persist those intermediate turns
|
||||
alongside the final prose so the receipts remain in conversation
|
||||
history on follow-up.
|
||||
|
||||
If the loop fails or the model returns empty prose, returns
|
||||
``("", [])`` and the caller should fall back to the legacy path.
|
||||
"""
|
||||
from fabledassistant.services.llm import stream_chat_with_tools, ChatChunk # noqa: F401
|
||||
from fabledassistant.services.tools import execute_tool
|
||||
from fabledassistant.services.briefing_tools import get_briefing_tools
|
||||
from fabledassistant.services.user_profile import build_profile_context
|
||||
|
||||
profile_context = await build_profile_context(user_id)
|
||||
tools = await get_briefing_tools(user_id)
|
||||
|
||||
if not tools:
|
||||
logger.warning(
|
||||
"Agentic briefing for user %d slot %s: no tools available — aborting",
|
||||
user_id, slot,
|
||||
)
|
||||
return "", []
|
||||
|
||||
# Compute today's window in the user's local timezone so the model
|
||||
# receives ready-to-use ISO 8601 boundaries and never has to do its
|
||||
# own tz math when calling date-sensitive tools like list_events.
|
||||
tz_name = await get_setting(user_id, "user_timezone") or "UTC"
|
||||
try:
|
||||
user_tz = ZoneInfo(tz_name)
|
||||
except ZoneInfoNotFoundError:
|
||||
user_tz = ZoneInfo("UTC")
|
||||
tz_name = "UTC"
|
||||
now_local = datetime.now(user_tz)
|
||||
today_iso = now_local.date().isoformat()
|
||||
day_start = datetime(now_local.year, now_local.month, now_local.day, 0, 0, 0, tzinfo=user_tz)
|
||||
day_end = datetime(now_local.year, now_local.month, now_local.day, 23, 59, 59, tzinfo=user_tz)
|
||||
day_from_iso = day_start.isoformat()
|
||||
day_to_iso = day_end.isoformat()
|
||||
|
||||
system_prompt = _agentic_system_prompt(
|
||||
profile_context, slot, today_iso, tz_name, day_from_iso, day_to_iso,
|
||||
)
|
||||
messages: list[dict] = [
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": _agentic_user_trigger(slot, today_iso)},
|
||||
]
|
||||
|
||||
final_text = ""
|
||||
for round_idx in range(_BRIEFING_AGENT_MAX_ROUNDS):
|
||||
accumulated_content = ""
|
||||
accumulated_tool_calls: list[dict] = []
|
||||
|
||||
try:
|
||||
async for chunk in stream_chat_with_tools(
|
||||
messages, model, tools=tools, think=False,
|
||||
num_ctx=_BRIEFING_AGENT_NUM_CTX,
|
||||
):
|
||||
if chunk.type == "content" and chunk.content:
|
||||
accumulated_content += chunk.content
|
||||
elif chunk.type == "tool_calls" and chunk.tool_calls:
|
||||
accumulated_tool_calls.extend(chunk.tool_calls)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Agentic briefing stream failed (user %d, slot %s, round %d)",
|
||||
user_id, slot, round_idx, exc_info=True,
|
||||
)
|
||||
return "", []
|
||||
|
||||
# Append the assistant turn (content + any tool calls) to history
|
||||
assistant_msg: dict = {"role": "assistant", "content": accumulated_content}
|
||||
if accumulated_tool_calls:
|
||||
assistant_msg["tool_calls"] = accumulated_tool_calls
|
||||
messages.append(assistant_msg)
|
||||
|
||||
# No tool calls → the model is done
|
||||
if not accumulated_tool_calls:
|
||||
final_text = accumulated_content.strip()
|
||||
break
|
||||
|
||||
# Execute each tool call and append results as tool-role messages
|
||||
for tc in accumulated_tool_calls:
|
||||
fn = tc.get("function") or {}
|
||||
tool_name = fn.get("name", "")
|
||||
arguments = fn.get("arguments") or {}
|
||||
if isinstance(arguments, str):
|
||||
try:
|
||||
import json as _json
|
||||
arguments = _json.loads(arguments)
|
||||
except Exception:
|
||||
arguments = {}
|
||||
|
||||
# Default list_tasks to active statuses only so cancelled/done
|
||||
# items don't slip into briefing prose. The model can still
|
||||
# pass an explicit status filter when it wants something else.
|
||||
if tool_name == "list_tasks" and not arguments.get("status"):
|
||||
arguments["status"] = ["todo", "in_progress"]
|
||||
|
||||
try:
|
||||
if tool_name == "get_rss_items" and rss_override is not None:
|
||||
# Use topic-scored/filtered items already computed by
|
||||
# the briefing pipeline rather than the raw feed dump
|
||||
# that execute_tool would return. Keeps the model's
|
||||
# view of news aligned with the user's topic prefs
|
||||
# and the sidebar's rss_item_ids metadata.
|
||||
slim = [
|
||||
{
|
||||
"id": item.get("id"),
|
||||
"title": item.get("title", ""),
|
||||
"url": item.get("url", ""),
|
||||
"source": item.get("feed_title", ""),
|
||||
"summary": (item.get("content") or "")[:400],
|
||||
"published_at": item.get("published_at"),
|
||||
"topics": item.get("topics") or [],
|
||||
}
|
||||
for item in rss_override
|
||||
]
|
||||
result = {"success": True, "data": {"items": slim, "count": len(slim)}}
|
||||
else:
|
||||
result = await execute_tool(user_id, tool_name, arguments, conv_id=conv_id)
|
||||
except Exception as exc:
|
||||
logger.warning(
|
||||
"Tool %s failed during agentic briefing: %s", tool_name, exc,
|
||||
)
|
||||
result = {"success": False, "error": str(exc)}
|
||||
|
||||
# Serialize the result compactly for the model's context
|
||||
import json as _json
|
||||
try:
|
||||
result_str = _json.dumps(result, default=str)[:4000]
|
||||
except Exception:
|
||||
result_str = str(result)[:4000]
|
||||
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"content": result_str,
|
||||
"tool_name": tool_name,
|
||||
})
|
||||
else:
|
||||
logger.warning(
|
||||
"Agentic briefing hit max rounds (%d) for user %d slot %s — using last content",
|
||||
_BRIEFING_AGENT_MAX_ROUNDS, user_id, slot,
|
||||
)
|
||||
# Walk back to find the last assistant message with non-empty content
|
||||
for m in reversed(messages):
|
||||
if m.get("role") == "assistant" and m.get("content"):
|
||||
final_text = m["content"].strip()
|
||||
break
|
||||
|
||||
return final_text, messages
|
||||
|
||||
|
||||
# ── Main entry point ───────────────────────────────────────────────────────────
|
||||
|
||||
async def _get_temp_unit(user_id: int) -> str:
|
||||
"""Read the user's preferred temperature unit from briefing_config ('C' or 'F')."""
|
||||
import json
|
||||
raw = await get_setting(user_id, "briefing_config", "{}")
|
||||
try:
|
||||
config = json.loads(raw) if isinstance(raw, str) else (raw or {})
|
||||
unit = config.get("temp_unit", "C")
|
||||
return unit if unit in ("C", "F") else "C"
|
||||
except Exception:
|
||||
return "C"
|
||||
|
||||
|
||||
async def run_compilation(
|
||||
user_id: int,
|
||||
slot: str,
|
||||
model: str | None = None,
|
||||
) -> tuple[str, dict]:
|
||||
"""
|
||||
Run the agentic briefing loop and gather UI metadata (RSS + weather).
|
||||
|
||||
Returns ``(briefing_text, metadata)`` where metadata contains
|
||||
``rss_item_ids``, ``rss_items``, ``weather`` for frontend rendering,
|
||||
and ``agentic_messages`` (the full tool-call sequence) for the
|
||||
scheduler to persist as separate conversation rows.
|
||||
"""
|
||||
if model is None:
|
||||
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
|
||||
|
||||
from fabledassistant.services.briefing_preferences import (
|
||||
load_topic_preferences,
|
||||
load_topic_reaction_scores,
|
||||
score_and_filter_items,
|
||||
)
|
||||
from fabledassistant.services.weather import parse_weather_card_data, get_cached_weather_rows
|
||||
|
||||
include_topics, exclude_topics = await load_topic_preferences(user_id)
|
||||
topic_scores = await load_topic_reaction_scores(user_id)
|
||||
|
||||
external_data, weather_rows, temp_unit = await asyncio.gather(
|
||||
_gather_external(user_id),
|
||||
get_cached_weather_rows(user_id),
|
||||
_get_temp_unit(user_id),
|
||||
)
|
||||
|
||||
raw_rss = external_data.get("rss_items") or []
|
||||
filtered_rss = score_and_filter_items(
|
||||
raw_rss,
|
||||
include_topics=include_topics,
|
||||
exclude_topics=exclude_topics,
|
||||
topic_scores=topic_scores,
|
||||
max_items=10,
|
||||
)
|
||||
rss_item_ids = [item["id"] for item in filtered_rss if item.get("id")]
|
||||
rss_items_meta = [
|
||||
{
|
||||
"id": item["id"],
|
||||
"title": item.get("title", ""),
|
||||
"url": item.get("url", ""),
|
||||
"source": item.get("feed_title", ""),
|
||||
"snippet": (item.get("content") or "")[:300],
|
||||
"published_at": item.get("published_at"),
|
||||
}
|
||||
for item in filtered_rss
|
||||
if item.get("id")
|
||||
]
|
||||
|
||||
weather_cards = [
|
||||
card for row in weather_rows
|
||||
if (card := parse_weather_card_data(row, temp_unit)) is not None
|
||||
]
|
||||
weather_card = weather_cards[0] if weather_cards else None
|
||||
|
||||
briefing_text, agentic_messages = await run_agentic_briefing(
|
||||
user_id, slot, model, conv_id=None, rss_override=filtered_rss,
|
||||
)
|
||||
|
||||
metadata: dict = {
|
||||
"rss_item_ids": rss_item_ids,
|
||||
"rss_items": rss_items_meta,
|
||||
"weather": weather_card,
|
||||
}
|
||||
if agentic_messages:
|
||||
metadata["agentic_messages"] = agentic_messages
|
||||
|
||||
if not briefing_text:
|
||||
logger.warning("Briefing compilation produced no content for user %d slot %s", user_id, slot)
|
||||
return "", metadata
|
||||
|
||||
return briefing_text, metadata
|
||||
|
||||
|
||||
async def run_slot_injection(
|
||||
user_id: int,
|
||||
slot: str,
|
||||
model: str | None = None,
|
||||
) -> tuple[str, dict]:
|
||||
"""
|
||||
Lighter check-in update for 8am/12pm/4pm slots.
|
||||
|
||||
Runs the agentic loop with the slot-specific prompt. Returns
|
||||
``(text, metadata)`` where metadata contains ``agentic_messages``
|
||||
for the scheduler to persist.
|
||||
"""
|
||||
if model is None:
|
||||
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
|
||||
|
||||
text, agentic_messages = await run_agentic_briefing(
|
||||
user_id, slot, model, conv_id=None,
|
||||
)
|
||||
|
||||
metadata: dict = {}
|
||||
if agentic_messages:
|
||||
metadata["agentic_messages"] = agentic_messages
|
||||
return text, metadata
|
||||
@@ -1,80 +0,0 @@
|
||||
"""Briefing profile note: stores learned user preferences for the briefing assistant."""
|
||||
|
||||
import logging
|
||||
|
||||
from fabledassistant.services.notes import create_note, list_notes, update_note
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
PROFILE_TAG = "briefing-profile"
|
||||
PROFILE_TITLE = "Briefing Profile"
|
||||
|
||||
|
||||
async def _find_profile_note(user_id: int) -> dict | None:
|
||||
"""Find the user's briefing profile note by tag."""
|
||||
notes, _total = await list_notes(user_id, tags=[PROFILE_TAG], limit=1)
|
||||
if not notes:
|
||||
return None
|
||||
note = notes[0]
|
||||
return {
|
||||
"id": note.id,
|
||||
"body": note.body or "",
|
||||
"title": note.title,
|
||||
}
|
||||
|
||||
|
||||
async def get_profile_body(user_id: int) -> str:
|
||||
"""Return the body of the briefing profile note, or '' if none exists."""
|
||||
note = await _find_profile_note(user_id)
|
||||
return note["body"] if note else ""
|
||||
|
||||
|
||||
async def get_profile_note_id(user_id: int) -> int | None:
|
||||
note = await _find_profile_note(user_id)
|
||||
return note["id"] if note else None
|
||||
|
||||
|
||||
async def ensure_profile_note(user_id: int) -> int:
|
||||
"""
|
||||
Get or create the briefing profile note.
|
||||
Returns the note id.
|
||||
"""
|
||||
note = await _find_profile_note(user_id)
|
||||
if note:
|
||||
return note["id"]
|
||||
created = await create_note(
|
||||
user_id=user_id,
|
||||
title=PROFILE_TITLE,
|
||||
body=(
|
||||
"# Briefing Profile\n\n"
|
||||
"This note is maintained by the briefing assistant. "
|
||||
"It stores your preferences, patterns, and work schedule.\n\n"
|
||||
"## Work Schedule\n\n"
|
||||
"Office days: (not yet configured)\n\n"
|
||||
"## Locations\n\n"
|
||||
"(configured via Settings → Briefing)\n\n"
|
||||
"## Preferences\n\n"
|
||||
"(the assistant will add observations here over time)\n"
|
||||
),
|
||||
tags=[PROFILE_TAG],
|
||||
)
|
||||
return created.id
|
||||
|
||||
|
||||
async def append_observations(user_id: int, observations: str) -> None:
|
||||
"""
|
||||
Append the assistant's end-of-day observations to the profile note.
|
||||
Creates the note if it doesn't exist.
|
||||
"""
|
||||
if not observations.strip():
|
||||
return
|
||||
note_id = await ensure_profile_note(user_id)
|
||||
note = await _find_profile_note(user_id)
|
||||
if not note:
|
||||
return
|
||||
current_body = note.get("body", "")
|
||||
from datetime import date
|
||||
date_str = date.today().isoformat()
|
||||
new_body = current_body.rstrip() + f"\n\n## Observations — {date_str}\n\n{observations.strip()}\n"
|
||||
await update_note(user_id, note_id, body=new_body)
|
||||
logger.info("Briefing profile updated for user %d", user_id)
|
||||
@@ -1,625 +0,0 @@
|
||||
"""
|
||||
APScheduler-based briefing scheduler — per-user, timezone-aware.
|
||||
|
||||
Each enabled user gets 4 individual CronTrigger jobs keyed to their IANA
|
||||
timezone (stored in briefing_config.timezone). Changing the config via the
|
||||
settings UI calls update_user_schedule() which live-patches the scheduler
|
||||
without a restart.
|
||||
|
||||
Uses a background thread scheduler (not async) because APScheduler 3.x's
|
||||
AsyncIOScheduler has known issues with Quart/hypercorn. Jobs are async
|
||||
functions wrapped with asyncio.run_coroutine_threadsafe().
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import date, datetime, time, timedelta, timezone
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from apscheduler.schedulers.background import BackgroundScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
from sqlalchemy import select
|
||||
|
||||
from fabledassistant.models import async_session
|
||||
from fabledassistant.models.setting import Setting
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_scheduler: BackgroundScheduler | None = None
|
||||
_loop: asyncio.AbstractEventLoop | None = None
|
||||
|
||||
# Slot definitions: (name, hour, minute) — local time in the user's timezone
|
||||
SLOTS = [
|
||||
("compilation", 4, 0),
|
||||
("morning", 8, 0),
|
||||
("midday", 12, 0),
|
||||
("afternoon", 16, 0),
|
||||
]
|
||||
|
||||
# Weekly review runs Sunday at 6pm by default
|
||||
WEEKLY_REVIEW_DAY = "sun" # APScheduler day_of_week format
|
||||
WEEKLY_REVIEW_HOUR = 18
|
||||
WEEKLY_REVIEW_MINUTE = 0
|
||||
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
def _resolve_timezone(tz_str: str) -> str:
|
||||
"""Validate and return an IANA timezone string, falling back to UTC."""
|
||||
if not tz_str:
|
||||
return "UTC"
|
||||
try:
|
||||
ZoneInfo(tz_str)
|
||||
return tz_str
|
||||
except (ZoneInfoNotFoundError, KeyError):
|
||||
logger.warning("Invalid timezone %r in briefing config, falling back to UTC", tz_str)
|
||||
return "UTC"
|
||||
|
||||
|
||||
async def _get_briefing_enabled_users() -> list[tuple[int, str, dict]]:
|
||||
"""Return [(user_id, iana_timezone, config)] for all users with briefing enabled."""
|
||||
import json
|
||||
async with async_session() as session:
|
||||
result = await session.execute(
|
||||
select(Setting).where(Setting.key.in_(["briefing_config", "user_timezone"]))
|
||||
)
|
||||
rows = list(result.scalars().all())
|
||||
|
||||
by_user: dict[int, dict[str, str]] = {}
|
||||
for row in rows:
|
||||
by_user.setdefault(row.user_id, {})[row.key] = row.value or ""
|
||||
|
||||
enabled = []
|
||||
for user_id, settings in by_user.items():
|
||||
try:
|
||||
config = json.loads(settings.get("briefing_config", "{}") or "{}")
|
||||
if config.get("enabled"):
|
||||
tz_str = settings.get("user_timezone") or config.get("timezone", "UTC")
|
||||
tz = _resolve_timezone(tz_str)
|
||||
enabled.append((user_id, tz, config))
|
||||
except Exception:
|
||||
pass
|
||||
return enabled
|
||||
|
||||
|
||||
def _job_id(user_id: int, slot: str) -> str:
|
||||
return f"briefing_{slot}_user_{user_id}"
|
||||
|
||||
|
||||
def _add_user_jobs(user_id: int, tz: str, config: dict | None = None) -> None:
|
||||
"""Add (or replace) slot jobs for a user, skipping disabled slots."""
|
||||
if _scheduler is None or _loop is None:
|
||||
return
|
||||
enabled_slots = (config or {}).get("slots", {})
|
||||
for slot_name, hour, minute in SLOTS:
|
||||
jid = _job_id(user_id, slot_name)
|
||||
# compilation always runs; other slots default to True if not in config
|
||||
slot_on = enabled_slots.get(slot_name, True)
|
||||
if not slot_on:
|
||||
if _scheduler.get_job(jid):
|
||||
_scheduler.remove_job(jid)
|
||||
continue
|
||||
_scheduler.add_job(
|
||||
_run_user_slot_sync,
|
||||
CronTrigger(hour=hour, minute=minute, timezone=tz),
|
||||
args=[user_id, slot_name],
|
||||
id=jid,
|
||||
replace_existing=True,
|
||||
misfire_grace_time=3600,
|
||||
)
|
||||
# Weekly review job — runs once per week
|
||||
weekly_jid = _job_id(user_id, "weekly_review")
|
||||
weekly_on = enabled_slots.get("weekly_review", True)
|
||||
if weekly_on:
|
||||
_scheduler.add_job(
|
||||
_run_user_slot_sync,
|
||||
CronTrigger(
|
||||
day_of_week=WEEKLY_REVIEW_DAY,
|
||||
hour=WEEKLY_REVIEW_HOUR,
|
||||
minute=WEEKLY_REVIEW_MINUTE,
|
||||
timezone=tz,
|
||||
),
|
||||
args=[user_id, "weekly_review"],
|
||||
id=weekly_jid,
|
||||
replace_existing=True,
|
||||
misfire_grace_time=7200,
|
||||
)
|
||||
elif _scheduler.get_job(weekly_jid):
|
||||
_scheduler.remove_job(weekly_jid)
|
||||
|
||||
logger.info("Scheduled briefing jobs for user %d in timezone %s", user_id, tz)
|
||||
|
||||
|
||||
def _remove_user_jobs(user_id: int) -> None:
|
||||
"""Remove all slot jobs for a user."""
|
||||
if _scheduler is None:
|
||||
return
|
||||
for slot_name, _, _ in SLOTS:
|
||||
jid = _job_id(user_id, slot_name)
|
||||
if _scheduler.get_job(jid):
|
||||
_scheduler.remove_job(jid)
|
||||
weekly_jid = _job_id(user_id, "weekly_review")
|
||||
if _scheduler.get_job(weekly_jid):
|
||||
_scheduler.remove_job(weekly_jid)
|
||||
logger.info("Removed briefing jobs for user %d", user_id)
|
||||
|
||||
|
||||
# ── Public API ────────────────────────────────────────────────────────────────
|
||||
|
||||
def update_user_schedule(user_id: int, config: dict, tz_override: str | None = None) -> None:
|
||||
"""
|
||||
Called when a user saves their briefing config via the settings UI.
|
||||
Live-patches the scheduler — no restart required.
|
||||
tz_override takes priority over any timezone in config.
|
||||
"""
|
||||
if config.get("enabled"):
|
||||
tz_str = tz_override or config.get("timezone", "UTC")
|
||||
tz = _resolve_timezone(tz_str)
|
||||
_add_user_jobs(user_id, tz, config)
|
||||
else:
|
||||
_remove_user_jobs(user_id)
|
||||
|
||||
|
||||
# ── Job execution ─────────────────────────────────────────────────────────────
|
||||
|
||||
async def _auto_pause_stale_projects(user_id: int) -> list[str]:
|
||||
"""Pause active projects with no note/task activity in 14+ days. Returns paused project titles."""
|
||||
from sqlalchemy import select as _sel, func as _func
|
||||
from fabledassistant.models.project import Project
|
||||
from fabledassistant.models.note import Note
|
||||
from fabledassistant.models import async_session as _session
|
||||
|
||||
paused: list[str] = []
|
||||
threshold = datetime.now(timezone.utc) - timedelta(days=14)
|
||||
try:
|
||||
async with _session() as session:
|
||||
projects = (await session.execute(
|
||||
_sel(Project).where(Project.user_id == user_id, Project.status == "active")
|
||||
)).scalars().all()
|
||||
for p in projects:
|
||||
latest = (await session.execute(
|
||||
_sel(_func.max(Note.updated_at)).where(Note.project_id == p.id)
|
||||
)).scalar()
|
||||
if latest is None or latest < threshold:
|
||||
p.status = "paused"
|
||||
paused.append(p.title or "Untitled")
|
||||
if paused:
|
||||
await session.commit()
|
||||
logger.info("Auto-paused %d stale projects for user %d: %s", len(paused), user_id, paused)
|
||||
except Exception:
|
||||
logger.debug("Auto-pause check failed for user %d", user_id, exc_info=True)
|
||||
return paused
|
||||
|
||||
|
||||
async def _persist_agentic_messages(
|
||||
conv_id: int,
|
||||
slot: str,
|
||||
metadata: dict | None,
|
||||
) -> None:
|
||||
"""Persist the intermediate turns from an agentic briefing run.
|
||||
|
||||
``metadata["agentic_messages"]`` is the full message list the agent
|
||||
generated — system prompt, user trigger, assistant tool-call turns,
|
||||
tool-role results, and the final assistant prose.
|
||||
|
||||
To stay compatible with the existing chat loader in ``routes/chat.py``,
|
||||
tool results are folded back into the parent assistant message's
|
||||
``tool_calls[i]["result"]`` field rather than being persisted as
|
||||
separate ``role="tool"`` rows. This matches how regular chat
|
||||
persists agentic turns, so the follow-up chat endpoint can rehydrate
|
||||
the tool sequence using its existing logic.
|
||||
|
||||
Persists everything except the system prompt (implicit in the chat
|
||||
pipeline) and the final assistant prose (the caller posts that
|
||||
separately with the user-facing metadata block). The synthetic user
|
||||
trigger message is persisted so Ollama sees a user→assistant→user
|
||||
sequence rather than an orphaned assistant reply — it's tagged as
|
||||
intermediate so the UI can hide it.
|
||||
|
||||
Legacy (non-agentic) briefings have no ``agentic_messages`` and this
|
||||
function is a no-op.
|
||||
"""
|
||||
from fabledassistant.services.briefing_conversations import post_message
|
||||
|
||||
if not metadata:
|
||||
return
|
||||
agentic_messages = metadata.get("agentic_messages") or []
|
||||
if not agentic_messages:
|
||||
return
|
||||
|
||||
# Drop the system prompt (index 0) and the final assistant prose
|
||||
# (last item). The caller posts the final prose as its own message.
|
||||
middle = agentic_messages[1:-1]
|
||||
|
||||
# Walk the middle sequence, pairing each assistant tool-call turn
|
||||
# with the tool-role results that immediately follow it.
|
||||
i = 0
|
||||
n = len(middle)
|
||||
while i < n:
|
||||
m = middle[i]
|
||||
role = m.get("role", "")
|
||||
content = m.get("content", "") or ""
|
||||
tag = {"briefing_slot": slot, "briefing_intermediate": True}
|
||||
|
||||
if role == "assistant" and m.get("tool_calls"):
|
||||
# Collect subsequent tool-role results, matching them
|
||||
# positionally onto this assistant's tool_calls. Normalise
|
||||
# each entry to the flat storage format the chat loader
|
||||
# expects: {"function": <name>, "arguments": <args>,
|
||||
# "result": <result>, "status": "success"|"error"}.
|
||||
raw_tool_calls = list(m["tool_calls"])
|
||||
flat_tool_calls: list[dict] = []
|
||||
result_idx = 0
|
||||
j = i + 1
|
||||
|
||||
import json as _json
|
||||
for raw_tc in raw_tool_calls:
|
||||
fn = raw_tc.get("function") or {}
|
||||
name = fn.get("name") if isinstance(fn, dict) else str(fn)
|
||||
arguments = fn.get("arguments") if isinstance(fn, dict) else {}
|
||||
if isinstance(arguments, str):
|
||||
try:
|
||||
arguments = _json.loads(arguments)
|
||||
except Exception:
|
||||
arguments = {}
|
||||
|
||||
# Pair up with the next available tool-role message
|
||||
parsed_result: object = {}
|
||||
status = "success"
|
||||
if j < n and middle[j].get("role") == "tool":
|
||||
tool_content = middle[j].get("content", "") or ""
|
||||
try:
|
||||
parsed_result = _json.loads(tool_content)
|
||||
except Exception:
|
||||
parsed_result = tool_content
|
||||
if isinstance(parsed_result, dict) and parsed_result.get("success") is False:
|
||||
status = "error"
|
||||
j += 1
|
||||
result_idx += 1
|
||||
|
||||
flat_tool_calls.append({
|
||||
"function": name,
|
||||
"arguments": arguments,
|
||||
"result": parsed_result,
|
||||
"status": status,
|
||||
})
|
||||
|
||||
try:
|
||||
await post_message(
|
||||
conv_id, "assistant", content,
|
||||
metadata=tag,
|
||||
tool_calls=flat_tool_calls,
|
||||
)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to persist agentic assistant turn for conv %d slot %s",
|
||||
conv_id, slot, exc_info=True,
|
||||
)
|
||||
i = j # skip the tool results we just folded in
|
||||
continue
|
||||
|
||||
if role == "tool":
|
||||
# Unpaired tool result — shouldn't normally happen, but be
|
||||
# defensive and persist it as an assistant-visible note so we
|
||||
# don't lose the receipt entirely.
|
||||
i += 1
|
||||
continue
|
||||
|
||||
if role == "user":
|
||||
# Skip the synthetic user trigger ("Generate my morning briefing…").
|
||||
# Persisting it would recreate the exact "[Midday briefing update]"
|
||||
# problem PR 2 is designed to eliminate: fake user messages
|
||||
# cluttering chat history. The LLM can follow an all-assistant
|
||||
# sequence just fine since the chat endpoint injects the real
|
||||
# system prompt on follow-up.
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# assistant without tool_calls — persist as-is (rare intermediate)
|
||||
try:
|
||||
await post_message(conv_id, role, content, metadata=tag)
|
||||
except Exception:
|
||||
logger.warning(
|
||||
"Failed to persist agentic %s message for conv %d slot %s",
|
||||
role, conv_id, slot, exc_info=True,
|
||||
)
|
||||
i += 1
|
||||
|
||||
|
||||
async def _run_slot_for_user(user_id: int, slot: str) -> None:
|
||||
"""Execute one slot job for one user."""
|
||||
from fabledassistant.services.briefing_conversations import (
|
||||
get_or_create_today_conversation, post_message
|
||||
)
|
||||
from fabledassistant.services.briefing_pipeline import run_compilation, run_slot_injection
|
||||
from fabledassistant.services.settings import get_setting
|
||||
from fabledassistant.config import Config
|
||||
|
||||
# Morning slot: skip if today is not a configured work day
|
||||
if slot == "morning":
|
||||
from fabledassistant.services.user_profile import get_profile
|
||||
tz_str = await get_setting(user_id, "user_timezone") or "UTC"
|
||||
try:
|
||||
user_tz = ZoneInfo(tz_str)
|
||||
except Exception:
|
||||
user_tz = ZoneInfo("UTC")
|
||||
today_abbr = datetime.now(user_tz).strftime("%a") # 'Mon', 'Tue', …
|
||||
profile = await get_profile(user_id)
|
||||
work_days = (profile.work_schedule or {}).get("days", ["Mon", "Tue", "Wed", "Thu", "Fri"])
|
||||
if today_abbr not in work_days:
|
||||
logger.info(
|
||||
"Skipping morning slot for user %d — %s not a configured work day",
|
||||
user_id, today_abbr,
|
||||
)
|
||||
return
|
||||
|
||||
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
|
||||
|
||||
if slot == "compilation":
|
||||
# Auto-pause stale projects before compiling the briefing
|
||||
await _auto_pause_stale_projects(user_id)
|
||||
|
||||
# Refresh external data first
|
||||
try:
|
||||
import json
|
||||
config_raw = await get_setting(user_id, "briefing_config", "{}")
|
||||
config = json.loads(config_raw) if isinstance(config_raw, str) else {}
|
||||
rss_on = (await get_setting(user_id, "rss_enabled", "false")).lower() == "true"
|
||||
if rss_on:
|
||||
from fabledassistant.services.rss import refresh_all_feeds
|
||||
await refresh_all_feeds(user_id)
|
||||
from fabledassistant.services import weather as wx
|
||||
for key, loc in config.get("locations", {}).items():
|
||||
if loc.get("lat") and loc.get("lon"):
|
||||
await wx.refresh_location_cache(
|
||||
user_id=user_id,
|
||||
location_key=key,
|
||||
location_label=loc.get("label", key),
|
||||
lat=loc["lat"],
|
||||
lon=loc["lon"],
|
||||
)
|
||||
except Exception:
|
||||
logger.warning("Pre-compilation refresh failed for user %d", user_id, exc_info=True)
|
||||
|
||||
await _run_profile_closeout(user_id, model)
|
||||
conv = await get_or_create_today_conversation(user_id, model)
|
||||
text, metadata = await run_compilation(user_id, slot, model)
|
||||
if text:
|
||||
# Persist the agentic tool-call sequence as its own messages
|
||||
# so follow-up chat can see the receipts. Each intermediate
|
||||
# message is tagged with briefing_slot so the chat context
|
||||
# loader can decide whether to include them in history.
|
||||
await _persist_agentic_messages(conv.id, slot, metadata)
|
||||
final_meta = {k: v for k, v in metadata.items() if k != "agentic_messages"}
|
||||
final_meta["briefing_slot"] = slot
|
||||
await post_message(conv.id, "assistant", text, metadata=final_meta)
|
||||
|
||||
else:
|
||||
conv = await get_or_create_today_conversation(user_id, model)
|
||||
text, slot_metadata = await run_slot_injection(user_id, slot, model)
|
||||
if text:
|
||||
# No more synthetic "[Midday briefing update]" user-role
|
||||
# messages. Slot updates are plain assistant messages tagged
|
||||
# with briefing_slot so the chat endpoint can filter them
|
||||
# from the LLM's view of history on follow-ups (they remain
|
||||
# visible in the UI).
|
||||
await _persist_agentic_messages(conv.id, slot, slot_metadata)
|
||||
final_meta = {k: v for k, v in slot_metadata.items() if k != "agentic_messages"}
|
||||
final_meta["briefing_slot"] = slot
|
||||
await post_message(conv.id, "assistant", text, metadata=final_meta)
|
||||
|
||||
try:
|
||||
from fabledassistant.services.push import send_push_notification
|
||||
slot_labels = {
|
||||
"compilation": "Morning briefing ready",
|
||||
"morning": "Office briefing ready",
|
||||
"midday": "Midday check-in",
|
||||
"afternoon": "End of day wrap-up",
|
||||
}
|
||||
await send_push_notification(
|
||||
user_id=user_id,
|
||||
title="Briefing",
|
||||
body=slot_labels.get(slot, "Briefing update"),
|
||||
url="/briefing",
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Push notification failed for briefing slot %s", slot, exc_info=True)
|
||||
|
||||
logger.info("Briefing slot '%s' completed for user %d", slot, user_id)
|
||||
|
||||
|
||||
def _run_user_slot_sync(user_id: int, slot: str) -> None:
|
||||
"""Synchronous wrapper called by APScheduler's background thread."""
|
||||
if _loop is None:
|
||||
logger.error("No event loop available for briefing slot %s user %d", slot, user_id)
|
||||
return
|
||||
future = asyncio.run_coroutine_threadsafe(_run_slot_for_user(user_id, slot), _loop)
|
||||
try:
|
||||
future.result(timeout=600)
|
||||
except Exception:
|
||||
logger.exception("Briefing slot '%s' failed for user %d", slot, user_id)
|
||||
|
||||
|
||||
async def _run_profile_closeout(user_id: int, model: str) -> None:
|
||||
"""
|
||||
Read yesterday's briefing conversation, extract preference observations,
|
||||
and append them to the briefing profile note.
|
||||
"""
|
||||
from fabledassistant.services.user_profile import append_observations
|
||||
from fabledassistant.services.llm import generate_completion
|
||||
from fabledassistant.services.tz import user_today
|
||||
from fabledassistant.models.conversation import Conversation, Message
|
||||
|
||||
# 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:
|
||||
result = await session.execute(
|
||||
select(Conversation).where(
|
||||
Conversation.user_id == user_id,
|
||||
Conversation.conversation_type == "briefing",
|
||||
Conversation.briefing_date == yesterday,
|
||||
)
|
||||
)
|
||||
conv = result.scalars().first()
|
||||
if not conv:
|
||||
return
|
||||
msgs_result = await session.execute(
|
||||
select(Message).where(Message.conversation_id == conv.id).order_by(Message.created_at)
|
||||
)
|
||||
messages = list(msgs_result.scalars().all())
|
||||
|
||||
if len(messages) < 2:
|
||||
return
|
||||
|
||||
transcript = "\n".join(
|
||||
f"{m.role.upper()}: {m.content[:500]}" for m in messages[-20:]
|
||||
)
|
||||
system = (
|
||||
"You are reviewing a day's briefing conversation to extract preference observations. "
|
||||
"Identify any patterns, preferences, or schedule facts the user revealed. "
|
||||
"Write 2-5 short bullet points. Be specific and factual. "
|
||||
"Example: '- User skipped news about finance', '- Prefers weather for home location first'. "
|
||||
"If nothing notable, output only: (nothing to note)"
|
||||
)
|
||||
try:
|
||||
observations = (await generate_completion(
|
||||
[
|
||||
{"role": "system", "content": system},
|
||||
{"role": "user", "content": transcript},
|
||||
],
|
||||
model,
|
||||
)).strip()
|
||||
except Exception:
|
||||
logger.warning("Profile closeout synthesis failed for user %d", user_id, exc_info=True)
|
||||
observations = ""
|
||||
if observations and "(nothing to note)" not in observations.lower():
|
||||
await append_observations(user_id, observations)
|
||||
|
||||
|
||||
# ── Startup / catchup ─────────────────────────────────────────────────────────
|
||||
|
||||
async def _catchup_missed_slots(loop: asyncio.AbstractEventLoop) -> None:
|
||||
"""
|
||||
On startup, fire any slot that was missed in the last 24 hours
|
||||
(one catch-up per slot per user, evaluated in the user's local timezone).
|
||||
"""
|
||||
users = await _get_briefing_enabled_users()
|
||||
for user_id, tz, _config in users:
|
||||
user_tz = ZoneInfo(tz)
|
||||
now_local = datetime.now(user_tz)
|
||||
today_local = now_local.date()
|
||||
|
||||
for slot_name, hour, minute in SLOTS:
|
||||
slot_local = datetime.combine(today_local, time(hour, minute), tzinfo=user_tz)
|
||||
if slot_local > now_local:
|
||||
continue # Not yet due
|
||||
age = (now_local - slot_local).total_seconds()
|
||||
if age > 86400:
|
||||
continue # More than 24h ago — skip
|
||||
|
||||
# Check if today's conversation already has a message from after slot time
|
||||
async with async_session() as session:
|
||||
from fabledassistant.models.conversation import Conversation, Message
|
||||
result = await session.execute(
|
||||
select(Conversation).where(
|
||||
Conversation.user_id == user_id,
|
||||
Conversation.conversation_type == "briefing",
|
||||
Conversation.briefing_date == today_local,
|
||||
)
|
||||
)
|
||||
conv = result.scalars().first()
|
||||
if conv:
|
||||
# Convert slot_local to UTC for DB comparison (stored as UTC)
|
||||
slot_utc = slot_local.astimezone(ZoneInfo("UTC"))
|
||||
msgs = await session.execute(
|
||||
select(Message).where(
|
||||
Message.conversation_id == conv.id,
|
||||
Message.created_at >= slot_utc,
|
||||
).limit(1)
|
||||
)
|
||||
if msgs.scalars().first():
|
||||
continue # Already covered
|
||||
|
||||
logger.info(
|
||||
"Catching up missed briefing slot '%s' for user %d (tz: %s)",
|
||||
slot_name, user_id, tz,
|
||||
)
|
||||
try:
|
||||
await _run_slot_for_user(user_id, slot_name)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"Catch-up for slot '%s' user %d failed", slot_name, user_id
|
||||
)
|
||||
|
||||
|
||||
async def start_briefing_scheduler(loop: asyncio.AbstractEventLoop) -> None:
|
||||
"""
|
||||
Start the APScheduler background scheduler with per-user timezone-aware jobs.
|
||||
Must be awaited from the app's before_serving hook (async context).
|
||||
"""
|
||||
global _scheduler, _loop
|
||||
if _scheduler is not None:
|
||||
return
|
||||
|
||||
_loop = loop
|
||||
_scheduler = BackgroundScheduler(timezone="UTC")
|
||||
|
||||
# Await directly — we're already on the event loop, so run_coroutine_threadsafe
|
||||
# would deadlock (it blocks the calling thread, which IS the event loop thread).
|
||||
try:
|
||||
users = await _get_briefing_enabled_users()
|
||||
except Exception:
|
||||
logger.exception("Failed to load briefing users at startup")
|
||||
users = []
|
||||
|
||||
for user_id, tz, config in users:
|
||||
_add_user_jobs(user_id, tz, config)
|
||||
|
||||
from fabledassistant.services.recurrence import spawn_recurring_tasks as _spawn_recurring
|
||||
|
||||
def _run_recurrence_spawn() -> None:
|
||||
future = asyncio.run_coroutine_threadsafe(_spawn_recurring(), _loop)
|
||||
try:
|
||||
count = future.result(timeout=300)
|
||||
logger.info("Recurrence spawn: %d task(s) created", count)
|
||||
except Exception as exc:
|
||||
logger.error("Recurrence spawn failed: %s", exc)
|
||||
|
||||
_scheduler.add_job(
|
||||
_run_recurrence_spawn,
|
||||
CronTrigger(hour=0, minute=0, timezone="UTC"),
|
||||
id="recurrence_daily",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
def _run_kokoro_update_check() -> None:
|
||||
from fabledassistant.services.tts import check_for_kokoro_updates
|
||||
future = asyncio.run_coroutine_threadsafe(check_for_kokoro_updates(), _loop)
|
||||
try:
|
||||
future.result(timeout=300)
|
||||
except Exception as exc:
|
||||
logger.error("Kokoro update check failed: %s", exc)
|
||||
|
||||
_scheduler.add_job(
|
||||
_run_kokoro_update_check,
|
||||
CronTrigger(hour=3, minute=0, timezone="UTC"),
|
||||
id="kokoro_update_check_daily",
|
||||
replace_existing=True,
|
||||
)
|
||||
|
||||
_scheduler.start()
|
||||
logger.info(
|
||||
"Briefing scheduler started with %d user(s) across %d job(s)",
|
||||
len(users), len(users) * len(SLOTS),
|
||||
)
|
||||
|
||||
asyncio.create_task(_catchup_missed_slots(loop))
|
||||
|
||||
|
||||
def stop_briefing_scheduler() -> None:
|
||||
global _scheduler, _loop
|
||||
if _scheduler:
|
||||
_scheduler.shutdown(wait=False)
|
||||
_scheduler = None
|
||||
_loop = None
|
||||
@@ -1,9 +0,0 @@
|
||||
"""Briefing tool subset — delegates to the registry's ``briefing=True`` filter.
|
||||
|
||||
Tools are opted-in to briefings via ``@tool(briefing=True)`` in their
|
||||
respective module, so there is no separate allowlist to maintain here.
|
||||
"""
|
||||
|
||||
from fabledassistant.services.tools import get_briefing_tools
|
||||
|
||||
__all__ = ["get_briefing_tools"]
|
||||
@@ -26,18 +26,18 @@ logger = logging.getLogger(__name__)
|
||||
briefing=True,
|
||||
)
|
||||
async def get_weather_tool(*, user_id, arguments, **_ctx):
|
||||
from fabledassistant.services.briefing_pipeline import _get_temp_unit
|
||||
from fabledassistant.services.weather import (
|
||||
_fetch_open_meteo,
|
||||
geocode,
|
||||
get_cached_weather,
|
||||
get_temp_unit,
|
||||
parse_forecast,
|
||||
refresh_location_cache,
|
||||
)
|
||||
|
||||
location = (arguments.get("location") or "").strip()
|
||||
num_days = max(1, min(8, int(arguments.get("days") or 1)))
|
||||
temp_unit = await _get_temp_unit(user_id)
|
||||
temp_unit = await get_temp_unit(user_id)
|
||||
|
||||
def _apply_unit(days: list[dict]) -> list[dict]:
|
||||
if temp_unit != "F":
|
||||
@@ -82,7 +82,7 @@ async def get_weather_tool(*, user_id, arguments, **_ctx):
|
||||
if stale_keys:
|
||||
try:
|
||||
from fabledassistant.services.settings import get_setting as _wx_get_setting
|
||||
raw_cfg = await _wx_get_setting(user_id, "briefing_config", "{}")
|
||||
raw_cfg = await _wx_get_setting(user_id, "journal_config", "{}")
|
||||
cfg = _wx_json.loads(raw_cfg) if isinstance(raw_cfg, str) else (raw_cfg or {})
|
||||
cfg_locs = cfg.get("locations", {}) if isinstance(cfg, dict) else {}
|
||||
for key in stale_keys:
|
||||
|
||||
@@ -39,6 +39,13 @@ def describe_weathercode(code: int) -> str:
|
||||
return _WMO_CODES.get(code, f"Unknown (code {code})")
|
||||
|
||||
|
||||
async def get_temp_unit(user_id: int) -> str:
|
||||
"""Read the user's preferred temperature unit ('C' or 'F'), default 'C'."""
|
||||
from fabledassistant.services.settings import get_setting
|
||||
raw = await get_setting(user_id, "temp_unit", "C")
|
||||
return raw if raw in ("C", "F") else "C"
|
||||
|
||||
|
||||
def parse_forecast(raw: dict) -> list[dict]:
|
||||
"""Convert Open-Meteo daily response into a clean list of day dicts."""
|
||||
daily = raw.get("daily", {})
|
||||
|
||||
Reference in New Issue
Block a user