docs: add news feed & briefing redesign spec

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-27 23:59:34 -04:00
parent 3d7c953627
commit 3391825550
2 changed files with 399 additions and 1 deletions
+1 -1
View File
@@ -22,7 +22,7 @@ settings.local.json
# Claude Code
.claude/
docs/superpowers/
docs/superpowers/specs/.brainstorm/
# Environment
.env
@@ -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<WeatherData[]>([])
async function loadWeather() {
try {
const data = await apiGet<{ locations: WeatherData[] }>('/api/briefing/weather')
weatherData.value = data.locations ?? []
} catch { /* silent */ }
}
```
Render in left column:
```html
<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:
```typescript
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:
```typescript
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:
```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 `<a target="_blank">`, 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<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`
```typescript
{ 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