From aa18f4f52744f2ca5932a6e7fa6e383b18a2fdc6 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Sat, 28 Mar 2026 00:13:40 -0400 Subject: [PATCH] docs: add news briefing redesign spec and plan Co-Authored-By: Claude Sonnet 4.6 --- .../2026-03-28-news-briefing-redesign.md | 1651 +++++++++++++++++ 1 file changed, 1651 insertions(+) create mode 100644 docs/superpowers/plans/2026-03-28-news-briefing-redesign.md diff --git a/docs/superpowers/plans/2026-03-28-news-briefing-redesign.md b/docs/superpowers/plans/2026-03-28-news-briefing-redesign.md new file mode 100644 index 0000000..725766e --- /dev/null +++ b/docs/superpowers/plans/2026-03-28-news-briefing-redesign.md @@ -0,0 +1,1651 @@ +# News Feed & Briefing View Redesign Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Extend RSS article retention to 90 days, add a unified `/api/briefing/news` endpoint, 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:** The `rss_items` table already stores all article data — no new tables needed. A new endpoint queries it with a user-scoped join and optional feed filter. `build_context()` in `llm.py` gets a new `conv_id` param; for briefing conversations it injects source article content from the day's briefing message metadata. The Vue frontend restructures `BriefingView.vue` into a CSS grid and adds a new `NewsView.vue`. + +**Tech Stack:** Python/Quart, SQLAlchemy 2.0 async, raw SQL for the news query, Vue 3 + TypeScript, Pinia, existing `WeatherCard` and `ChatMessage` components. + +--- + +## File Map + +**Modified:** +- `src/fabledassistant/services/rss.py` — change `ITEM_MAX_AGE_DAYS` to 90 +- `src/fabledassistant/routes/briefing.py` — add `GET /api/briefing/news` +- `src/fabledassistant/services/llm.py` — add `conv_id` param + `_build_briefing_article_context()` helper +- `src/fabledassistant/services/generation_task.py` — pass `conv_id` to `build_context()` +- `frontend/src/api/client.ts` — add `getNewsItems()` function, import NewsItem +- `frontend/src/views/BriefingView.vue` — full 3-column redesign +- `frontend/src/router/index.ts` — add `/news` route +- `frontend/src/components/AppHeader.vue` — add News nav link + +**Created:** +- `frontend/src/types/news.ts` — `NewsItem` interface +- `frontend/src/views/NewsView.vue` — news archive view +- `tests/test_news_api.py` — backend unit tests + +--- + +### Task 1: RSS article retention (90 days) + +**Files:** +- Modify: `src/fabledassistant/services/rss.py:15-17` and `:135-141` +- Test: `tests/test_news_api.py` + +- [ ] **Step 1: Write the failing test** + +```python +# tests/test_news_api.py +def test_rss_item_max_age_is_90_days(): + """Retention window should be 90 days.""" + from fabledassistant.services.rss import ITEM_MAX_AGE_DAYS + assert ITEM_MAX_AGE_DAYS == 90 +``` + +- [ ] **Step 2: Run to verify it fails** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/fabledassistant +source .venv/bin/activate +pytest tests/test_news_api.py::test_rss_item_max_age_is_90_days -v +``` +Expected: FAIL — `AssertionError: assert 14 == 90` + +- [ ] **Step 3: Update `rss.py`** + +Change line 16 and the SQL interval in `_prune_old_items`: + +```python +# Line 16 — was: ITEM_MAX_AGE_DAYS = 14 +ITEM_MAX_AGE_DAYS = 90 +``` + +In `_prune_old_items` (around line 135), change the SQL: +```python +async def _prune_old_items(feed_id: int) -> None: + """Delete items older than ITEM_MAX_AGE_DAYS from a feed.""" + async with async_session() as session: + await session.execute( + text(""" + DELETE FROM rss_items + WHERE feed_id = :feed_id + AND published_at < NOW() - INTERVAL '90 days' + """).bindparams(feed_id=feed_id) + ) + await session.commit() +``` + +- [ ] **Step 4: Run to verify it passes** + +```bash +pytest tests/test_news_api.py::test_rss_item_max_age_is_90_days -v +``` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add src/fabledassistant/services/rss.py tests/test_news_api.py +git commit -m "feat: extend RSS item retention from 14 to 90 days" +``` + +--- + +### Task 2: `GET /api/briefing/news` endpoint + +**Files:** +- Modify: `src/fabledassistant/routes/briefing.py` +- Test: `tests/test_news_api.py` + +- [ ] **Step 1: Write the failing test** + +Add to `tests/test_news_api.py`: + +```python +import pytest +from unittest.mock import AsyncMock, MagicMock, patch +from datetime import datetime, timezone + + +def _make_mock_session(rows): + """Return a mock async context manager whose session.execute returns rows.""" + mock_result = MagicMock() + mock_result.mappings.return_value.all.return_value = rows + + mock_session = AsyncMock() + mock_session.execute = AsyncMock(return_value=mock_result) + + mock_cm = MagicMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_session) + mock_cm.__aexit__ = AsyncMock(return_value=False) + return mock_cm + + +@pytest.mark.asyncio +async def test_list_news_formats_items_correctly(): + """list_news service logic should truncate content to 300 chars and include reaction.""" + pub = datetime(2026, 3, 28, 8, 0, tzinfo=timezone.utc) + rows = [ + { + "id": 1, + "title": "EU AI Act deadline", + "url": "https://example.com/ai", + "content": "x" * 500, # should be truncated to 300 + "published_at": pub, + "topics": ["tech"], + "feed_title": "TechCrunch", + "reaction": "up", + } + ] + + # Import and call the formatting logic directly + # We test the item serialisation used in the route + item = rows[0] + result = { + "id": item["id"], + "title": item["title"], + "url": item["url"], + "snippet": (item["content"] or "")[:300], + "published_at": item["published_at"].isoformat() if item["published_at"] else None, + "topics": item["topics"] or [], + "source": item["feed_title"], + "reaction": item["reaction"], + } + + assert result["snippet"] == "x" * 300 + assert result["source"] == "TechCrunch" + assert result["reaction"] == "up" + assert result["published_at"] == pub.isoformat() +``` + +- [ ] **Step 2: Run to verify it passes already (pure logic test)** + +```bash +pytest tests/test_news_api.py::test_list_news_formats_items_correctly -v +``` +Expected: PASS (no imports needed — pure dict manipulation) + +- [ ] **Step 3: Add the `list_news` route to `routes/briefing.py`** + +Add after the `recent_items` route (around line 124), before the weather section: + +```python +@briefing_bp.route("/news", methods=["GET"]) +@_REQUIRE +async def list_news(): + """Unified news endpoint for briefing panel (days=2) and archive view (days=90).""" + 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}) +``` + +- [ ] **Step 4: Run the existing test suite to check nothing broke** + +```bash +pytest tests/ -v --tb=short 2>&1 | tail -20 +``` +Expected: all existing tests pass + +- [ ] **Step 5: Commit** + +```bash +git add src/fabledassistant/routes/briefing.py tests/test_news_api.py +git commit -m "feat: add GET /api/briefing/news unified news endpoint" +``` + +--- + +### Task 3: Deep article Q&A in briefing chat + +**Files:** +- Modify: `src/fabledassistant/services/llm.py` +- Modify: `src/fabledassistant/services/generation_task.py:180-188` +- Test: `tests/test_news_api.py` + +- [ ] **Step 1: Write the failing tests** + +Add to `tests/test_news_api.py`: + +```python +@pytest.mark.asyncio +async def test_build_briefing_article_context_empty_when_no_metadata(): + """Returns empty string when no briefing message has rss_item_ids.""" + mock_msg = MagicMock() + mock_msg.msg_metadata = {} # no rss_item_ids key + mock_msg.role = "assistant" + + mock_result = MagicMock() + mock_result.scalars.return_value.all.return_value = [mock_msg] + + mock_session = AsyncMock() + mock_session.execute = AsyncMock(return_value=mock_result) + + mock_cm = MagicMock() + mock_cm.__aenter__ = AsyncMock(return_value=mock_session) + mock_cm.__aexit__ = AsyncMock(return_value=False) + + with patch("fabledassistant.models.async_session", return_value=mock_cm): + from fabledassistant.services.llm import _build_briefing_article_context + result = await _build_briefing_article_context(conv_id=1) + + assert result == "" + + +@pytest.mark.asyncio +async def test_build_briefing_article_context_returns_formatted_string(): + """Returns ARTICLE CONTEXT block with titles and content from rss_item_ids.""" + mock_msg = MagicMock() + mock_msg.msg_metadata = {"rss_item_ids": [1, 2]} + mock_msg.role = "assistant" + + # First session: Message query + mock_result1 = MagicMock() + mock_result1.scalars.return_value.all.return_value = [mock_msg] + mock_session1 = AsyncMock() + mock_session1.execute = AsyncMock(return_value=mock_result1) + mock_cm1 = MagicMock() + mock_cm1.__aenter__ = AsyncMock(return_value=mock_session1) + mock_cm1.__aexit__ = AsyncMock(return_value=False) + + # Second session: rss_items query + rows = [ + {"title": "EU AI Act deadline", "url": "https://example.com/ai", + "content": "The EU has confirmed deadlines.", "feed_title": "TechCrunch"}, + ] + mock_result2 = MagicMock() + mock_result2.mappings.return_value.all.return_value = rows + mock_session2 = AsyncMock() + mock_session2.execute = AsyncMock(return_value=mock_result2) + mock_cm2 = MagicMock() + mock_cm2.__aenter__ = AsyncMock(return_value=mock_session2) + mock_cm2.__aexit__ = AsyncMock(return_value=False) + + with patch("fabledassistant.models.async_session", side_effect=[mock_cm1, mock_cm2]): + import importlib + import fabledassistant.services.llm as llm_mod + importlib.reload(llm_mod) + result = await llm_mod._build_briefing_article_context(conv_id=42) + + assert "ARTICLE CONTEXT" in result + assert "EU AI Act deadline" in result + assert "TechCrunch" in result + assert "The EU has confirmed" in result +``` + +- [ ] **Step 2: Run to verify they fail** + +```bash +pytest tests/test_news_api.py::test_build_briefing_article_context_empty_when_no_metadata tests/test_news_api.py::test_build_briefing_article_context_returns_formatted_string -v +``` +Expected: FAIL — `ImportError: cannot import name '_build_briefing_article_context'` + +- [ ] **Step 3: Add `_build_briefing_article_context` to `llm.py`** + +Add this function at the module level in `src/fabledassistant/services/llm.py`, after the `build_context` function (around line 685, after the `return messages, context_meta` line): + +```python +async def _build_briefing_article_context(conv_id: int) -> str: + """Inject source article content into system prompt for briefing follow-up Q&A. + + Finds the most recent assistant briefing message with rss_item_ids in its + metadata, loads those articles from DB, and returns a formatted context block. + Capped at 10 articles × 500 chars to keep token use reasonable. + """ + import json + from sqlalchemy import select, text as _text + from fabledassistant.models import async_session + from fabledassistant.models.conversation import Message + + rss_item_ids: list[int] = [] + + async with async_session() as session: + 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() + + for msg in messages: + meta = msg.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[:10] + 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) + ) + 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) +``` + +- [ ] **Step 4: Add `conv_id` param to `build_context()` and call the helper** + +In `src/fabledassistant/services/llm.py`, change the `build_context` signature (line 444): + +```python +async def build_context( + user_id: int, + history: list[dict], + current_note_id: int | None, + user_message: str, + exclude_note_ids: list[int] | None = None, + history_summary: str | None = None, + include_note_ids: list[int] | None = None, + excluded_note_ids: list[int] | None = None, + rag_project_id: int | None = None, + workspace_project_id: int | None = None, + user_timezone: str | None = None, + conv_id: int | None = None, +) -> tuple[list[dict], dict]: +``` + +Then add the briefing context injection just before the `messages = [...]` line (around line 679). The full block to add: + +```python + # Inject briefing article context for follow-up Q&A + if conv_id is not None: + from fabledassistant.models import async_session as _async_session + from fabledassistant.models.conversation import Conversation as _Conversation + async with _async_session() as _sess: + _conv = await _sess.get(_Conversation, conv_id) + if _conv and _conv.conversation_type == "briefing": + article_context = await _build_briefing_article_context(conv_id) + if article_context: + system_parts.append(f"\n\n{article_context}") +``` + +After that block the existing code continues: +```python + messages = [{"role": "system", "content": "".join(system_parts)}] + messages.extend(history) + messages.append({"role": "user", "content": user_message}) + return messages, context_meta +``` + +- [ ] **Step 5: Pass `conv_id` in `generation_task.py`** + +In `src/fabledassistant/services/generation_task.py`, change the `build_context` call (around line 180): + +```python + context_task = asyncio.create_task(build_context( + user_id, history_to_use, context_note_id, user_content, + history_summary=history_summary, + include_note_ids=include_note_ids, + excluded_note_ids=excluded_note_ids, + rag_project_id=rag_project_id, + workspace_project_id=workspace_project_id, + user_timezone=user_timezone, + conv_id=conv_id, + )) +``` + +- [ ] **Step 6: Run the new tests** + +```bash +pytest tests/test_news_api.py::test_build_briefing_article_context_empty_when_no_metadata tests/test_news_api.py::test_build_briefing_article_context_returns_formatted_string -v +``` +Expected: PASS + +- [ ] **Step 7: Run full test suite** + +```bash +pytest tests/ -v --tb=short 2>&1 | tail -20 +``` +Expected: all tests pass + +- [ ] **Step 8: Commit** + +```bash +git add src/fabledassistant/services/llm.py src/fabledassistant/services/generation_task.py tests/test_news_api.py +git commit -m "feat: inject briefing article content into chat context for deep Q&A" +``` + +--- + +### Task 4: NewsItem type + API client function + +**Files:** +- Create: `frontend/src/types/news.ts` +- Modify: `frontend/src/api/client.ts` + +- [ ] **Step 1: Create `frontend/src/types/news.ts`** + +```typescript +export interface NewsItem { + id: number + title: string + url: string + snippet: string + published_at: string | null + topics: string[] + source: string + reaction: 'up' | 'down' | null +} +``` + +- [ ] **Step 2: Add `getNewsItems` to `frontend/src/api/client.ts`** + +Add the import at the top of `client.ts` alongside existing imports: + +```typescript +import type { NewsItem } from '@/types/news' +``` + +Add the function after `deleteRssReaction` (around line 428): + +```typescript +export async function getNewsItems( + days: number = 2, + limit: number = 40, + offset: number = 0, + feedId?: number +): Promise<{ items: NewsItem[]; offset: number; limit: number }> { + const params = new URLSearchParams({ + days: String(days), + limit: String(limit), + offset: String(offset), + }) + if (feedId != null) params.set('feed_id', String(feedId)) + return apiGet(`/api/briefing/news?${params}`) +} +``` + +- [ ] **Step 3: TypeScript check** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend +npx tsc --noEmit 2>&1 | grep -v "SlashCommands\|TagSuggestion\|WikilinkSuggestion\|suggestionRenderer" +``` +Expected: no new errors + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/types/news.ts frontend/src/api/client.ts +git commit -m "feat: add NewsItem type and getNewsItems API client function" +``` + +--- + +### Task 5: BriefingView.vue — 3-column redesign + +**Files:** +- Modify: `frontend/src/views/BriefingView.vue` (full rewrite) + +- [ ] **Step 1: Replace the entire content of `BriefingView.vue`** + +```vue + + + + + +``` + +- [ ] **Step 2: TypeScript check** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend +npx tsc --noEmit 2>&1 | grep -v "SlashCommands\|TagSuggestion\|WikilinkSuggestion\|suggestionRenderer" +``` +Expected: no new errors + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/views/BriefingView.vue +git commit -m "feat: redesign briefing view as 3-column layout with weather left, news right" +``` + +--- + +### Task 6: News archive view (`/news`) + +**Files:** +- Create: `frontend/src/views/NewsView.vue` +- Modify: `frontend/src/router/index.ts` +- Modify: `frontend/src/components/AppHeader.vue` + +- [ ] **Step 1: Create `frontend/src/views/NewsView.vue`** + +```vue + + + + + +``` + +- [ ] **Step 2: Add `/news` route to `frontend/src/router/index.ts`** + +Add after the `/briefing` route (around line 122): + +```typescript + { + path: '/news', + name: 'news', + component: () => import('@/views/NewsView.vue'), + }, +``` + +- [ ] **Step 3: Add News nav link to `frontend/src/components/AppHeader.vue`** + +In the `.nav-center` div (around line 74), add after the Briefing link: + +```html +News +``` + +In the mobile menu div (around line 131), add after the Briefing link: + +```html +News +``` + +- [ ] **Step 4: TypeScript check** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/fabledassistant/frontend +npx tsc --noEmit 2>&1 | grep -v "SlashCommands\|TagSuggestion\|WikilinkSuggestion\|suggestionRenderer" +``` +Expected: no new errors + +- [ ] **Step 5: Run full backend test suite** + +```bash +cd /home/bvandeusen/Nextcloud/Projects/fabledassistant +source .venv/bin/activate +pytest tests/ -v --tb=short 2>&1 | tail -20 +``` +Expected: all tests pass + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/views/NewsView.vue frontend/src/router/index.ts frontend/src/components/AppHeader.vue +git commit -m "feat: add /news archive view with feed filter, pagination, and reactions" +```