"""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 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) msg = await post_message(conv.id, "assistant", text, metadata=metadata) return jsonify({"conversation_id": conv.id, "message_id": msg.id, "slot": slot}) # ── 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 from fabledassistant.services.rss import _fetch_full_article article_content = await _fetch_full_article(item.url) or item.content or "" # Store synthetic assistant message with read_article tool result synthetic_tool_calls = [{ "function": "read_article", "arguments": {"url": item.url}, "result": { "success": True, "type": "article_content", "url": item.url, "content": article_content, "truncated": False, }, }] await add_message(conv_id, "assistant", "", status="complete", tool_calls=synthetic_tool_calls) # Store user message await add_message(conv_id, "user", "Please summarize and discuss this article.") # 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) 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 "", "Please summarize and discuss this article.", think=True, )) 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, think=True, )) return jsonify({ "assistant_message_id": assistant_msg.id, "status": "generating", "article_count": len(items), }), 202