Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
12 KiB
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:
ITEM_MAX_AGE_DAYS = 90
Update the SQL in _prune_old_items:
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):
@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:
.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.
const weatherData = ref<WeatherData[]>([])
async function loadWeather() {
try {
const data = await apiGet<{ locations: WeatherData[] }>('/api/briefing/weather')
weatherData.value = data.locations ?? []
} catch { /* silent */ }
}
Render in left column:
<div class="briefing-left">
<div class="panel-label">Weather</div>
<template v-if="weatherData.length">
<WeatherCard v-for="loc in weatherData" :key="(loc as WeatherData).location" :weather="loc" />
</template>
<div v-else class="panel-empty">No weather configured</div>
</div>
2c. News panel (right column)
Load on mount and on background refresh:
const newsItems = ref<NewsItem[]>([])
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:
const messagesEl = ref<HTMLElement | null>(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
<WeatherCard>block rendered above assistant messages - The
<div class="news-cards">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:
# 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:
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)
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/feedsfor 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
<a target="_blank">, snippet, relative date, 👍/👎 buttons - Reactions call existing
POST /api/briefing/rss-reactions/DELETE /api/briefing/rss-reactions/:id - Local
reactionsmap tracks optimistic state (same pattern asBriefingView.vue)
const items = ref<NewsItem[]>([])
const offset = ref(0)
const hasMore = ref(true)
const selectedFeedId = ref<number | null>(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
{ path: '/news', component: () => import('@/views/NewsView.vue') }
File: frontend/src/App.vue (nav links section)
Add <router-link to="/news">News</router-link> 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