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 Code
|
||||||
.claude/
|
.claude/
|
||||||
docs/superpowers/
|
docs/superpowers/specs/.brainstorm/
|
||||||
|
|
||||||
# Environment
|
# Environment
|
||||||
.env
|
.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