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:
2026-04-25 22:33:37 -04:00
parent d352e9264b
commit 7602bf2293
24 changed files with 28 additions and 4026 deletions
+1 -7
View File
@@ -11,7 +11,6 @@ from fabledassistant.config import Config
from fabledassistant.routes.admin import admin_bp
from fabledassistant.routes.api import api
from fabledassistant.routes.auth import auth_bp
from fabledassistant.routes.briefing import briefing_bp
from fabledassistant.routes.chat import chat_bp
from fabledassistant.routes.export import export_bp
from fabledassistant.routes.notes import notes_bp
@@ -75,7 +74,6 @@ def create_app() -> Quart:
app.register_blueprint(admin_bp)
app.register_blueprint(api)
app.register_blueprint(auth_bp)
app.register_blueprint(briefing_bp)
app.register_blueprint(chat_bp)
app.register_blueprint(export_bp)
app.register_blueprint(images_bp)
@@ -333,9 +331,7 @@ def create_app() -> Quart:
asyncio.create_task(_delayed_backfill())
# Start briefing scheduler
from fabledassistant.services.briefing_scheduler import start_briefing_scheduler
await start_briefing_scheduler(asyncio.get_running_loop())
# Journal scheduler is wired up in Stage C (services/journal_scheduler).
# Start event scheduler (reminders + CalDAV pull sync)
from fabledassistant.services.event_scheduler import start_event_scheduler
@@ -349,8 +345,6 @@ def create_app() -> Quart:
@app.after_serving
async def shutdown():
from fabledassistant.services.briefing_scheduler import stop_briefing_scheduler
stop_briefing_scheduler()
from fabledassistant.services.event_scheduler import stop_event_scheduler
stop_event_scheduler()
-705
View File
@@ -1,705 +0,0 @@
"""Briefing API: RSS feeds, weather, and briefing configuration."""
import asyncio
import json
import logging
from quart import Blueprint, g, jsonify, request
from sqlalchemy import select
from fabledassistant.auth import login_required
from fabledassistant.models import async_session
from fabledassistant.models.conversation import Conversation, Message
from fabledassistant.models.rss_feed import RssFeed, RssItem
from fabledassistant.services import rss as rss_svc
from fabledassistant.services import weather as weather_svc
from fabledassistant.services.briefing_conversations import (
get_or_create_today_conversation,
list_briefing_conversations,
post_message,
)
from fabledassistant.services.chat import add_message, get_conversation
from fabledassistant.services.generation_buffer import create_buffer, get_buffer
from fabledassistant.services.generation_task import run_generation
from fabledassistant.services.settings import get_setting, set_settings_batch
logger = logging.getLogger(__name__)
briefing_bp = Blueprint("briefing", __name__, url_prefix="/api/briefing")
_REQUIRE = login_required
# ── Config ────────────────────────────────────────────────────────────────────
@briefing_bp.route("/config", methods=["GET"])
@_REQUIRE
async def get_config():
raw = await get_setting(g.user.id, "briefing_config", "{}")
try:
config = json.loads(raw) if isinstance(raw, str) else (raw or {})
except Exception:
config = {}
return jsonify(config)
@briefing_bp.route("/config", methods=["PUT"])
@_REQUIRE
async def put_config():
data = await request.get_json()
await set_settings_batch(g.user.id, {"briefing_config": json.dumps(data)})
# Live-patch the scheduler using the stored user_timezone.
from fabledassistant.services.briefing_scheduler import update_user_schedule
tz_override = await get_setting(g.user.id, "user_timezone") or None
update_user_schedule(g.user.id, data, tz_override=tz_override)
return jsonify({"ok": True})
async def _rss_enabled() -> bool:
return (await get_setting(g.user.id, "rss_enabled", "false")).lower() == "true"
# ── RSS Feeds ─────────────────────────────────────────────────────────────────
@briefing_bp.route("/feeds", methods=["GET"])
@_REQUIRE
async def list_feeds():
if not await _rss_enabled():
return jsonify([])
async with async_session() as session:
result = await session.execute(
select(RssFeed).where(RssFeed.user_id == g.user.id).order_by(RssFeed.id)
)
feeds = list(result.scalars().all())
return jsonify([f.to_dict() for f in feeds])
@briefing_bp.route("/feeds", methods=["POST"])
@_REQUIRE
async def add_feed():
if not await _rss_enabled():
return jsonify({"error": "RSS is disabled"}), 403
data = await request.get_json()
url = (data.get("url") or "").strip()
if not url:
return jsonify({"error": "url required"}), 400
scheme = url.split("://")[0].lower() if "://" in url else ""
if scheme not in ("http", "https"):
return jsonify({"error": "Feed URL must use http or https"}), 400
from fabledassistant.services.llm import _is_private_url
if _is_private_url(url):
return jsonify({"error": "Feed URL must not point to an internal address"}), 400
category = data.get("category") or None
async with async_session() as session:
# Prevent duplicates
existing = await session.execute(
select(RssFeed).where(RssFeed.user_id == g.user.id, RssFeed.url == url)
)
if existing.scalars().first():
return jsonify({"error": "Feed already added"}), 409
feed = RssFeed(user_id=g.user.id, url=url, title="", category=category)
session.add(feed)
await session.commit()
await session.refresh(feed)
feed_id = feed.id
# Fetch in background to auto-populate title and get initial items
asyncio.create_task(rss_svc.fetch_and_cache_feed(feed_id, url))
return jsonify({"id": feed_id, "url": url, "title": "", "category": category}), 201
@briefing_bp.route("/feeds/<int:feed_id>", methods=["DELETE"])
@_REQUIRE
async def delete_feed(feed_id: int):
async with async_session() as session:
result = await session.execute(
select(RssFeed).where(RssFeed.id == feed_id, RssFeed.user_id == g.user.id)
)
feed = result.scalars().first()
if not feed:
return jsonify({"error": "Not found"}), 404
await session.delete(feed)
await session.commit()
return jsonify({"ok": True})
@briefing_bp.route("/feeds/refresh", methods=["POST"])
@_REQUIRE
async def refresh_feeds():
if not await _rss_enabled():
return jsonify({"feeds_refreshed": 0, "new_items": 0})
results = await rss_svc.refresh_all_feeds(g.user.id)
total_new = sum(results.values())
return jsonify({"feeds_refreshed": len(results), "new_items": total_new})
@briefing_bp.route("/feeds/recent", methods=["GET"])
@_REQUIRE
async def recent_items():
if not await _rss_enabled():
return jsonify({"items": []})
limit = min(int(request.args.get("limit", 20)), 100)
items = await rss_svc.get_recent_items(g.user.id, limit=limit)
return jsonify({"items": items})
# ── Weather ───────────────────────────────────────────────────────────────────
@briefing_bp.route("/weather", methods=["GET"])
@_REQUIRE
async def get_weather():
rows = await weather_svc.get_cached_weather_rows(g.user.id)
import json as _json
raw = await get_setting(g.user.id, "briefing_config", "{}")
try:
_cfg = _json.loads(raw) if isinstance(raw, str) else (raw or {})
temp_unit = _cfg.get("temp_unit", "C")
if temp_unit not in ("C", "F"):
temp_unit = "C"
except Exception:
temp_unit = "C"
cards = [
card for row in rows
if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
]
return jsonify({"locations": cards, "temp_unit": temp_unit})
@briefing_bp.route("/weather/current", methods=["GET"])
@_REQUIRE
async def get_current_weather():
"""Return current temperature, conditions, and precipitation for the user's primary location.
Lightweight — fetches live from Open-Meteo, no caching. Intended for periodic frontend polling.
"""
raw = await get_setting(g.user.id, "briefing_config", "{}")
try:
config = json.loads(raw) if isinstance(raw, str) else (raw or {})
temp_unit = config.get("temp_unit", "C")
if temp_unit not in ("C", "F"):
temp_unit = "C"
locations = config.get("locations", {})
except Exception:
return jsonify({"error": "No briefing config"}), 404
# Use home location, fall back to work
loc = locations.get("home") or locations.get("work")
if not loc or not loc.get("lat") or not loc.get("lon"):
return jsonify({"error": "No location configured"}), 404
from fabledassistant.services.weather import fetch_current_conditions
current = await fetch_current_conditions(loc["lat"], loc["lon"])
if current is None:
return jsonify({"error": "Failed to fetch current conditions"}), 502
# Convert temperature if needed
temp = current["temperature"]
if temp is not None and temp_unit == "F":
temp = temp * 9 / 5 + 32
current["temperature"] = round(temp) if temp is not None else None
current["temp_unit"] = temp_unit
current["location"] = loc.get("label") or "Home"
return jsonify(current)
@briefing_bp.route("/weather/geocode", methods=["POST"])
@_REQUIRE
async def geocode_location():
data = await request.get_json()
query = (data.get("query") or "").strip()
if not query:
return jsonify({"error": "query required"}), 400
try:
lat, lon, label = await weather_svc.geocode(query)
return jsonify({"lat": lat, "lon": lon, "label": label})
except ValueError as e:
return jsonify({"error": str(e)}), 404
@briefing_bp.route("/weather/refresh", methods=["POST"])
@_REQUIRE
async def refresh_weather():
raw = await get_setting(g.user.id, "briefing_config", "{}")
try:
config = json.loads(raw) if isinstance(raw, str) else {}
temp_unit = config.get("temp_unit", "C")
if temp_unit not in ("C", "F"):
temp_unit = "C"
except Exception:
config = {}
temp_unit = "C"
locations = config.get("locations", {})
for key, loc in locations.items():
if not loc.get("lat") or not loc.get("lon"):
continue
try:
await weather_svc.refresh_location_cache(
user_id=g.user.id,
location_key=key,
location_label=loc.get("label", key),
lat=loc["lat"],
lon=loc["lon"],
)
except Exception:
logger.warning("Failed to refresh weather for %s", key, exc_info=True)
rows = await weather_svc.get_cached_weather_rows(g.user.id)
cards = [
card for row in rows
if (card := weather_svc.parse_weather_card_data(row, temp_unit)) is not None
]
return jsonify({"locations": cards, "temp_unit": temp_unit})
# ── Briefing Conversations ─────────────────────────────────────────────────────
@briefing_bp.route("/conversations", methods=["GET"])
@_REQUIRE
async def get_conversations():
convs = await list_briefing_conversations(g.user.id)
return jsonify({"conversations": convs})
@briefing_bp.route("/conversations/today", methods=["GET"])
@_REQUIRE
async def get_today_conversation():
model = await get_setting(g.user.id, "default_model", "")
conv = await get_or_create_today_conversation(g.user.id, model)
# Load messages
async with async_session() as session:
result = await session.execute(
select(Message)
.where(Message.conversation_id == conv.id)
.order_by(Message.created_at)
)
messages = [m.to_dict() for m in result.scalars().all()]
data = conv.to_dict()
data["messages"] = messages
return jsonify(data)
@briefing_bp.route("/conversations/<int:conv_id>/messages", methods=["GET"])
@_REQUIRE
async def get_conversation_messages(conv_id: int):
# Verify ownership
async with async_session() as session:
conv = await session.get(Conversation, conv_id)
if not conv or conv.user_id != g.user.id or conv.conversation_type != "briefing":
return jsonify({"error": "Not found"}), 404
result = await session.execute(
select(Message)
.where(Message.conversation_id == conv_id)
.order_by(Message.created_at)
)
messages = [m.to_dict() for m in result.scalars().all()]
return jsonify({"messages": messages})
@briefing_bp.route("/trigger", methods=["POST"])
@_REQUIRE
async def manual_trigger():
"""Manually trigger a briefing compilation, including a fresh data refresh."""
data = await request.get_json() or {}
slot = data.get("slot", "compilation")
if slot not in ("compilation", "morning", "midday", "afternoon"):
return jsonify({"error": "invalid slot"}), 400
# Refresh external data first (mirrors what the scheduler does)
try:
from fabledassistant.services.rss import refresh_all_feeds
config_raw = await get_setting(g.user.id, "briefing_config", "{}")
config = json.loads(config_raw) if isinstance(config_raw, str) else {}
await refresh_all_feeds(g.user.id)
for key, loc in config.get("locations", {}).items():
if loc.get("lat") and loc.get("lon"):
await weather_svc.refresh_location_cache(
user_id=g.user.id,
location_key=key,
location_label=loc.get("label", key),
lat=loc["lat"],
lon=loc["lon"],
)
except Exception:
logger.warning("Pre-trigger refresh failed for user %d", g.user.id, exc_info=True)
from fabledassistant.services.briefing_pipeline import run_compilation
from fabledassistant.services.briefing_scheduler import _persist_agentic_messages
model = await get_setting(g.user.id, "default_model", "")
conv = await get_or_create_today_conversation(g.user.id, model)
text, metadata = await run_compilation(g.user.id, slot, model)
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
msg = await post_message(conv.id, "assistant", text, metadata=final_meta)
return jsonify({"conversation_id": conv.id, "message_id": msg.id, "slot": slot})
@briefing_bp.route("/reset-today", methods=["POST"])
@_REQUIRE
async def reset_today_briefing():
"""Delete all messages in today's briefing conversation.
The conversation row itself is kept so its id stays stable for any
open UI sessions. Intended for "wipe and start fresh" workflows
driven from the MCP when iterating on prompts. Pair with
``POST /api/briefing/trigger`` to immediately regenerate.
"""
from sqlalchemy import delete as _delete
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:
result = await session.execute(
select(Conversation).where(
Conversation.user_id == g.user.id,
Conversation.conversation_type == "briefing",
Conversation.briefing_date == today,
)
)
conv = result.scalars().first()
if conv is None:
return jsonify({"deleted": 0, "conversation_id": None})
deleted_result = await session.execute(
_delete(Message).where(Message.conversation_id == conv.id)
)
await session.commit()
deleted = deleted_result.rowcount or 0
return jsonify({"deleted": deleted, "conversation_id": conv.id})
# ── RSS Reactions ──────────────────────────────────────────────────────────────
@briefing_bp.route("/rss-reactions", methods=["POST"])
@_REQUIRE
async def upsert_rss_reaction():
"""Upsert a 👍/👎 reaction on an RSS item. Same reaction toggles off; opposite flips."""
data = await request.get_json()
rss_item_id = data.get("rss_item_id")
reaction = data.get("reaction")
if not rss_item_id or reaction not in ("up", "down"):
return jsonify({"error": "rss_item_id and reaction ('up'|'down') required"}), 400
from sqlalchemy import text as _text
async with async_session() as session:
# Ownership check: verify item belongs to a feed owned by this user
result = await session.execute(
_text("""
SELECT i.id FROM rss_items i
JOIN rss_feeds f ON f.id = i.feed_id
WHERE i.id = :item_id AND f.user_id = :uid
""").bindparams(item_id=rss_item_id, uid=g.user.id)
)
if result.first() is None:
return jsonify({"error": "Not found"}), 404
# Check existing reaction
existing = await session.execute(
_text("""
SELECT id, reaction FROM rss_item_reactions
WHERE user_id = :uid AND rss_item_id = :item_id
""").bindparams(uid=g.user.id, item_id=rss_item_id)
)
row = existing.first()
if row is None:
await session.execute(
_text("""
INSERT INTO rss_item_reactions (user_id, rss_item_id, reaction)
VALUES (:uid, :item_id, :reaction)
""").bindparams(uid=g.user.id, item_id=rss_item_id, reaction=reaction)
)
action = "created"
elif row.reaction == reaction:
# Toggle off (same reaction clicked again)
await session.execute(
_text("""
DELETE FROM rss_item_reactions
WHERE user_id = :uid AND rss_item_id = :item_id
""").bindparams(uid=g.user.id, item_id=rss_item_id)
)
action = "removed"
else:
# Flip to opposite reaction
await session.execute(
_text("""
UPDATE rss_item_reactions SET reaction = :reaction
WHERE user_id = :uid AND rss_item_id = :item_id
""").bindparams(reaction=reaction, uid=g.user.id, item_id=rss_item_id)
)
action = "updated"
await session.commit()
return jsonify({"ok": True, "action": action})
@briefing_bp.route("/rss-reactions/<int:item_id>", methods=["DELETE"])
@_REQUIRE
async def delete_rss_reaction(item_id: int):
"""Explicitly remove a reaction (useful for MCP/external API callers)."""
from sqlalchemy import text as _text
async with async_session() as session:
await session.execute(
_text("""
DELETE FROM rss_item_reactions
WHERE user_id = :uid AND rss_item_id = :item_id
""").bindparams(uid=g.user.id, item_id=item_id)
)
await session.commit()
return jsonify({"ok": True})
@briefing_bp.route("/news", methods=["GET"])
@_REQUIRE
async def list_news():
"""Return recent RSS articles with optional feed filter and pagination.
Query params:
days — lookback window (default 2, max 90)
limit — items per page (default 40, max 100)
offset — pagination offset (default 0)
feed_id — optional integer filter by feed
"""
if not await _rss_enabled():
return jsonify({"items": [], "total": 0})
from sqlalchemy import text as _text
days = min(int(request.args.get("days", 2)), 90)
limit = min(int(request.args.get("limit", 40)), 100)
offset = max(int(request.args.get("offset", 0)), 0)
feed_id = request.args.get("feed_id", type=int)
async with async_session() as session:
result = await session.execute(
_text("""
SELECT
i.id, i.title, i.url, i.content, i.published_at,
i.topics, f.title AS feed_title,
r.reaction
FROM rss_items i
JOIN rss_feeds f ON f.id = i.feed_id
LEFT JOIN rss_item_reactions r
ON r.rss_item_id = i.id AND r.user_id = :uid
WHERE f.user_id = :uid
AND (CAST(:feed_id AS integer) IS NULL OR f.id = CAST(:feed_id AS integer))
AND COALESCE(i.published_at, i.fetched_at) >= NOW() - make_interval(days => :days)
ORDER BY COALESCE(i.published_at, i.fetched_at) DESC
LIMIT :limit OFFSET :offset
""").bindparams(uid=g.user.id, days=days, limit=limit,
offset=offset, feed_id=feed_id)
)
rows = result.mappings().all()
items = [
{
"id": r["id"],
"title": r["title"],
"url": r["url"],
"snippet": (r["content"] or "")[:300],
"content": r["content"] or "",
"published_at": r["published_at"].isoformat() if r["published_at"] else None,
"topics": r["topics"] or [],
"source": r["feed_title"],
"reaction": r["reaction"],
}
for r in rows
]
return jsonify({"items": items, "offset": offset, "limit": limit})
# ── Article Discuss ────────────────────────────────────────────────────────────
@briefing_bp.route("/articles/<int:item_id>/discuss", methods=["POST"])
@_REQUIRE
async def discuss_article(item_id: int):
"""Inject article content as a synthetic tool exchange and trigger generation."""
data = await request.get_json() or {}
conv_id = data.get("conv_id")
if not conv_id:
return jsonify({"error": "conv_id required"}), 400
uid = g.user.id
# Verify item belongs to user via feed ownership
async with async_session() as session:
result = await session.execute(
select(RssItem)
.join(RssFeed, RssFeed.id == RssItem.feed_id)
.where(RssItem.id == item_id, RssFeed.user_id == uid)
)
item = result.scalars().first()
if item is None:
return jsonify({"error": "Not found"}), 404
# Verify conversation belongs to user
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
# Reject if generation already running
if get_buffer(conv_id) is not None:
return jsonify({"error": "Generation already in progress"}), 409
# Shared helper handles the three-layer cache (context_prepared →
# content_full → fresh fetch), writes the synthetic read_article tool
# exchange and the conversational seed user prompt into the conversation.
# The /news from-article route calls the same helper so behavior stays
# byte-identical across entry points.
from fabledassistant.services.article_context import (
EmptyArticleError,
seed_article_discussion,
)
model = await get_setting(uid, "default_model", "") or ""
try:
discuss_prompt = await seed_article_discussion(conv_id, item, model)
except EmptyArticleError as e:
return jsonify({"error": str(e)}), 422
# Reload conversation with fresh messages to build history
conv = await get_conversation(uid, conv_id)
assert conv is not None
history = []
for msg in conv.messages:
if msg.role == "system":
continue
msg_dict = {"role": msg.role, "content": msg.content or ""}
if msg.tool_calls:
msg_dict["tool_calls"] = [
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
for tc in msg.tool_calls
]
history.append(msg_dict)
for tc in msg.tool_calls:
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
else:
history.append(msg_dict)
assistant_msg = await add_message(conv_id, "assistant", "", status="generating")
buf = create_buffer(conv_id, assistant_msg.id)
asyncio.create_task(run_generation(
buf, history, model,
uid, conv_id, conv.title or "",
discuss_prompt,
))
return jsonify({"assistant_message_id": assistant_msg.id, "status": "generating"}), 202
@briefing_bp.route("/topics/<topic>/discuss", methods=["POST"])
@_REQUIRE
async def discuss_topic(topic: str):
"""Discuss all recent articles in a topic cluster — multi-article deep analysis."""
data = await request.get_json() or {}
conv_id = data.get("conv_id")
if not conv_id:
return jsonify({"error": "conv_id required"}), 400
uid = g.user.id
# Verify conversation belongs to user
conv = await get_conversation(uid, conv_id)
if conv is None:
return jsonify({"error": "Conversation not found"}), 404
if get_buffer(conv_id) is not None:
return jsonify({"error": "Generation already in progress"}), 409
# Find recent articles with this topic (last 2 days)
async with async_session() as session:
result = await session.execute(
select(RssItem)
.join(RssFeed, RssFeed.id == RssItem.feed_id)
.where(RssFeed.user_id == uid)
.where(RssItem.topics.contains([topic]))
.order_by(RssItem.published_at.desc().nullslast())
.limit(5)
)
items = list(result.scalars().all())
if not items:
return jsonify({"error": f"No articles found for topic '{topic}'"}), 404
# Fetch full content for each article
from fabledassistant.services.rss import _fetch_full_article
synthetic_tool_calls = []
for item in items:
content = await _fetch_full_article(item.url) if item.url else None
content = content or item.content or ""
synthetic_tool_calls.append({
"function": "read_article",
"arguments": {"url": item.url or ""},
"result": {
"success": True,
"type": "article_content",
"url": item.url or "",
"title": item.title or "",
"source": "",
"content": content[:8000], # cap per article to stay within context
"truncated": len(content) > 8000,
},
})
await add_message(conv_id, "assistant", "", status="complete", tool_calls=synthetic_tool_calls)
user_prompt = (
f"I'd like to discuss the {len(items)} articles about '{topic}'. "
"Don't just summarize each one — draw connections between the sources, "
"highlight where they agree or disagree, and share your analysis of what "
"this means. Let's have a real discussion about this topic."
)
await add_message(conv_id, "user", user_prompt)
conv = await get_conversation(uid, conv_id)
assert conv is not None
history = []
for msg in conv.messages:
if msg.role == "system":
continue
msg_dict = {"role": msg.role, "content": msg.content or ""}
if msg.tool_calls:
msg_dict["tool_calls"] = [
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
for tc in msg.tool_calls
]
history.append(msg_dict)
for tc in msg.tool_calls:
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
else:
history.append(msg_dict)
model = await get_setting(uid, "default_model", "") or ""
assistant_msg = await add_message(conv_id, "assistant", "", status="generating")
buf = create_buffer(conv_id, assistant_msg.id)
asyncio.create_task(run_generation(
buf, history, model,
uid, conv_id, conv.title or "",
user_prompt,
))
return jsonify({
"assistant_message_id": assistant_msg.id,
"status": "generating",
"article_count": len(items),
}), 202
+2 -11
View File
@@ -94,17 +94,8 @@ async def update_settings_route():
if to_save:
await set_settings_batch(uid, to_save)
# When timezone changes, live-patch the briefing scheduler immediately
if "user_timezone" in to_save:
import json as _json
from fabledassistant.services.briefing_scheduler import update_user_schedule
config_raw = await get_setting(uid, "briefing_config", "{}")
try:
config = _json.loads(config_raw) if isinstance(config_raw, str) else {}
except Exception:
config = {}
if config.get("enabled"):
update_user_schedule(uid, config, tz_override=to_save["user_timezone"] or None)
# Journal scheduler live-reschedule on timezone change is wired up in
# services/journal_scheduler.update_user_schedule (Stage C).
if "default_model" in to_save and to_save["default_model"]:
asyncio.create_task(_prime_kv_cache_bg(uid, to_save["default_model"]))
@@ -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:
+7
View File
@@ -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", {})