Files
FabledScribe/docs/plans/2026-03-25-briefing-improvements.md
T
2026-03-25 09:48:41 -04:00

2173 lines
67 KiB
Markdown

# Briefing Service Improvements — 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:** Replace the briefing's repetitive, prose-only output with a pre-filtered, structured pipeline that tracks task changes, classifies RSS topics, gates stale weather, and surfaces news source links with per-story reactions.
**Architecture:** Pre-processing stage runs before gather — task change detection diffs against a DB snapshot, RSS filtering applies topic preferences and reaction weights, weather is gated at 24h. Structured card metadata is stored on `Message.metadata` (JSONB) at write time; the frontend reads it on load to render `WeatherCard.vue` and reaction buttons without any SSE changes.
**Tech Stack:** Python 3.12 + SQLAlchemy 2.0 async, Quart, httpx (Open-Meteo `current_weather=true` + `past_days=1`), Vue 3 + TypeScript, existing `TagInput.vue`, FastMCP (fable-mcp).
**Spec:** `docs/specs/2026-03-25-briefing-improvements-design.md`
---
## File Map
### New backend files
- `alembic/versions/0028_add_briefing_improvements.py`
- `src/fabledassistant/services/rss_classifier.py`
- `src/fabledassistant/services/briefing_preferences.py`
### Modified backend files
- `src/fabledassistant/models/conversation.py` — add `Message.metadata` JSONB column + `to_dict()`
- `src/fabledassistant/models/rss_feed.py` — add `RssItem.topics`, `RssItem.classified_at`
- `src/fabledassistant/services/briefing_conversations.py``post_message(... metadata=None)`
- `src/fabledassistant/services/weather.py` — add `current_weather=true`, `past_days=1`, `parse_weather_card_data()`
- `src/fabledassistant/services/rss.py` — trigger classification after storing new items
- `src/fabledassistant/services/briefing_pipeline.py` — pre-processing stage, task snapshot helpers, return `(text, metadata)` from `run_compilation`
- `src/fabledassistant/routes/briefing.py` — unpack tuple from `run_compilation`; add reaction endpoints
### New frontend files
- `frontend/src/components/WeatherCard.vue`
### Modified frontend files
- `frontend/src/api/client.ts` — add `postRssReaction()`, `deleteRssReaction()`
- `frontend/src/views/BriefingView.vue` — render `WeatherCard`, reaction buttons from metadata
- `frontend/src/views/SettingsView.vue` — add "News Preferences" subsection
### New MCP files
- `fable-mcp/fable_mcp/tools/briefing.py`
### Modified MCP files
- `fable-mcp/fable_mcp/server.py` — register briefing tools
### Test files
- `tests/test_briefing_pipeline.py` — extend with new tests
- `tests/test_weather_service.py` — extend with card parser tests
- `tests/test_rss_service.py` — extend with classifier + preferences tests
---
## Task 1: Migration 0028
**Files:**
- Create: `alembic/versions/0028_add_briefing_improvements.py`
- [ ] **Step 1: Create the migration file**
```python
"""Add briefing improvements: rss_items topics/classified_at, messages metadata,
rss_item_reactions, briefing_task_snapshot."""
from alembic import op
revision = "0028"
down_revision = "0027"
def upgrade() -> None:
op.execute("""
ALTER TABLE rss_items
ADD COLUMN IF NOT EXISTS topics TEXT[] DEFAULT '{}',
ADD COLUMN IF NOT EXISTS classified_at TIMESTAMPTZ
""")
op.execute("""
ALTER TABLE messages
ADD COLUMN IF NOT EXISTS metadata JSONB
""")
op.execute("""
CREATE TABLE IF NOT EXISTS rss_item_reactions (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rss_item_id INTEGER NOT NULL REFERENCES rss_items(id) ON DELETE CASCADE,
reaction TEXT NOT NULL CHECK (reaction IN ('up', 'down')),
created_at TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (user_id, rss_item_id)
)
""")
op.execute(
"CREATE INDEX IF NOT EXISTS ix_rss_item_reactions_user_id "
"ON rss_item_reactions(user_id)"
)
op.execute("""
CREATE TABLE IF NOT EXISTS briefing_task_snapshot (
id SERIAL PRIMARY KEY,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
task_id INTEGER NOT NULL REFERENCES notes(id) ON DELETE CASCADE,
snapshot_hash TEXT NOT NULL,
last_briefed TIMESTAMPTZ DEFAULT NOW(),
UNIQUE (user_id, task_id)
)
""")
op.execute(
"CREATE INDEX IF NOT EXISTS ix_briefing_task_snapshot_user_id "
"ON briefing_task_snapshot(user_id)"
)
def downgrade() -> None:
op.execute("DROP TABLE IF EXISTS briefing_task_snapshot")
op.execute("DROP TABLE IF EXISTS rss_item_reactions")
op.execute("ALTER TABLE messages DROP COLUMN IF EXISTS metadata")
op.execute("ALTER TABLE rss_items DROP COLUMN IF EXISTS classified_at")
op.execute("ALTER TABLE rss_items DROP COLUMN IF EXISTS topics")
```
- [ ] **Step 2: Apply migration inside the running container**
```bash
docker compose exec app alembic upgrade head
```
Expected: `Running upgrade 0027 -> 0028, Add briefing improvements`
- [ ] **Step 3: Commit**
```bash
git add alembic/versions/0028_add_briefing_improvements.py
git commit -m "feat(briefing): add migration 0028 — briefing improvements schema"
```
---
## Task 2: Update SQLAlchemy Models
**Files:**
- Modify: `src/fabledassistant/models/conversation.py`
- Modify: `src/fabledassistant/models/rss_feed.py`
- [ ] **Step 1: Add `metadata` column to `Message` in `models/conversation.py`**
In the `Message` class, after the existing `tool_calls` column, add:
```python
metadata: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
```
In `Message.to_dict()`, add `"metadata": self.metadata` to the returned dict.
- [ ] **Step 2: Add `topics` and `classified_at` columns to `RssItem` in `models/rss_feed.py`**
Add imports at the top (if not already present):
```python
from sqlalchemy import ARRAY, DateTime, Text # ARRAY is new
from sqlalchemy.dialects.postgresql import ARRAY as PG_ARRAY
```
Actually SQLAlchemy's native ARRAY works for PostgreSQL. Use:
```python
from sqlalchemy import ARRAY, Text
```
In the `RssItem` class, after `fetched_at`, add:
```python
topics: Mapped[list[str]] = mapped_column(
ARRAY(Text), nullable=False, default=list, server_default="{}"
)
classified_at: Mapped[datetime | None] = mapped_column(
DateTime(timezone=True), nullable=True
)
```
In `RssItem.to_dict()`, add:
```python
"topics": self.topics or [],
"classified_at": self.classified_at.isoformat() if self.classified_at else None,
```
- [ ] **Step 3: Rebuild the container to pick up model changes**
```bash
docker compose up --build -d app
```
- [ ] **Step 4: Write a quick smoke test (no DB needed)**
In `tests/test_briefing_models.py`, add:
```python
def test_message_metadata_field_exists():
from fabledassistant.models.conversation import Message
m = Message(conversation_id=1, role="assistant", content="hi", metadata={"foo": 1})
assert m.metadata == {"foo": 1}
d = m.to_dict()
assert "metadata" in d
def test_rss_item_topics_field_exists():
from fabledassistant.models.rss_feed import RssItem
item = RssItem(feed_id=1, guid="x", title="Test", topics=["technology"])
assert item.topics == ["technology"]
d = item.to_dict()
assert "topics" in d
assert "classified_at" in d
```
- [ ] **Step 5: Run tests**
```bash
docker compose exec app /opt/venv/bin/pytest tests/test_briefing_models.py -v
```
Expected: both new tests PASS.
- [ ] **Step 6: Commit**
```bash
git add src/fabledassistant/models/conversation.py src/fabledassistant/models/rss_feed.py tests/test_briefing_models.py
git commit -m "feat(briefing): add Message.metadata and RssItem.topics/classified_at columns"
```
---
## Task 3: Extend `post_message()`
**Files:**
- Modify: `src/fabledassistant/services/briefing_conversations.py`
- [ ] **Step 1: Write the failing test**
In `tests/test_briefing_pipeline.py`, add:
```python
@pytest.mark.asyncio
async def test_post_message_accepts_metadata():
"""post_message should accept an optional metadata dict and store it."""
from unittest.mock import AsyncMock, patch, MagicMock
mock_msg = MagicMock()
mock_msg.id = 1
with patch("fabledassistant.services.briefing_conversations.async_session") as mock_session_cls:
mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
mock_session.add = MagicMock()
mock_session.get = AsyncMock(return_value=None)
mock_session.commit = AsyncMock()
mock_session.refresh = AsyncMock()
mock_session_cls.return_value = mock_session
from fabledassistant.services.briefing_conversations import post_message
import importlib
import fabledassistant.services.briefing_conversations as bc
importlib.reload(bc)
# Should not raise TypeError
await bc.post_message(1, "assistant", "text", metadata={"rss_item_ids": [1, 2]})
```
- [ ] **Step 2: Run test to verify it fails**
```bash
docker compose exec app /opt/venv/bin/pytest tests/test_briefing_pipeline.py::test_post_message_accepts_metadata -v
```
Expected: FAIL — `TypeError: post_message() got an unexpected keyword argument 'metadata'`
- [ ] **Step 3: Update `post_message()` signature**
In `services/briefing_conversations.py`, change:
```python
async def post_message(conversation_id: int, role: str, content: str) -> Message:
"""Append a message to a briefing conversation."""
async with async_session() as session:
msg = Message(
conversation_id=conversation_id,
role=role,
content=content,
status="complete",
)
```
To:
```python
async def post_message(
conversation_id: int,
role: str,
content: str,
metadata: dict | None = None,
) -> Message:
"""Append a message to a briefing conversation."""
async with async_session() as session:
msg = Message(
conversation_id=conversation_id,
role=role,
content=content,
status="complete",
metadata=metadata,
)
```
- [ ] **Step 4: Run test to verify it passes**
```bash
docker compose exec app /opt/venv/bin/pytest tests/test_briefing_pipeline.py::test_post_message_accepts_metadata -v
```
Expected: PASS.
- [ ] **Step 5: Commit**
```bash
git add src/fabledassistant/services/briefing_conversations.py tests/test_briefing_pipeline.py
git commit -m "feat(briefing): extend post_message() with optional metadata parameter"
```
---
## Task 4: Weather Service — Card Data Parser
**Files:**
- Modify: `src/fabledassistant/services/weather.py`
- Test: `tests/test_weather_service.py`
- [ ] **Step 1: Write failing tests**
Open `tests/test_weather_service.py` and add:
```python
def test_parse_weather_card_data_returns_none_when_stale():
"""Should return None when cache is older than 24 hours."""
from datetime import datetime, timezone, timedelta
from unittest.mock import MagicMock
from fabledassistant.services.weather import parse_weather_card_data
cache = MagicMock()
cache.fetched_at = datetime.now(timezone.utc) - timedelta(hours=25)
cache.forecast_json = {}
assert parse_weather_card_data(cache) is None
def test_parse_weather_card_data_returns_card_schema():
"""Should return the correct metadata.weather schema for fresh cache."""
from datetime import datetime, timezone, timedelta, date
from unittest.mock import MagicMock
from fabledassistant.services.weather import parse_weather_card_data
today = date.today().isoformat()
yesterday = (date.today() - timedelta(days=1)).isoformat()
tomorrow = (date.today() + timedelta(days=1)).isoformat()
cache = MagicMock()
cache.fetched_at = datetime.now(timezone.utc)
cache.location_label = "Berlin, DE"
cache.forecast_json = {
"current_weather": {"temperature": 12.0, "weathercode": 2},
"daily": {
"time": [yesterday, today, tomorrow],
"temperature_2m_max": [14.0, 16.0, 18.0],
"temperature_2m_min": [9.0, 8.0, 10.0],
"precipitation_sum": [0.0, 0.0, 1.5],
"weathercode": [1, 2, 61],
"windspeed_10m_max": [10.0, 12.0, 8.0],
}
}
card = parse_weather_card_data(cache)
assert card is not None
assert card["current_temp"] == 12
assert card["condition"] == "Partly cloudy"
assert card["today_high"] == 16
assert card["today_low"] == 8
assert card["yesterday_high"] == 14
assert card["yesterday_low"] == 9
assert len(card["forecast"]) >= 1
assert card["location"] == "Berlin, DE"
```
- [ ] **Step 2: Run tests to verify they fail**
```bash
docker compose exec app /opt/venv/bin/pytest tests/test_weather_service.py -v -k "parse_weather_card"
```
Expected: FAIL — `ImportError: cannot import name 'parse_weather_card_data'`
- [ ] **Step 3: Update `_fetch_open_meteo` to request current weather + past day**
In `services/weather.py`, update `_fetch_open_meteo`:
```python
async def _fetch_open_meteo(lat: float, lon: float) -> dict:
"""Fetch 7-day forecast from Open-Meteo with current conditions and yesterday's data."""
async with httpx.AsyncClient(timeout=15.0) as client:
resp = await client.get(OPEN_METEO_URL, params={
"latitude": lat,
"longitude": lon,
"daily": OPEN_METEO_DAILY,
"current_weather": "true",
"past_days": 1,
"timezone": "auto",
"forecast_days": 7,
})
resp.raise_for_status()
return resp.json()
```
- [ ] **Step 4: Add `parse_weather_card_data()` to `services/weather.py`**
Add after `detect_changes()`:
```python
def parse_weather_card_data(
cache_row,
temp_unit: str = "C",
) -> dict | None:
"""
Parse a WeatherCache row into the metadata.weather card schema.
Returns None if the cache is stale (older than 24 hours).
"""
from datetime import date, timedelta
if cache_row is None or cache_row.fetched_at is None:
return None
age_seconds = (datetime.now(timezone.utc) - cache_row.fetched_at).total_seconds()
if age_seconds > 86400:
return None
raw = cache_row.forecast_json or {}
current_weather = raw.get("current_weather", {})
days = parse_forecast(raw)
today_str = date.today().isoformat()
yesterday_str = (date.today() - timedelta(days=1)).isoformat()
today_day = next((d for d in days if d["date"] == today_str), None)
yesterday_day = next((d for d in days if d["date"] == yesterday_str), None)
future_days = [d for d in days if d["date"] > today_str][:5]
def to_temp(c: float) -> int:
if temp_unit == "F":
return round(c * 9 / 5 + 32)
return round(c)
def day_label(date_str: str) -> str:
from datetime import date as _date
try:
return _date.fromisoformat(date_str).strftime("%a")
except ValueError:
return date_str
return {
"location": getattr(cache_row, "location_label", ""),
"fetched_at": cache_row.fetched_at.isoformat(),
"current_temp": to_temp(current_weather.get("temperature", 0)),
"condition": describe_weathercode(current_weather.get("weathercode", 0)),
"today_high": to_temp(today_day["temp_max"]) if today_day else None,
"today_low": to_temp(today_day["temp_min"]) if today_day else None,
"yesterday_high": to_temp(yesterday_day["temp_max"]) if yesterday_day else None,
"yesterday_low": to_temp(yesterday_day["temp_min"]) if yesterday_day else None,
"forecast": [
{
"day": day_label(d["date"]),
"condition": d["description"],
"high": to_temp(d["temp_max"]),
"low": to_temp(d["temp_min"]),
}
for d in future_days
],
}
```
- [ ] **Step 5: Run tests**
```bash
docker compose exec app /opt/venv/bin/pytest tests/test_weather_service.py -v -k "parse_weather_card"
```
Expected: both PASS.
- [ ] **Step 6: Commit**
```bash
git add src/fabledassistant/services/weather.py tests/test_weather_service.py
git commit -m "feat(briefing): add past_days/current_weather to Open-Meteo fetch; add parse_weather_card_data()"
```
---
## Task 5: RSS Classifier Service
**Files:**
- Create: `src/fabledassistant/services/rss_classifier.py`
- Test: `tests/test_rss_service.py`
- [ ] **Step 1: Write failing test**
In `tests/test_rss_service.py`, add:
```python
@pytest.mark.asyncio
async def test_classify_items_batch_returns_topic_map():
"""classify_items_batch should return a dict mapping item_id to topic list."""
from unittest.mock import AsyncMock, patch
fake_response = '{"1": ["technology", "ai"], "2": ["politics"]}'
with patch(
"fabledassistant.services.rss_classifier._llm_classify",
new_callable=AsyncMock,
return_value=fake_response,
):
from fabledassistant.services import rss_classifier
import importlib; importlib.reload(rss_classifier)
items = [
{"id": 1, "title": "OpenAI releases GPT-5", "content": "..."},
{"id": 2, "title": "EU passes new law", "content": "..."},
]
result = await rss_classifier.classify_items_batch(items, user_include_topics=[])
assert result[1] == ["technology", "ai"]
assert result[2] == ["politics"]
@pytest.mark.asyncio
async def test_classify_items_batch_handles_llm_failure():
"""classify_items_batch should return empty lists on LLM error."""
from unittest.mock import AsyncMock, patch
with patch(
"fabledassistant.services.rss_classifier._llm_classify",
new_callable=AsyncMock,
side_effect=Exception("LLM unavailable"),
):
from fabledassistant.services import rss_classifier
import importlib; importlib.reload(rss_classifier)
items = [{"id": 5, "title": "Some news", "content": ""}]
result = await rss_classifier.classify_items_batch(items, user_include_topics=[])
assert result == {} # Empty on failure — items stay unclassified
```
- [ ] **Step 2: Run tests to confirm FAIL**
```bash
docker compose exec app /opt/venv/bin/pytest tests/test_rss_service.py -v -k "classify"
```
Expected: FAIL — module not found.
- [ ] **Step 3: Create `services/rss_classifier.py`**
```python
"""
RSS item topic classifier.
Classifies RSS items into topic tags using a fast non-streaming LLM call.
Called from rss.py after new items are stored — fire-and-forget.
"""
import json
import logging
from datetime import datetime, timezone
import httpx
from fabledassistant.config import Config
logger = logging.getLogger(__name__)
STANDARD_TOPICS = [
"technology", "science", "politics", "business",
"health", "environment", "local", "entertainment", "sports", "other",
]
_CLASSIFY_PROMPT = """\
Classify each news item into 1-3 topics. Use only topics from this list: {vocab}.
Return ONLY a JSON object mapping item_id (as string) to a list of topics.
Example: {{"1": ["technology", "ai"], "2": ["politics"]}}
Items:
{items_block}"""
async def _llm_classify(prompt: str, model: str) -> str:
"""Make a fast non-streaming LLM call and return the raw text response."""
payload = {
"model": model,
"messages": [{"role": "user", "content": prompt}],
"stream": False,
"options": {"num_ctx": 2048, "temperature": 0.0},
}
async with httpx.AsyncClient(timeout=30.0) as client:
resp = await client.post(f"{Config.OLLAMA_URL}/api/chat", json=payload)
resp.raise_for_status()
return resp.json().get("message", {}).get("content", "")
async def classify_items_batch(
items: list[dict],
user_include_topics: list[str],
model: str | None = None,
) -> dict[int, list[str]]:
"""
Classify a batch of RSS items into topic tags.
Args:
items: list of dicts with 'id', 'title', 'content'
user_include_topics: extra topics from user preferences to add to vocabulary
model: Ollama model name; defaults to Config.OLLAMA_MODEL
Returns:
dict mapping item_id (int) -> list of topic strings.
Items not returned had classification fail; callers should leave classified_at=NULL.
"""
if not items:
return {}
if model is None:
model = Config.OLLAMA_MODEL
vocab = STANDARD_TOPICS + [t for t in user_include_topics if t not in STANDARD_TOPICS]
items_block = "\n".join(
f"[{item['id']}] {item['title']}{item.get('content', '')[:300]}"
for item in items
)
prompt = _CLASSIFY_PROMPT.format(vocab=", ".join(vocab), items_block=items_block)
try:
raw = await _llm_classify(prompt, model)
# Extract JSON from response (LLM may wrap it in markdown)
raw = raw.strip()
if raw.startswith("```"):
raw = raw.split("```")[1]
if raw.startswith("json"):
raw = raw[4:]
parsed = json.loads(raw)
return {int(k): v for k, v in parsed.items() if isinstance(v, list)}
except Exception:
logger.warning("RSS classification failed", exc_info=True)
return {}
async def classify_and_store(
item_ids: list[int],
user_id: int,
) -> None:
"""
Classify unclassified RSS items and write results to DB.
Called as a fire-and-forget task from rss.py.
"""
from sqlalchemy import select
from fabledassistant.models import async_session
from fabledassistant.models.rss_feed import RssItem
from fabledassistant.services.settings import get_setting
if not item_ids:
return
# Load the items
async with async_session() as session:
result = await session.execute(
select(RssItem).where(RssItem.id.in_(item_ids))
)
items = list(result.scalars().all())
if not items:
return
# Get user's include topics to extend vocabulary
raw_include = await get_setting(user_id, "briefing_include_topics", "[]")
try:
include_topics = json.loads(raw_include) if isinstance(raw_include, str) else []
except Exception:
include_topics = []
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
# Classify in batches of 10
batch_size = 10
all_results: dict[int, list[str]] = {}
for i in range(0, len(items), batch_size):
batch = items[i: i + batch_size]
batch_dicts = [{"id": it.id, "title": it.title, "content": it.content} for it in batch]
results = await classify_items_batch(batch_dicts, include_topics, model=model)
all_results.update(results)
# Write back to DB
now = datetime.now(timezone.utc)
async with async_session() as session:
for item in items:
item_db = await session.get(RssItem, item.id)
if item_db is None:
continue
topics = all_results.get(item.id)
if topics is not None:
item_db.topics = topics
item_db.classified_at = now
await session.commit()
```
- [ ] **Step 4: Run tests**
```bash
docker compose exec app /opt/venv/bin/pytest tests/test_rss_service.py -v -k "classify"
```
Expected: both PASS.
- [ ] **Step 5: Commit**
```bash
git add src/fabledassistant/services/rss_classifier.py tests/test_rss_service.py
git commit -m "feat(briefing): add rss_classifier service for LLM-based topic tagging"
```
---
## Task 6: Briefing Preferences Service
> **Dependency:** Task 2 Step 2 must be complete before this task — `score_and_filter_items` expects items to have a `topics` key in their dicts, which comes from the `RssItem.topics` column added in Task 2. Run Task 2 first.
**Files:**
- Create: `src/fabledassistant/services/briefing_preferences.py`
- Test: `tests/test_rss_service.py`
- [ ] **Step 1: Write failing tests**
In `tests/test_rss_service.py`, add:
```python
def test_score_rss_items_excludes_blacklisted_topics():
"""Items with excluded topics should be removed."""
from fabledassistant.services.briefing_preferences import score_and_filter_items
items = [
{"id": 1, "title": "Tech news", "topics": ["technology"], "published_at": "2026-03-25T08:00:00"},
{"id": 2, "title": "Sports score", "topics": ["sports"], "published_at": "2026-03-25T08:00:00"},
]
result = score_and_filter_items(
items,
include_topics=["technology"],
exclude_topics=["sports"],
topic_scores={},
max_items=10,
)
ids = [r["id"] for r in result]
assert 1 in ids
assert 2 not in ids
def test_score_rss_items_boosts_included_topics():
"""Items matching include_topics should rank higher than neutral items."""
from fabledassistant.services.briefing_preferences import score_and_filter_items
items = [
{"id": 1, "title": "Random news", "topics": ["other"], "published_at": "2026-03-25T07:00:00"},
{"id": 2, "title": "Tech news", "topics": ["technology"], "published_at": "2026-03-25T06:00:00"},
]
result = score_and_filter_items(
items,
include_topics=["technology"],
exclude_topics=[],
topic_scores={},
max_items=10,
)
# Tech item should be ranked first despite being older
assert result[0]["id"] == 2
def test_score_rss_items_no_preferences_returns_all():
"""With no preferences, all items should be returned sorted by recency."""
from fabledassistant.services.briefing_preferences import score_and_filter_items
items = [
{"id": 1, "title": "A", "topics": [], "published_at": "2026-03-24T10:00:00"},
{"id": 2, "title": "B", "topics": [], "published_at": "2026-03-25T10:00:00"},
]
result = score_and_filter_items(items, [], [], {}, max_items=10)
assert result[0]["id"] == 2 # Newer first
```
- [ ] **Step 2: Run tests to confirm FAIL**
```bash
docker compose exec app /opt/venv/bin/pytest tests/test_rss_service.py -v -k "score_rss"
```
Expected: FAIL — module not found.
- [ ] **Step 3: Create `services/briefing_preferences.py`**
```python
"""
Briefing preferences: load topic settings, aggregate reaction scores,
filter and rank RSS items for briefing inclusion.
"""
import json
import logging
from datetime import datetime, timezone
from sqlalchemy import func, select
from fabledassistant.models import async_session
logger = logging.getLogger(__name__)
async def load_topic_preferences(user_id: int) -> tuple[list[str], list[str]]:
"""
Return (include_topics, exclude_topics) from user settings.
"""
from fabledassistant.services.settings import get_setting
raw_include = await get_setting(user_id, "briefing_include_topics", "[]")
raw_exclude = await get_setting(user_id, "briefing_exclude_topics", "[]")
def _parse(raw) -> list[str]:
try:
val = json.loads(raw) if isinstance(raw, str) else raw
return [str(t) for t in val] if isinstance(val, list) else []
except Exception:
return []
return _parse(raw_include), _parse(raw_exclude)
async def load_topic_reaction_scores(user_id: int) -> dict[str, float]:
"""
Aggregate per-topic reaction scores from the last 30 days.
Returns a dict of topic -> net_score (positive = liked, negative = disliked).
Uses rss_item_reactions joined to rss_items.topics.
"""
from fabledassistant.models.rss_feed import RssItem
try:
async with async_session() as session:
# Raw SQL is simpler here due to ARRAY unnest
result = await session.execute(
__import__("sqlalchemy", fromlist=["text"]).text("""
SELECT unnest(i.topics) AS topic,
SUM(CASE r.reaction WHEN 'up' THEN 1 ELSE -1 END) AS score
FROM rss_item_reactions r
JOIN rss_items i ON i.id = r.rss_item_id
WHERE r.user_id = :uid
AND r.created_at > NOW() - INTERVAL '30 days'
GROUP BY topic
""").bindparams(uid=user_id)
)
return {row.topic: float(row.score) for row in result}
except Exception:
logger.warning("Failed to load topic reaction scores", exc_info=True)
return {}
def score_and_filter_items(
items: list[dict],
include_topics: list[str],
exclude_topics: list[str],
topic_scores: dict[str, float],
max_items: int = 10,
) -> list[dict]:
"""
Score, filter, and rank RSS items for briefing inclusion.
Scoring:
- Hard-exclude: any item tagged with an excluded topic is removed.
- Base score: 0.0
- +2.0 per topic that appears in include_topics
- +1.0 / -1.0 per topic based on reaction score (clamped per topic)
- Tiebreak: newer published_at wins
Returns up to max_items items, highest score first.
Items with classified_at=None (unclassified) pass through with score=0.
"""
include_set = set(include_topics)
exclude_set = set(exclude_topics)
scored = []
for item in items:
item_topics = item.get("topics") or []
# Hard exclude
if exclude_set and any(t in exclude_set for t in item_topics):
continue
score = 0.0
for topic in item_topics:
if topic in include_set:
score += 2.0
if topic in topic_scores:
score += max(-1.0, min(1.0, topic_scores[topic]))
# Parse published_at for tiebreak
pub_str = item.get("published_at") or ""
try:
pub_ts = datetime.fromisoformat(pub_str).timestamp() if pub_str else 0.0
except ValueError:
pub_ts = 0.0
scored.append((score, pub_ts, item))
# Sort: highest score first, then newest first
scored.sort(key=lambda x: (x[0], x[1]), reverse=True)
return [item for _, _, item in scored[:max_items]]
```
- [ ] **Step 4: Run tests**
```bash
docker compose exec app /opt/venv/bin/pytest tests/test_rss_service.py -v -k "score_rss"
```
Expected: all PASS.
- [ ] **Step 5: Commit**
```bash
git add src/fabledassistant/services/briefing_preferences.py tests/test_rss_service.py
git commit -m "feat(briefing): add briefing_preferences service for RSS scoring and filtering"
```
---
## Task 7: Task Change Detection
**Files:**
- Modify: `src/fabledassistant/services/briefing_pipeline.py`
- Test: `tests/test_briefing_pipeline.py`
- [ ] **Step 1: Write failing tests**
In `tests/test_briefing_pipeline.py`, add:
```python
def test_compute_task_snapshot_hash():
"""compute_task_hash should return a stable SHA-256 hex string."""
from fabledassistant.services.briefing_pipeline import compute_task_hash
task = {"status": "todo", "priority": "high", "due_date": "2026-03-25", "title": "Write spec"}
h = compute_task_hash(task)
assert len(h) == 64 # SHA-256 hex
# Same inputs produce same hash
assert h == compute_task_hash(task)
# Different status produces different hash
assert h != compute_task_hash({**task, "status": "done"})
@pytest.mark.asyncio
async def test_split_changed_tasks_all_new():
"""split_changed_tasks should return all tasks as changed when no snapshot exists."""
from unittest.mock import AsyncMock, patch, MagicMock
from fabledassistant.services.briefing_pipeline import split_changed_tasks
tasks = [
{"task_id": 1, "title": "A", "status": "todo", "priority": "none", "due_date": None},
]
with patch(
"fabledassistant.services.briefing_pipeline.async_session"
) as mock_cls:
mock_session = AsyncMock()
mock_session.__aenter__ = AsyncMock(return_value=mock_session)
mock_session.__aexit__ = AsyncMock(return_value=False)
mock_session.execute = AsyncMock(return_value=MagicMock(
scalars=MagicMock(return_value=MagicMock(all=MagicMock(return_value=[])))
))
mock_cls.return_value = mock_session
changed, unchanged_count = await split_changed_tasks(user_id=1, tasks=tasks)
assert len(changed) == 1
assert unchanged_count == 0
```
- [ ] **Step 2: Run tests to verify FAIL**
```bash
docker compose exec app /opt/venv/bin/pytest tests/test_briefing_pipeline.py -v -k "task_snapshot or task_hash or split_changed"
```
Expected: FAIL.
- [ ] **Step 3: Add helpers to `briefing_pipeline.py`**
Add these imports at the top of `briefing_pipeline.py`:
```python
import hashlib
from datetime import datetime, timezone
```
Add these functions after `format_task()`:
```python
def compute_task_hash(task: dict) -> str:
"""Stable SHA-256 of the task's key change-detectable fields."""
key = "|".join([
str(task.get("status") or ""),
str(task.get("priority") or ""),
str(task.get("due_date") or ""),
str(task.get("title") or ""),
])
return hashlib.sha256(key.encode()).hexdigest()
async def split_changed_tasks(
user_id: int,
tasks: list[dict],
) -> tuple[list[dict], int]:
"""
Compare tasks against the briefing_task_snapshot table.
Returns (changed_tasks, unchanged_count).
changed_tasks includes new tasks (no snapshot row) and tasks whose hash differs.
"""
from sqlalchemy import select, text
from fabledassistant.models import async_session
if not tasks:
return [], 0
task_ids = [t["task_id"] for t in tasks if t.get("task_id")]
async with async_session() as session:
result = await session.execute(
text("""
SELECT task_id, snapshot_hash
FROM briefing_task_snapshot
WHERE user_id = :uid AND task_id = ANY(:ids)
""").bindparams(uid=user_id, ids=task_ids)
)
snapshots = {row.task_id: row.snapshot_hash for row in result}
changed = []
unchanged_count = 0
for task in tasks:
current_hash = compute_task_hash(task)
stored_hash = snapshots.get(task.get("task_id"))
if stored_hash is None or stored_hash != current_hash:
changed.append(task)
else:
unchanged_count += 1
return changed, unchanged_count
async def upsert_task_snapshots(user_id: int, tasks: list[dict]) -> None:
"""Upsert snapshot hashes for all tasks included in this briefing."""
from sqlalchemy import text
from fabledassistant.models import async_session
if not tasks:
return
now = datetime.now(timezone.utc)
async with async_session() as session:
for task in tasks:
task_id = task.get("task_id")
if not task_id:
continue
await session.execute(
text("""
INSERT INTO briefing_task_snapshot (user_id, task_id, snapshot_hash, last_briefed)
VALUES (:uid, :tid, :hash, :now)
ON CONFLICT (user_id, task_id)
DO UPDATE SET snapshot_hash = EXCLUDED.snapshot_hash,
last_briefed = EXCLUDED.last_briefed
""").bindparams(
uid=user_id,
tid=task_id,
hash=compute_task_hash(task),
now=now,
)
)
await session.commit()
```
- [ ] **Step 4: Update `_gather_internal` to include `task_id`**
In `_gather_internal`, change the task serialisation from:
```python
all_tasks = [
{
"title": t.title,
"status": t.status,
"due_date": t.due_date.isoformat() if t.due_date else None,
"priority": t.priority,
}
for t in all_task_objs
]
```
To:
```python
all_tasks = [
{
"task_id": t.id,
"title": t.title,
"status": t.status,
"due_date": t.due_date.isoformat() if t.due_date else None,
"priority": t.priority,
}
for t in all_task_objs
]
```
Also add `"all_tasks_raw": all_tasks` to the dict that `_gather_internal` returns, alongside the existing keys (`overdue_tasks`, `due_today`, etc.). This is the key `run_compilation` will use for snapshot diffing.
- [ ] **Step 5: Run tests**
```bash
docker compose exec app /opt/venv/bin/pytest tests/test_briefing_pipeline.py -v -k "task_snapshot or task_hash or split_changed"
```
Expected: all PASS.
- [ ] **Step 6: Commit**
```bash
git add src/fabledassistant/services/briefing_pipeline.py tests/test_briefing_pipeline.py
git commit -m "feat(briefing): add task change detection helpers and task_id to _gather_internal"
```
---
## Task 8: Wire Pre-processing into `run_compilation`
**Files:**
- Modify: `src/fabledassistant/services/briefing_pipeline.py`
- Modify: `src/fabledassistant/routes/briefing.py`
- Modify: `src/fabledassistant/services/briefing_scheduler.py` (read the file first to find all `run_compilation` calls)
- [ ] **Step 1: Read the briefing scheduler to find callers**
```bash
docker compose exec app grep -n "run_compilation\|post_message" src/fabledassistant/services/briefing_scheduler.py
```
Note every line number that calls `run_compilation` or `post_message` — you must update all of them in step 4.
- [ ] **Step 2: Update `_external_system_prompt` and `_external_user_prompt` for news cards**
In `_external_system_prompt()`, replace the current string with:
```python
def _external_system_prompt() -> str:
return (
"You are a briefing assistant for external information. Your job is to present "
"selected news items and summarise any remaining RSS content. "
"IMPORTANT: Weather is handled separately — do NOT include any weather section.\n\n"
"Format each news item EXACTLY as:\n"
"**[Headline text](source_url)**\n"
"*Outlet Name · Day Month*\n"
"One or two sentence summary.\n\n"
"Present news items in the EXACT ORDER they are provided. Do not reorder them. "
"After the news cards, add a brief paragraph for any remaining context."
)
```
- [ ] **Step 3: Rewrite `run_compilation` to add pre-processing and return `(text, metadata)`**
Replace the existing `run_compilation` function entirely:
```python
async def run_compilation(
user_id: int,
slot: str,
model: str | None = None,
) -> tuple[str, dict]:
"""
Run the full two-lane briefing pipeline for a user and slot.
Returns (briefing_text, metadata_dict) where metadata contains
weather card data and rss_item_ids for frontend rendering.
"""
if model is None:
model = await get_setting(user_id, "default_model", Config.OLLAMA_MODEL)
from fabledassistant.services.briefing_profile import get_profile_body
from fabledassistant.services.briefing_preferences import (
load_topic_preferences,
load_topic_reaction_scores,
score_and_filter_items,
)
from fabledassistant.services.weather import parse_weather_card_data
profile_body, temp_unit = await asyncio.gather(
get_profile_body(user_id),
_get_temp_unit(user_id),
)
# ── Pre-processing ──────────────────────────────────────────────────────
include_topics, exclude_topics = await load_topic_preferences(user_id)
topic_scores = await load_topic_reaction_scores(user_id)
from fabledassistant.services.weather import get_cached_weather_rows
# Parallel raw gather — include weather rows in the same gather to avoid a second DB round-trip
internal_data, external_data, weather_rows = await asyncio.gather(
_gather_internal(user_id),
_gather_external(user_id),
get_cached_weather_rows(user_id),
)
# Task change detection — uses the raw task dicts added in Task 7
all_tasks = internal_data.get("all_tasks_raw", [])
changed_tasks, unchanged_count = await split_changed_tasks(user_id, all_tasks)
# RSS filtering
raw_rss = external_data.get("rss_items") or []
filtered_rss = score_and_filter_items(
raw_rss,
include_topics=include_topics,
exclude_topics=exclude_topics,
topic_scores=topic_scores,
max_items=10,
)
rss_item_ids = [item["id"] for item in filtered_rss if item.get("id")]
# Weather staleness gate — parse_weather_card_data returns None if data is >24h old
weather_card = parse_weather_card_data(weather_rows[0], temp_unit) if weather_rows else None
# ── LLM Synthesis ──────────────────────────────────────────────────────
# Rebuild internal_data with changed tasks only
internal_data_filtered = dict(internal_data)
internal_data_filtered["unchanged_task_count"] = unchanged_count
# Replace task lists with only changed tasks (formatted)
today = internal_data["date"]
changed_formatted = [format_task(t) for t in changed_tasks]
internal_data_filtered["overdue_tasks"] = [
f for f in changed_formatted
if any(t.get("due_date") and t["due_date"] < today and t.get("status") != "done"
for t in changed_tasks if format_task(t) == f)
]
# Simplified: pass all changed tasks, let the LLM sort by urgency
internal_data_filtered["changed_tasks"] = changed_formatted
# Build filtered external data (no weather — card handles it)
external_data_filtered = {
"rss_items": filtered_rss,
"weather": [], # Suppressed — handled by WeatherCard
}
internal_text, external_text = await asyncio.gather(
_llm_synthesise(
_internal_system_prompt(profile_body),
_internal_user_prompt(internal_data_filtered, slot),
model,
),
_llm_synthesise(
_external_system_prompt(),
_external_user_prompt(external_data_filtered, slot, temp_unit),
model,
),
)
# ── Post-processing ────────────────────────────────────────────────────
# Upsert task snapshots so next run can diff
await upsert_task_snapshots(user_id, all_tasks)
# Build metadata
metadata: dict = {"rss_item_ids": rss_item_ids, "weather": weather_card}
# Build output text
if not internal_text and not external_text:
logger.warning(
"Briefing compilation produced no content for user %d slot %s", user_id, slot
)
return "", metadata
greeting = slot_greeting(slot)
parts = [f"**{greeting}{today}**", ""]
if internal_text:
parts += ["## Your Day", "", internal_text, ""]
if external_text:
parts += ["## The World", "", external_text]
return "\n".join(parts).strip(), metadata
```
> **Note:** `_gather_internal` needs to also return the raw task objects (with `task_id`) for snapshot diffing. See step below.
- [ ] **Step 4: Add `get_cached_weather_rows()` to `services/weather.py`**
The new pipeline needs the raw `WeatherCache` ORM rows (not the processed dicts) so it can call `parse_weather_card_data()`. Add this function to `weather.py`:
```python
async def get_cached_weather_rows(user_id: int) -> list:
"""Return raw WeatherCache ORM rows for a user (for card parsing)."""
async with async_session() as session:
result = await session.execute(
select(WeatherCache).where(WeatherCache.user_id == user_id)
)
return list(result.scalars().all())
```
- [ ] **Step 5: Update `_internal_user_prompt` to handle changed tasks**
The prompt currently references `overdue_tasks`, `due_today`, `high_priority`. Add an `unchanged_task_count` line:
```python
def _internal_user_prompt(data: dict, slot: str) -> str:
lines = [f"Briefing slot: {slot}", f"Date: {data['date']}", ""]
if data.get("unchanged_task_count", 0) > 0:
lines.append(
f"({data['unchanged_task_count']} tasks are unchanged since the last briefing "
"— acknowledge briefly, do not list them.)"
)
lines.append("")
# ... rest unchanged
```
- [ ] **Step 6: Update `routes/briefing.py` trigger endpoint**
In `manual_trigger()`, unpack the tuple:
```python
text, metadata = await run_compilation(g.user.id, slot, model)
msg = await post_message(conv.id, "assistant", text, metadata=metadata)
```
- [ ] **Step 7: Update `services/briefing_scheduler.py`**
Find all calls to `run_compilation` (from step 1) and unpack the returned tuple, passing `metadata` to `post_message`. The pattern will be the same as step 6.
- [ ] **Step 8: Run the full test suite**
```bash
make test
```
Expected: all existing tests still PASS (no regressions).
- [ ] **Step 9: Commit**
```bash
git add src/fabledassistant/services/briefing_pipeline.py \
src/fabledassistant/services/weather.py \
src/fabledassistant/routes/briefing.py \
src/fabledassistant/services/briefing_scheduler.py
git commit -m "feat(briefing): wire pre-processing pipeline; run_compilation now returns (text, metadata)"
```
---
## Task 9: Trigger RSS Classification from `rss.py`
**Files:**
- Modify: `src/fabledassistant/services/rss.py`
- [ ] **Step 1: Find the `fetch_and_cache_feed` function and add classification trigger**
After `await _prune_old_items(feed_id)` (the last line of `fetch_and_cache_feed`), add:
```python
# Queue classification for newly stored items if any were added
if new_count > 0 and new_item_ids:
import asyncio as _asyncio
from fabledassistant.services.rss_classifier import classify_and_store
# Fire-and-forget — don't await, don't block feed fetch
_asyncio.create_task(classify_and_store(new_item_ids, _feed_user_id))
return new_count
```
To make this work, you need to:
1. Collect `new_item_ids` during the loop (after `session.commit()`, refresh the items to get their DB-assigned IDs, or collect IDs inline)
2. Expose `_feed_user_id` by fetching it from the `RssFeed` row
Update `fetch_and_cache_feed` to capture new item IDs and the feed's `user_id`:
```python
async def fetch_and_cache_feed(feed_id: int, url: str) -> int:
# ... existing fetch/parse code ...
new_count = 0
new_item_ids: list[int] = []
feed_user_id: int | None = None
async with async_session() as session:
for entry in parsed.entries:
# ... existing upsert code ...
item = RssItem(feed_id=feed_id, **item_data)
session.add(item)
new_count += 1
feed_row = await session.get(RssFeed, feed_id)
if feed_row:
feed_row.last_fetched_at = datetime.now(timezone.utc)
feed_user_id = feed_row.user_id
if not feed_row.title and parsed.feed.get("title"):
feed_row.title = parsed.feed.title[:200]
await session.commit()
# Collect IDs of newly inserted items after commit.
# We query classified_at IS NULL (not just the items inserted above) because
# classification is best-effort and may have failed on previous fetches.
# Re-queuing all unclassified items for this feed on each fetch is intentional:
# it provides automatic retry without a separate retry loop. The classifier
# only writes to items it successfully classifies, so already-classified items
# are not re-processed (they have classified_at set).
if new_count > 0:
result = await session.execute(
select(RssItem.id).where(
RssItem.feed_id == feed_id,
RssItem.classified_at.is_(None),
)
)
new_item_ids = list(result.scalars().all())
await _prune_old_items(feed_id)
if new_count > 0 and new_item_ids and feed_user_id is not None:
import asyncio as _asyncio
from fabledassistant.services.rss_classifier import classify_and_store
_asyncio.create_task(classify_and_store(new_item_ids, feed_user_id))
return new_count
```
- [ ] **Step 2: Run the test suite**
```bash
make test
```
Expected: all PASS (no regressions — classification is fire-and-forget so existing tests are unaffected).
- [ ] **Step 3: Commit**
```bash
git add src/fabledassistant/services/rss.py
git commit -m "feat(briefing): trigger RSS classification after new items are stored"
```
---
## Task 10: Reaction Endpoints
**Files:**
- Modify: `src/fabledassistant/routes/briefing.py`
- [ ] **Step 1: Add reaction endpoints at the bottom of `routes/briefing.py`**
```python
# ── RSS Reactions ─────────────────────────────────────────────────────────────
@briefing_bp.route("/rss-reactions", methods=["POST"])
@_REQUIRE
async def upsert_rss_reaction():
"""Upsert a 👍/👎 reaction on an RSS item. Same reaction toggles off; opposite flips."""
data = await request.get_json()
rss_item_id = data.get("rss_item_id")
reaction = data.get("reaction")
if not rss_item_id or reaction not in ("up", "down"):
return jsonify({"error": "rss_item_id and reaction ('up'|'down') required"}), 400
from sqlalchemy import text as _text
async with async_session() as session:
# Ownership check: verify item belongs to a feed owned by this user
result = await session.execute(
_text("""
SELECT i.id FROM rss_items i
JOIN rss_feeds f ON f.id = i.feed_id
WHERE i.id = :item_id AND f.user_id = :uid
""").bindparams(item_id=rss_item_id, uid=g.user.id)
)
if result.first() is None:
return jsonify({"error": "Not found"}), 404
# Check existing reaction
existing = await session.execute(
_text("""
SELECT id, reaction FROM rss_item_reactions
WHERE user_id = :uid AND rss_item_id = :item_id
""").bindparams(uid=g.user.id, item_id=rss_item_id)
)
row = existing.first()
if row is None:
# Insert
await session.execute(
_text("""
INSERT INTO rss_item_reactions (user_id, rss_item_id, reaction)
VALUES (:uid, :item_id, :reaction)
""").bindparams(uid=g.user.id, item_id=rss_item_id, reaction=reaction)
)
action = "created"
elif row.reaction == reaction:
# Toggle off
await session.execute(
_text("""
DELETE FROM rss_item_reactions
WHERE user_id = :uid AND rss_item_id = :item_id
""").bindparams(uid=g.user.id, item_id=rss_item_id)
)
action = "removed"
else:
# Flip
await session.execute(
_text("""
UPDATE rss_item_reactions SET reaction = :reaction
WHERE user_id = :uid AND rss_item_id = :item_id
""").bindparams(reaction=reaction, uid=g.user.id, item_id=rss_item_id)
)
action = "updated"
await session.commit()
return jsonify({"ok": True, "action": action})
@briefing_bp.route("/rss-reactions/<int:item_id>", methods=["DELETE"])
@_REQUIRE
async def delete_rss_reaction(item_id: int):
"""Explicitly remove a reaction (useful for MCP/external API callers)."""
from sqlalchemy import text as _text
async with async_session() as session:
await session.execute(
_text("""
DELETE FROM rss_item_reactions
WHERE user_id = :uid AND rss_item_id = :item_id
""").bindparams(uid=g.user.id, item_id=item_id)
)
await session.commit()
return jsonify({"ok": True})
```
- [ ] **Step 2: Run the test suite**
```bash
make test
```
Expected: all PASS.
- [ ] **Step 3: Commit**
```bash
git add src/fabledassistant/routes/briefing.py
git commit -m "feat(briefing): add POST/DELETE /api/briefing/rss-reactions endpoints"
```
---
## Task 11: Frontend API Helpers
**Files:**
- Modify: `frontend/src/api/client.ts`
- [ ] **Step 1: Add the two new API helpers to `client.ts`**
Find the section at the end of `client.ts` where other helpers are defined and add:
```typescript
export async function postRssReaction(
rssItemId: number,
reaction: 'up' | 'down'
): Promise<{ ok: boolean; action: string }> {
return apiPost('/api/briefing/rss-reactions', { rss_item_id: rssItemId, reaction });
}
export async function deleteRssReaction(rssItemId: number): Promise<{ ok: boolean }> {
return apiDelete(`/api/briefing/rss-reactions/${rssItemId}`);
}
```
- [ ] **Step 2: Run TypeScript check**
```bash
make typecheck
```
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/api/client.ts
git commit -m "feat(briefing): add postRssReaction and deleteRssReaction API helpers"
```
---
## Task 12: WeatherCard.vue
**Files:**
- Create: `frontend/src/components/WeatherCard.vue`
- [ ] **Step 1: Create the component**
```vue
<script setup lang="ts">
import { computed } from 'vue'
interface ForecastDay {
day: string
condition: string
high: number
low: number
}
interface WeatherData {
location: string
fetched_at: string
current_temp: number
condition: string
today_high: number | null
today_low: number | null
yesterday_high: number | null
yesterday_low: number | null
forecast: ForecastDay[]
}
const props = defineProps<{
weather: WeatherData | null
tempUnit?: string
}>()
const unit = computed(() => props.tempUnit ?? 'C')
const tempDelta = computed(() => {
const w = props.weather
if (!w || w.today_high == null || w.yesterday_high == null) return null
const diff = w.today_high - w.yesterday_high
if (Math.abs(diff) < 1) return 'Same as yesterday'
const dir = diff > 0 ? 'warmer' : 'cooler'
return `${Math.abs(diff)}° ${dir} than yesterday`
})
const fetchedAtLabel = computed(() => {
if (!props.weather?.fetched_at) return ''
try {
return new Date(props.weather.fetched_at).toLocaleTimeString([], {
hour: '2-digit',
minute: '2-digit',
})
} catch {
return ''
}
})
</script>
<template>
<div v-if="weather" class="weather-card">
<div class="weather-header">
<span class="weather-location">{{ weather.location }}</span>
<span class="weather-fetched-at">as of {{ fetchedAtLabel }}</span>
</div>
<div class="weather-current">
<span class="weather-temp">{{ weather.current_temp }}°{{ unit }}</span>
<span class="weather-condition">{{ weather.condition }}</span>
</div>
<div class="weather-today" v-if="weather.today_high != null">
Today: {{ weather.today_high }}° / {{ weather.today_low }}°
<span v-if="tempDelta" class="weather-delta"> · {{ tempDelta }}</span>
</div>
<div class="weather-forecast" v-if="weather.forecast.length">
<div v-for="day in weather.forecast" :key="day.day" class="weather-forecast-day">
<span class="forecast-day-name">{{ day.day }}</span>
<span class="forecast-condition">{{ day.condition }}</span>
<span class="forecast-temps">{{ day.high }}° / {{ day.low }}°</span>
</div>
</div>
</div>
<div v-else class="weather-card weather-unavailable">
Weather data unavailable will retry at next slot.
</div>
</template>
<style scoped>
.weather-card {
background: color-mix(in srgb, var(--color-surface) 80%, transparent);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 1rem 1.25rem;
margin-bottom: 1rem;
font-size: 0.9rem;
}
.weather-header {
display: flex;
justify-content: space-between;
align-items: baseline;
margin-bottom: 0.5rem;
}
.weather-location {
font-weight: 600;
font-size: 0.95rem;
}
.weather-fetched-at {
color: var(--color-text-muted);
font-size: 0.78rem;
}
.weather-current {
display: flex;
align-items: baseline;
gap: 0.75rem;
margin-bottom: 0.35rem;
}
.weather-temp {
font-size: 1.8rem;
font-weight: 700;
line-height: 1;
}
.weather-condition {
color: var(--color-text-muted);
}
.weather-today {
color: var(--color-text-secondary);
margin-bottom: 0.75rem;
}
.weather-delta {
color: var(--color-text-muted);
font-size: 0.85rem;
}
.weather-forecast {
display: flex;
gap: 0.5rem;
overflow-x: auto;
padding-top: 0.5rem;
border-top: 1px solid var(--color-border);
}
.weather-forecast-day {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.2rem;
min-width: 3.5rem;
font-size: 0.8rem;
}
.forecast-day-name {
font-weight: 600;
}
.forecast-condition {
color: var(--color-text-muted);
font-size: 0.75rem;
text-align: center;
}
.forecast-temps {
white-space: nowrap;
}
.weather-unavailable {
color: var(--color-text-muted);
font-style: italic;
}
</style>
```
- [ ] **Step 2: TypeScript check**
```bash
make typecheck
```
Expected: no errors.
- [ ] **Step 3: Commit**
```bash
git add frontend/src/components/WeatherCard.vue
git commit -m "feat(briefing): add WeatherCard.vue component"
```
---
## Task 13: BriefingView.vue — Metadata Integration
**Files:**
- Modify: `frontend/src/views/BriefingView.vue`
- [ ] **Step 1: Read the current `BriefingView.vue` to understand its structure**
```bash
docker compose exec app cat frontend/src/views/BriefingView.vue | head -100
```
Or use the Read tool. Understand: how messages are loaded, how they are rendered, where to insert the WeatherCard.
- [ ] **Step 2: Add WeatherCard import and type definitions**
At the top of `<script setup>`, add:
```typescript
import WeatherCard from '@/components/WeatherCard.vue'
```
Add a type for the message metadata:
```typescript
interface MessageMetadata {
weather?: {
location: string
fetched_at: string
current_temp: number
condition: string
today_high: number | null
today_low: number | null
yesterday_high: number | null
yesterday_low: number | null
forecast: { day: string; condition: string; high: number; low: number }[]
} | null
rss_item_ids?: number[]
}
```
- [ ] **Step 3: Add reaction state**
```typescript
// Reaction state: map of rss_item_id -> 'up' | 'down' | null
const reactions = ref<Record<number, 'up' | 'down' | null>>({})
async function handleReaction(itemId: number, reaction: 'up' | 'down') {
const current = reactions.value[itemId]
// Optimistic update
reactions.value[itemId] = current === reaction ? null : reaction
try {
if (current === reaction) {
await deleteRssReaction(itemId)
} else {
await postRssReaction(itemId, reaction)
}
} catch {
// Revert on error
reactions.value[itemId] = current ?? null
}
}
```
Import `postRssReaction` and `deleteRssReaction` from `@/api/client`.
- [ ] **Step 4: Add WeatherCard and reaction buttons to the briefing message template**
In the template, for each assistant message in the briefing conversation, replace or augment the existing message rendering with:
```vue
<template v-for="msg in messages" :key="msg.id">
<!-- Weather card: render above the first assistant message that has weather metadata -->
<WeatherCard
v-if="msg.role === 'assistant' && msg.metadata?.weather !== undefined"
:weather="msg.metadata?.weather ?? null"
/>
<!-- Existing message rendering -->
<div class="message" :class="msg.role">
<!-- ... existing content ... -->
</div>
<!-- Reaction buttons: shown below the first assistant message with rss_item_ids -->
<div
v-if="msg.role === 'assistant' && msg.metadata?.rss_item_ids?.length"
class="rss-reactions"
>
<div
v-for="(itemId, index) in msg.metadata.rss_item_ids"
:key="itemId"
class="rss-reaction-row"
>
<span class="reaction-label">Story {{ index + 1 }}</span>
<button
class="reaction-btn"
:class="{ active: reactions[itemId] === 'up' }"
@click="handleReaction(itemId, 'up')"
title="Interested"
>👍</button>
<button
class="reaction-btn"
:class="{ active: reactions[itemId] === 'down' }"
@click="handleReaction(itemId, 'down')"
title="Not interested"
>👎</button>
</div>
</div>
</template>
```
- [ ] **Step 5: TypeScript check**
```bash
make typecheck
```
Expected: no errors.
- [ ] **Step 6: Commit**
```bash
git add frontend/src/views/BriefingView.vue
git commit -m "feat(briefing): render WeatherCard and RSS reaction buttons from message metadata"
```
---
## Task 14: SettingsView.vue — News Preferences
**Files:**
- Modify: `frontend/src/views/SettingsView.vue`
- [ ] **Step 1: Add reactive state for topic preferences in the briefing tab**
Find where the briefing tab's settings are loaded (search for `briefing` in the `<script setup>`). Add:
```typescript
const briefingIncludeTopics = ref<string[]>([])
const briefingExcludeTopics = ref<string[]>([])
// Load alongside existing briefing settings
async function loadBriefingSettings() {
// ... existing load code ...
const inc = await apiGet<string[]>('/api/settings/briefing_include_topics').catch(() => [])
const exc = await apiGet<string[]>('/api/settings/briefing_exclude_topics').catch(() => [])
briefingIncludeTopics.value = Array.isArray(inc) ? inc : JSON.parse(inc as string || '[]')
briefingExcludeTopics.value = Array.isArray(exc) ? exc : JSON.parse(exc as string || '[]')
}
async function saveIncludeTopics(topics: string[]) {
await apiPut('/api/settings/briefing_include_topics', JSON.stringify(topics))
briefingIncludeTopics.value = topics
}
async function saveExcludeTopics(topics: string[]) {
await apiPut('/api/settings/briefing_exclude_topics', JSON.stringify(topics))
briefingExcludeTopics.value = topics
}
```
- [ ] **Step 2: Add the "News Preferences" UI section to the briefing tab template**
Find the RSS feed management section in the briefing tab and add below it:
```vue
<div class="settings-section">
<h3>News Preferences</h3>
<p class="settings-hint">
Tell the briefing what topics you care about. Topics are matched against
classified RSS items before each briefing runs.
</p>
<div class="settings-field">
<label>Interested in</label>
<TagInput
:model-value="briefingIncludeTopics"
placeholder="e.g. technology, science, local"
@update:model-value="saveIncludeTopics"
/>
</div>
<div class="settings-field">
<label>Not interested in</label>
<TagInput
:model-value="briefingExcludeTopics"
placeholder="e.g. sports, celebrity"
@update:model-value="saveExcludeTopics"
/>
</div>
<details class="topic-vocab-hint">
<summary>Standard topic vocabulary</summary>
<p class="topic-vocab-list">
technology · science · politics · business · health · environment ·
local · entertainment · sports · other
</p>
<p class="settings-hint">Custom terms are also accepted.</p>
</details>
</div>
```
Import `TagInput` if not already imported.
- [ ] **Step 3: TypeScript check**
```bash
make typecheck
```
Expected: no errors.
- [ ] **Step 4: Commit**
```bash
git add frontend/src/views/SettingsView.vue
git commit -m "feat(briefing): add News Preferences section with topic include/exclude inputs"
```
---
## Task 15: MCP Briefing Tools
**Files:**
- Create: `fable-mcp/fable_mcp/tools/briefing.py`
- Modify: `fable-mcp/fable_mcp/server.py`
- [ ] **Step 1: Create `fable-mcp/fable_mcp/tools/briefing.py`**
```python
"""MCP tools for Fable RSS feed management."""
from __future__ import annotations
from typing import Any
from fable_mcp.client import FableClient
async def list_rss_feeds(client: FableClient) -> dict[str, Any]:
"""List the user's RSS feeds."""
return await client.get("/api/briefing/feeds")
async def add_rss_feed(
client: FableClient,
*,
url: str,
title: str | None = None,
category: str | None = None,
) -> dict[str, Any]:
"""Add a new RSS feed. Title is optional — auto-populated from feed metadata."""
payload: dict[str, Any] = {"url": url}
if title:
payload["title"] = title
if category:
payload["category"] = category
return await client.post("/api/briefing/feeds", json=payload)
async def remove_rss_feed(client: FableClient, *, feed_id: int) -> dict[str, Any]:
"""Remove an RSS feed by ID."""
return await client.delete(f"/api/briefing/feeds/{feed_id}")
```
- [ ] **Step 2: Register the tools in `server.py`**
Add to the import line at the top:
```python
from fable_mcp.tools import notes, tasks, projects, milestones, search, chat, admin, briefing
```
Add a new section at the end (before the Entry point section):
```python
# ---------------------------------------------------------------------------
# Briefing / RSS
# ---------------------------------------------------------------------------
@mcp.tool()
async def fable_list_rss_feeds() -> dict:
"""List all RSS feeds configured in Fable for the current user."""
async with FableClient() as client:
return await briefing.list_rss_feeds(client)
@mcp.tool()
async def fable_add_rss_feed(
url: str,
title: str = "",
category: str = "",
) -> dict:
"""Add a new RSS feed to Fable. Title is optional — auto-populated from feed metadata.
Args:
url: The RSS/Atom feed URL.
title: Optional display name override.
category: Optional category label (e.g. 'news', 'tech').
"""
async with FableClient() as client:
return await briefing.add_rss_feed(
client,
url=url,
title=title or None,
category=category or None,
)
@mcp.tool()
async def fable_remove_rss_feed(feed_id: int) -> dict:
"""Remove an RSS feed from Fable by its ID."""
async with FableClient() as client:
return await briefing.remove_rss_feed(client, feed_id=feed_id)
```
- [ ] **Step 3: Test the MCP tools import**
```bash
docker compose exec app python -c "from fable_mcp.tools import briefing; print('OK')"
```
Expected: `OK`
- [ ] **Step 4: TypeScript check + full test suite**
```bash
make check
```
Expected: all checks PASS.
- [ ] **Step 5: Commit**
```bash
git add fable-mcp/fable_mcp/tools/briefing.py fable-mcp/fable_mcp/server.py
git commit -m "feat(fable-mcp): add RSS feed management tools (list/add/remove)"
```
---
## Final Integration Check
- [ ] **Rebuild the full stack**
```bash
docker compose down && docker compose up --build
```
- [ ] **Verify migration ran**
```bash
docker compose exec app alembic current
```
Expected: shows `0028`.
- [ ] **Manually trigger a briefing compilation to verify metadata is stored**
```bash
curl -s -X POST http://localhost:5000/api/briefing/trigger \
-H "Cookie: <your session cookie>" \
-H "Content-Type: application/json" \
-d '{"slot": "compilation"}' | python -m json.tool
```
Check the returned `message_id`, then:
```bash
curl -s http://localhost:5000/api/briefing/conversations/today \
-H "Cookie: <your session cookie>" | python -m json.tool | grep metadata
```
Expected: `"metadata"` key present on the assistant message with `weather` and `rss_item_ids` fields.
- [ ] **Open the briefing view in the browser**
Navigate to `/briefing`. Verify:
1. `WeatherCard` renders above the message text (or shows failure placeholder)
2. 👍/👎 buttons appear below each news card
3. Settings → Briefing → "News Preferences" section visible with two tag inputs
- [ ] **Final commit**
```bash
git add -A
git commit -m "chore(briefing): final integration — all briefing improvements complete"
```