# News Feed & Briefing View Redesign > **For agentic workers:** Use superpowers:executing-plans or superpowers:subagent-driven-development to implement this spec task-by-task. **Goal:** Extend RSS article retention to 90 days, introduce a unified news API, redesign the briefing view into a 3-column layout (weather · chat · news), add deep article Q&A in briefing chat, and add a `/news` archive view. --- ## Architecture ### Data layer `rss_items` and `rss_item_reactions` already exist and contain everything needed. No new tables or migrations required. The only DB change is extending the prune window from 14 to 90 days. ### Unified news API A single `GET /api/briefing/news` endpoint serves both the briefing side panel (short window, no pagination) and the news archive view (full history, paginated). Both consumers share the same response shape and reaction logic. ### Briefing view `BriefingView.vue` restructures from a single-column `max-width: 760px` layout to a full-width CSS grid with three independently scrolling columns. Weather and news are fetched directly — no longer read from message metadata. ### News archive view A new `NewsView.vue` at `/news`, added to the sidebar nav, uses the same `/api/briefing/news` endpoint with `days=90` and offset-based pagination. ### Deep article Q&A `build_context()` in `llm.py` detects briefing conversations and injects the source article content from today's briefing into the system prompt, giving follow-up chat messages access to the full article text. --- ## Section 1 — Data & API layer ### 1a. Article retention (90 days) **File:** `src/fabledassistant/services/rss.py` Change the module-level constant: ```python ITEM_MAX_AGE_DAYS = 90 ``` Update the SQL in `_prune_old_items`: ```python AND published_at < NOW() - INTERVAL '90 days' ``` ### 1b. Unified news endpoint **File:** `src/fabledassistant/routes/briefing.py` Add `GET /api/briefing/news` endpoint (registered on `briefing_bp` which has prefix `/api/briefing`): ```python @briefing_bp.route("/news", methods=["GET"]) @_REQUIRE async def list_news(): 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) from sqlalchemy import text as _text 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 (:feed_id IS NULL OR f.id = :feed_id) AND i.published_at >= NOW() - make_interval(days => :days) ORDER BY i.published_at DESC NULLS LAST 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], "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}) ``` --- ## Section 2 — Briefing view redesign ### 2a. Layout restructure **File:** `frontend/src/views/BriefingView.vue` **Layout change:** Replace `.briefing-shell` (single column, max-width 760px) with a full-width 3-column grid: ```css .briefing-shell { display: grid; grid-template-rows: auto 1fr; grid-template-columns: 1fr 2fr 1fr; height: 100%; min-height: 0; } .briefing-header { grid-column: 1 / -1; /* spans all three columns */ } .briefing-left { grid-column: 1; border-right: 1px solid var(--color-border); overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.75rem; } .briefing-center { grid-column: 2; display: flex; flex-direction: column; min-height: 0; } .briefing-right { grid-column: 3; border-left: 1px solid var(--color-border); overflow-y: auto; padding: 1rem; display: flex; flex-direction: column; gap: 0.5rem; } @media (max-width: 900px) { .briefing-shell { grid-template-columns: 1fr; grid-template-rows: auto auto 1fr auto; } .briefing-header { grid-column: 1; } .briefing-left, .briefing-right { border: none; border-bottom: 1px solid var(--color-border); max-height: 200px; } } ``` ### 2b. Weather panel (left column) Remove `WeatherCard` from the message flow. Load weather independently on mount: Extract the existing weather shape from `MessageMetadata` into a standalone `WeatherData` interface at the top of `BriefingView.vue` (same fields: `location`, `current_temp`, `condition`, `today_high`, `today_low`, `yesterday_high`, `yesterday_low`, `forecast[]`). The `MessageMetadata` interface can then be removed since `rss_items`/weather are no longer read from message metadata. ```typescript const weatherData = ref([]) async function loadWeather() { try { const data = await apiGet<{ locations: WeatherData[] }>('/api/briefing/weather') weatherData.value = data.locations ?? [] } catch { /* silent */ } } ``` Render in left column: ```html
Weather
No weather configured
``` ### 2c. News panel (right column) Load on mount and on background refresh: ```typescript const newsItems = ref([]) async function loadNews() { try { const data = await apiGet<{ items: NewsItem[] }>('/api/briefing/news?days=2&limit=40') newsItems.value = data.items } catch { /* silent */ } } ``` Render in right column using the existing `news-card` markup and `handleReaction` function. Remove the inline `news-cards` block from the message loop. ### 2d. Auto-scroll to bottom on mount After messages load, scroll the center messages container to the bottom: ```typescript const messagesEl = ref(null) function scrollToBottom() { nextTick(() => { if (messagesEl.value) { messagesEl.value.scrollTop = messagesEl.value.scrollHeight } }) } ``` Call `scrollToBottom()` after `loadAll()` completes and after the streaming watcher fires. ### 2e. Remove inline weather/news from message flow In the message loop template, remove: - The `` block rendered above assistant messages - The `
` block rendered below assistant messages The `msgMetadata()` helper and `MessageMetadata` interface can be removed entirely since they're no longer used in the template. --- ## Section 3 — Deep article Q&A in briefing chat **File:** `src/fabledassistant/services/llm.py` (the `build_context()` function) After the existing RAG context is assembled, check if the conversation is a briefing and inject article content: ```python # Inject briefing article content for follow-up Q&A if conversation and getattr(conversation, "conversation_type", None) == "briefing": article_context = await _build_briefing_article_context(conversation.id) if article_context: system_parts.append(article_context) ``` New helper function in `llm.py`: ```python async def _build_briefing_article_context(conv_id: int) -> str: """ Fetch article content from today's briefing message and return a formatted context block for injection into the system prompt. Capped at 10 articles × 500 chars to keep token use reasonable. """ from sqlalchemy import select, text as _text from fabledassistant.models import async_session from fabledassistant.models.conversation import Message import json async with async_session() as session: # Get most recent assistant briefing message with rss_item_ids result = await session.execute( select(Message) .where( Message.conversation_id == conv_id, Message.role == "assistant", ) .order_by(Message.created_at.desc()) .limit(10) ) messages = result.scalars().all() rss_item_ids: list[int] = [] for msg in messages: meta = msg.metadata or {} if isinstance(meta, str): try: meta = json.loads(meta) except Exception: continue ids = meta.get("rss_item_ids") or [] if ids: rss_item_ids = ids break if not rss_item_ids: return "" async with async_session() as session: result = await session.execute( _text(""" SELECT i.title, i.url, i.content, f.title AS feed_title FROM rss_items i JOIN rss_feeds f ON f.id = i.feed_id WHERE i.id = ANY(:ids) ORDER BY i.published_at DESC NULLS LAST LIMIT 10 """).bindparams(ids=rss_item_ids[:10]) ) rows = result.mappings().all() if not rows: return "" lines = ["ARTICLE CONTEXT (source articles from today's briefing):"] for row in rows: lines.append(f"\n[{row['feed_title']}] {row['title']}") if row["url"]: lines.append(f"URL: {row['url']}") if row["content"]: lines.append(row["content"][:500]) return "\n".join(lines) ``` --- ## Section 4 — News archive view ### 4a. Frontend type **File:** `frontend/src/types/news.ts` (new) ```typescript export interface NewsItem { id: number title: string url: string snippet: string published_at: string | null topics: string[] source: string reaction: 'up' | 'down' | null } ``` ### 4b. NewsView component **File:** `frontend/src/views/NewsView.vue` (new) - Loads feeds list from `GET /api/briefing/feeds` for the filter dropdown - Loads items from `GET /api/briefing/news?days=90&limit=40&offset=0&feed_id=X` - "Load more" button appends next page (increments offset by 40) - Each card: source label, title as ``, snippet, relative date, 👍/👎 buttons - Reactions call existing `POST /api/briefing/rss-reactions` / `DELETE /api/briefing/rss-reactions/:id` - Local `reactions` map tracks optimistic state (same pattern as `BriefingView.vue`) ```typescript const items = ref([]) const offset = ref(0) const hasMore = ref(true) const selectedFeedId = ref(null) const loading = ref(false) async function loadMore() { if (loading.value || !hasMore.value) return loading.value = true try { const params = new URLSearchParams({ days: '90', limit: '40', offset: String(offset.value) }) if (selectedFeedId.value) params.set('feed_id', String(selectedFeedId.value)) const data = await apiGet<{ items: NewsItem[] }>(`/api/briefing/news?${params}`) items.value = [...items.value, ...data.items] offset.value += data.items.length hasMore.value = data.items.length === 40 } finally { loading.value = false } } function onFeedChange() { items.value = [] offset.value = 0 hasMore.value = true loadMore() } ``` ### 4c. Router and nav **File:** `frontend/src/router/index.ts` ```typescript { path: '/news', component: () => import('@/views/NewsView.vue') } ``` **File:** `frontend/src/App.vue` (nav links section) Add `News` alongside Notes/Tasks/Projects. --- ## What is NOT in scope - Topic-based filtering in the news archive (reactions shape preferences; filtering can come later) - Inline article display / full-text reader (links open to source) - Changes to the RSS feed management UI in Settings - Push notifications for new articles