"""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}) # ── RSS Feeds ───────────────────────────────────────────────────────────────── @briefing_bp.route("/feeds", methods=["GET"]) @_REQUIRE async def list_feeds(): 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(): 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/", 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(): 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(): 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 {} except Exception: config = {} locations = config.get("locations", {}) refreshed = [] 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"], ) refreshed.append(key) except Exception: logger.warning("Failed to refresh weather for %s", key, exc_info=True) return jsonify({"refreshed": refreshed}) # ── 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//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/", 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 """ 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//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//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