Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
67 KiB
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.pysrc/fabledassistant/services/rss_classifier.pysrc/fabledassistant/services/briefing_preferences.py
Modified backend files
src/fabledassistant/models/conversation.py— addMessage.metadataJSONB column +to_dict()src/fabledassistant/models/rss_feed.py— addRssItem.topics,RssItem.classified_atsrc/fabledassistant/services/briefing_conversations.py—post_message(... metadata=None)src/fabledassistant/services/weather.py— addcurrent_weather=true,past_days=1,parse_weather_card_data()src/fabledassistant/services/rss.py— trigger classification after storing new itemssrc/fabledassistant/services/briefing_pipeline.py— pre-processing stage, task snapshot helpers, return(text, metadata)fromrun_compilationsrc/fabledassistant/routes/briefing.py— unpack tuple fromrun_compilation; add reaction endpoints
New frontend files
frontend/src/components/WeatherCard.vue
Modified frontend files
frontend/src/api/client.ts— addpostRssReaction(),deleteRssReaction()frontend/src/views/BriefingView.vue— renderWeatherCard, reaction buttons from metadatafrontend/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 teststests/test_weather_service.py— extend with card parser teststests/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
"""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
docker compose exec app alembic upgrade head
Expected: Running upgrade 0027 -> 0028, Add briefing improvements
- Step 3: Commit
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
metadatacolumn toMessageinmodels/conversation.py
In the Message class, after the existing tool_calls column, add:
metadata: Mapped[dict | None] = mapped_column(JSONB, nullable=True)
In Message.to_dict(), add "metadata": self.metadata to the returned dict.
- Step 2: Add
topicsandclassified_atcolumns toRssIteminmodels/rss_feed.py
Add imports at the top (if not already present):
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:
from sqlalchemy import ARRAY, Text
In the RssItem class, after fetched_at, add:
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:
"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
docker compose up --build -d app
- Step 4: Write a quick smoke test (no DB needed)
In tests/test_briefing_models.py, add:
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
docker compose exec app /opt/venv/bin/pytest tests/test_briefing_models.py -v
Expected: both new tests PASS.
- Step 6: Commit
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:
@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
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:
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:
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
docker compose exec app /opt/venv/bin/pytest tests/test_briefing_pipeline.py::test_post_message_accepts_metadata -v
Expected: PASS.
- Step 5: Commit
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:
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
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_meteoto request current weather + past day
In services/weather.py, update _fetch_open_meteo:
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()toservices/weather.py
Add after detect_changes():
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
docker compose exec app /opt/venv/bin/pytest tests/test_weather_service.py -v -k "parse_weather_card"
Expected: both PASS.
- Step 6: Commit
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:
@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
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
"""
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
docker compose exec app /opt/venv/bin/pytest tests/test_rss_service.py -v -k "classify"
Expected: both PASS.
- Step 5: Commit
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_itemsexpects items to have atopicskey in their dicts, which comes from theRssItem.topicscolumn 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:
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
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
"""
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
docker compose exec app /opt/venv/bin/pytest tests/test_rss_service.py -v -k "score_rss"
Expected: all PASS.
- Step 5: Commit
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:
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
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:
import hashlib
from datetime import datetime, timezone
Add these functions after format_task():
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_internalto includetask_id
In _gather_internal, change the task serialisation from:
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:
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
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
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 allrun_compilationcalls) -
Step 1: Read the briefing scheduler to find callers
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_promptand_external_user_promptfor news cards
In _external_system_prompt(), replace the current string with:
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_compilationto add pre-processing and return(text, metadata)
Replace the existing run_compilation function entirely:
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_internalneeds to also return the raw task objects (withtask_id) for snapshot diffing. See step below.
- Step 4: Add
get_cached_weather_rows()toservices/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:
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_promptto handle changed tasks
The prompt currently references overdue_tasks, due_today, high_priority. Add an unchanged_task_count line:
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.pytrigger endpoint
In manual_trigger(), unpack the tuple:
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
make test
Expected: all existing tests still PASS (no regressions).
- Step 9: Commit
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_feedfunction and add classification trigger
After await _prune_old_items(feed_id) (the last line of fetch_and_cache_feed), add:
# 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:
- Collect
new_item_idsduring the loop (aftersession.commit(), refresh the items to get their DB-assigned IDs, or collect IDs inline) - Expose
_feed_user_idby fetching it from theRssFeedrow
Update fetch_and_cache_feed to capture new item IDs and the feed's user_id:
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
make test
Expected: all PASS (no regressions — classification is fire-and-forget so existing tests are unaffected).
- Step 3: Commit
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
# ── 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
make test
Expected: all PASS.
- Step 3: Commit
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:
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
make typecheck
Expected: no errors.
- Step 3: Commit
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
<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
make typecheck
Expected: no errors.
- Step 3: Commit
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.vueto understand its structure
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:
import WeatherCard from '@/components/WeatherCard.vue'
Add a type for the message metadata:
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
// 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:
<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
make typecheck
Expected: no errors.
- Step 6: Commit
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:
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:
<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
make typecheck
Expected: no errors.
- Step 4: Commit
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
"""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:
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):
# ---------------------------------------------------------------------------
# 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
docker compose exec app python -c "from fable_mcp.tools import briefing; print('OK')"
Expected: OK
- Step 4: TypeScript check + full test suite
make check
Expected: all checks PASS.
- Step 5: Commit
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
docker compose down && docker compose up --build
- Verify migration ran
docker compose exec app alembic current
Expected: shows 0028.
- Manually trigger a briefing compilation to verify metadata is stored
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:
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:
WeatherCardrenders above the message text (or shows failure placeholder)- 👍/👎 buttons appear below each news card
- Settings → Briefing → "News Preferences" section visible with two tag inputs
- Final commit
git add -A
git commit -m "chore(briefing): final integration — all briefing improvements complete"