docs: add news feed & briefing redesign spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+1
-1
@@ -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
|
||||
Reference in New Issue
Block a user