# 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" ```