From 3391825550cbbb6b966df9398ca4fc470fb7f507 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 27 Mar 2026 23:59:34 -0400 Subject: [PATCH] docs: add news feed & briefing redesign spec Co-Authored-By: Claude Sonnet 4.6 --- .gitignore | 2 +- .../2026-03-27-news-briefing-redesign.md | 398 ++++++++++++++++++ 2 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 docs/superpowers/specs/2026-03-27-news-briefing-redesign.md diff --git a/.gitignore b/.gitignore index c891752..54ef042 100644 --- a/.gitignore +++ b/.gitignore @@ -22,7 +22,7 @@ settings.local.json # Claude Code .claude/ -docs/superpowers/ +docs/superpowers/specs/.brainstorm/ # Environment .env diff --git a/docs/superpowers/specs/2026-03-27-news-briefing-redesign.md b/docs/superpowers/specs/2026-03-27-news-briefing-redesign.md new file mode 100644 index 0000000..be6efa5 --- /dev/null +++ b/docs/superpowers/specs/2026-03-27-news-briefing-redesign.md @@ -0,0 +1,398 @@ +# 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