24bd80b5d7
Covers task deduplication, RSS classification and preference filtering, weather card with staleness gate, news cards with reactions, topic preference settings UI, and Fable MCP RSS feed tools. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
286 lines
14 KiB
Markdown
286 lines
14 KiB
Markdown
# Briefing Service Improvements — Design Spec
|
||
|
||
**Date:** 2026-03-25
|
||
**Status:** Approved
|
||
**Scope:** Web-first (no Android changes in this cycle)
|
||
|
||
---
|
||
|
||
## Problem Statement
|
||
|
||
The daily briefing has several usability issues:
|
||
|
||
1. **Task repetition** — tasks are restated identically every day regardless of whether anything changed, making the briefing feel stale and hard to scan.
|
||
2. **RSS repetition** — the same news stories resurface across days with no mechanism to learn what the user cares about.
|
||
3. **No path to sources** — news items are summarised in prose with no link to the original article.
|
||
4. **Stale weather** — if the weather cache is outdated, the briefing silently uses old data rather than failing gracefully. The current prose weather format is also hard to scan.
|
||
5. **No feedback loop** — there is no way to teach the briefing what topics are interesting or uninteresting.
|
||
|
||
---
|
||
|
||
## Approach
|
||
|
||
**Pre-processing pipeline with explicit state tracking.** Rather than relying on the synthesis LLM to handle deduplication and filtering, we add deterministic pre-processing steps before synthesis runs. Each concern is isolated: task change detection, RSS topic classification and filtering, weather staleness gating. The synthesis LLM receives pre-filtered, structured input and focuses on tone and flow.
|
||
|
||
---
|
||
|
||
## Data Model
|
||
|
||
### Migration: `0028_add_briefing_improvements`
|
||
|
||
**`rss_items` table — two new columns**
|
||
|
||
```sql
|
||
ALTER TABLE rss_items ADD COLUMN IF NOT EXISTS topics TEXT[] DEFAULT '{}';
|
||
ALTER TABLE rss_items ADD COLUMN IF NOT EXISTS classified_at TIMESTAMPTZ;
|
||
```
|
||
|
||
`topics` stores LLM-assigned topic tags (e.g. `["technology", "ai"]`). `classified_at` is NULL until classification runs, allowing backfill queries. The `RssFeed` / `RssItem` SQLAlchemy model (`models/rss_feed.py`) must also be updated to add these two mapped columns and expose them in `to_dict()`.
|
||
|
||
**`messages` table — new metadata column**
|
||
|
||
```sql
|
||
ALTER TABLE messages ADD COLUMN IF NOT EXISTS metadata JSONB;
|
||
```
|
||
|
||
The briefing pipeline populates this when it creates the compiled message. The frontend reads it when loading the conversation to render the `WeatherCard` and attach reaction buttons. No SSE events are needed — structured data travels with the message record.
|
||
|
||
Schema stored in `metadata`:
|
||
```json
|
||
{
|
||
"weather": {
|
||
"location": "Berlin",
|
||
"fetched_at": "2026-03-25T06:00:00Z",
|
||
"current_temp": 12,
|
||
"condition": "Partly Cloudy",
|
||
"today_high": 16,
|
||
"today_low": 8,
|
||
"yesterday_high": 14,
|
||
"yesterday_low": 9,
|
||
"forecast": [
|
||
{"day": "Wed", "condition": "Sunny", "high": 18, "low": 10},
|
||
{"day": "Thu", "condition": "Cloudy", "high": 14, "low": 9}
|
||
]
|
||
},
|
||
"rss_item_ids": [42, 17, 89, 103, 55]
|
||
}
|
||
```
|
||
|
||
If weather is unavailable, `metadata.weather` is `null` and the card renders a failure placeholder. The `Message` SQLAlchemy model (`models/conversation.py`) must be updated to add the `metadata` mapped column.
|
||
|
||
**New table: `rss_item_reactions`**
|
||
|
||
```sql
|
||
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)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS ix_rss_item_reactions_user_id ON rss_item_reactions(user_id);
|
||
```
|
||
|
||
One reaction per user per item. A second click on the same button removes the reaction; clicking the opposite button flips it.
|
||
|
||
**New table: `briefing_task_snapshot`**
|
||
|
||
```sql
|
||
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)
|
||
);
|
||
CREATE INDEX IF NOT EXISTS ix_briefing_task_snapshot_user_id ON briefing_task_snapshot(user_id);
|
||
```
|
||
|
||
`snapshot_hash` is `SHA-256(status + priority + due_date + title)`. The pipeline diffs current task state against these rows to detect what has changed since the last briefing.
|
||
|
||
**Settings keys (existing key-value store — no new table)**
|
||
|
||
- `briefing_include_topics` — JSON array of topic strings to prioritise
|
||
- `briefing_exclude_topics` — JSON array of topics to hard-exclude from briefings
|
||
|
||
---
|
||
|
||
## Pipeline Changes
|
||
|
||
### Pre-processing Stage (new, runs before parallel gather)
|
||
|
||
Three sequential steps added to `services/briefing_pipeline.py`. Note: `_gather_internal` currently serialises tasks as dicts without `id`. It must be updated to include `task_id` in each serialised task dict (the `Note` ORM object has `.id` available) so the post-briefing snapshot upsert has the required FK value.
|
||
|
||
**1. Task change detection**
|
||
|
||
For each of the user's current tasks, compute `SHA-256(status + priority + due_date + title)` and compare against `briefing_task_snapshot`. Split into:
|
||
- `changed_tasks` — new hash or no snapshot row (included fully in briefing)
|
||
- `unchanged_count` — integer count passed to the synthesis prompt as context
|
||
|
||
The synthesis prompt receives `changed_tasks` and the instruction: "N tasks are unchanged since the last briefing — acknowledge this briefly rather than listing them."
|
||
|
||
**2. RSS item filtering**
|
||
|
||
Load the user's `briefing_include_topics` and `briefing_exclude_topics` settings, plus reaction history (last 30 days, aggregated per topic as a net score). Score each recent classified item:
|
||
- Hard-remove items tagged with any excluded topic
|
||
- Boost items tagged with any included or positively-reacted topic
|
||
- Penalise items from negatively-reacted topics
|
||
- Sort by score, take top 10
|
||
|
||
Items with `classified_at IS NULL` pass through unfiltered (new feeds not yet classified) and are queued for background classification.
|
||
|
||
**3. Weather staleness gate**
|
||
|
||
Check `weather_cache.fetched_at`. If older than 24 hours: skip weather entirely, set `weather_unavailable = True`. The frontend renders a `WeatherCard` placeholder in the failure state. If fresh: pass the forecast JSON (including `past_days=1` data) to the pipeline for card rendering.
|
||
|
||
### RSS Classification (background, triggered at fetch time)
|
||
|
||
When `services/rss.py` stores new items, it queues a fire-and-forget async task to classify them. Classification is a fast, non-streaming LLM call processing batches of up to 10 items:
|
||
|
||
```
|
||
Classify each news item into 1-3 topics from this vocabulary:
|
||
technology, science, politics, business, health, environment,
|
||
local, entertainment, sports, other, [user_defined_topics]
|
||
Return JSON: {"item_id": ["topic1", "topic2"]}
|
||
```
|
||
|
||
The vocabulary is extended with the user's declared preference topics so custom interests can be matched. Results are written to `rss_items.topics` and `classified_at`.
|
||
|
||
Model: the user's `default_model` setting (same as chat). If the LLM is unavailable or classification fails, the item is stored with `topics = []` and `classified_at` left NULL — it will be retried the next time new items are fetched. No retry loop; classification is best-effort.
|
||
|
||
### Post-briefing Stage (new, runs after `post_message()` returns)
|
||
|
||
1. **Upsert task snapshots** — upsert `briefing_task_snapshot` rows for all tasks included in this briefing so the next run can diff against current state.
|
||
2. **Populate message metadata** — the `metadata` dict (`weather` + `rss_item_ids`) is assembled during pre-processing and passed through to `post_message()`, which writes it to the `Message.metadata` column. No separate post-step is needed — the metadata is stored atomically with the message.
|
||
|
||
---
|
||
|
||
## Weather Card
|
||
|
||
The weather section is no longer generated as prose by the synthesis LLM. Instead:
|
||
|
||
- `services/weather.py` is updated to request `past_days=1` from Open-Meteo, including yesterday's high/low in the same API response.
|
||
- The pipeline parses the forecast into the `metadata.weather` schema (defined in the Data Model section) and stores it on the `Message` record when `post_message()` is called.
|
||
- `BriefingView.vue` reads `message.metadata.weather` when loading the conversation and renders `WeatherCard.vue` above the message text if the field is present.
|
||
- The synthesis LLM's weather section is suppressed entirely — the prompt instructs it to skip weather since it is handled by the card.
|
||
|
||
**`WeatherCard.vue` displays:**
|
||
- Location name and "as of" timestamp
|
||
- Current temperature and condition
|
||
- Today's high / low
|
||
- Yesterday's high / low with delta ("3° warmer than yesterday")
|
||
- Compact 3–5 day forecast strip (day name, condition, high/low)
|
||
|
||
**Failure state:** If `metadata.weather` is `null`, the same card position renders a muted placeholder: "Weather data unavailable — will retry at next slot."
|
||
|
||
---
|
||
|
||
## News Cards
|
||
|
||
### Format
|
||
|
||
The synthesis LLM is instructed to format each included news item as:
|
||
|
||
```markdown
|
||
**[Headline text](source_url)**
|
||
*Outlet Name · Day Month*
|
||
One or two sentence summary of the story.
|
||
```
|
||
|
||
No prose wrapper between cards. The synthesis prompt must explicitly instruct the LLM to **present news items in the exact order provided** — `metadata.rss_item_ids` records this order and the frontend maps reaction buttons positionally. Reordering by the LLM would break the mapping.
|
||
|
||
The briefing message structure becomes:
|
||
1. Greeting / task summary
|
||
2. `WeatherCard` (rendered from `message.metadata.weather`, not prose)
|
||
3. News cards (markdown blocks with links)
|
||
4. Calendar / other sections
|
||
|
||
### Reaction Buttons
|
||
|
||
`BriefingView.vue` reads `message.metadata.rss_item_ids` when loading the conversation. The ordered list of IDs maps directly to the news cards in the rendered message (cards appear in synthesis output in the same order). A 👍 / 👎 pair is rendered below each card. Reaction buttons are only shown in the briefing view — not in message history exports.
|
||
|
||
Clicking a reaction:
|
||
1. Optimistic UI update (button enters selected state immediately)
|
||
2. `POST /api/briefing/rss-reactions` — `{rss_item_id, reaction}`
|
||
3. Backend validates ownership: joins through `rss_items → rss_feeds` to confirm `rss_feeds.user_id = g.user.id` before upserting
|
||
4. Upserts into `rss_item_reactions` — same reaction removes it, opposite flips it
|
||
|
||
**New endpoints in `routes/briefing.py`:**
|
||
- `POST /api/briefing/rss-reactions` — upsert or remove reaction (ownership-checked)
|
||
- `DELETE /api/briefing/rss-reactions/:item_id` — explicit removal (useful for MCP/external API consumers that cannot use the toggle behaviour of POST)
|
||
|
||
---
|
||
|
||
## Topic Preferences UI
|
||
|
||
**Settings → Briefing tab — new "News Preferences" subsection** (added below existing RSS feed management):
|
||
|
||
Two chip-input fields using the existing `TagInput.vue` component:
|
||
- **Interested in** → `briefing_include_topics` setting
|
||
- **Not interested in** → `briefing_exclude_topics` setting
|
||
|
||
A collapsed hint lists the standard topic vocabulary so users know valid terms. Custom terms are accepted — the RSS classifier will attempt to match them.
|
||
|
||
Saved via the existing `PUT /api/settings/:key` endpoint.
|
||
|
||
---
|
||
|
||
## MCP Tool Additions
|
||
|
||
New file: `fable-mcp/fable_mcp/tools/briefing.py`
|
||
|
||
Three tools registered in `server.py`:
|
||
|
||
| Tool | Description |
|
||
|------|-------------|
|
||
| `add_rss_feed(url, title=None, category=None)` | Adds a feed to the user's RSS list. `title` is optional — the feed title is auto-populated from feed metadata after the first fetch, but an override can be passed. Returns the created feed object. |
|
||
| `list_rss_feeds()` | Returns current feed list with id, title, url, category, last_fetched_at. |
|
||
| `remove_rss_feed(feed_id)` | Removes a feed by ID. |
|
||
|
||
These call existing RSS endpoints in `routes/briefing.py` via `FableClient`. No new backend routes required.
|
||
|
||
---
|
||
|
||
## New Backend Files
|
||
|
||
| File | Purpose |
|
||
|------|---------|
|
||
| `services/briefing_preferences.py` | Load/compute topic preference weights; apply to RSS item scoring |
|
||
| `services/rss_classifier.py` | Batch LLM classification of RSS items; background task management |
|
||
|
||
## Modified Backend Files
|
||
|
||
| File | Changes |
|
||
|------|---------|
|
||
| `services/briefing_pipeline.py` | Add pre-processing and post-briefing stages; carry `task_id` through serialised task dicts; pass `metadata` dict to `post_message()` |
|
||
| `services/rss.py` | Trigger background classification after storing new items |
|
||
| `services/weather.py` | Add `past_days=1` to Open-Meteo request; expose parsed yesterday data |
|
||
| `routes/briefing.py` | Add `POST/DELETE /api/briefing/rss-reactions` endpoints |
|
||
| `models/rss_feed.py` | Add `topics` and `classified_at` mapped columns to `RssItem`; expose in `to_dict()` |
|
||
| `models/conversation.py` | Add `metadata` JSONB mapped column to `Message`; update `to_dict()` to include `metadata` in the returned dict |
|
||
| `services/briefing_conversations.py` | Extend `post_message(conversation_id, role, content)` signature to accept an optional `metadata: dict \| None = None` parameter; pass it to the `Message(...)` constructor |
|
||
|
||
## New Frontend Files
|
||
|
||
| File | Purpose |
|
||
|------|---------|
|
||
| `components/WeatherCard.vue` | Weather display card (current, today, yesterday delta, 3-5 day strip, failure state) |
|
||
|
||
## Modified Frontend Files
|
||
|
||
| File | Changes |
|
||
|------|---------|
|
||
| `views/BriefingView.vue` | Read `message.metadata.weather` on conversation load → render `WeatherCard.vue` above message text; read `message.metadata.rss_item_ids` → attach reaction buttons to news cards in order |
|
||
| `views/SettingsView.vue` | Add "News Preferences" subsection with two `TagInput` fields |
|
||
| `api/client.ts` | Add `postRssReaction()`, `deleteRssReaction()` helpers |
|
||
|
||
---
|
||
|
||
## Out of Scope
|
||
|
||
- Android companion app changes (web-first; parity deferred)
|
||
- Full numeric scoring system (Approach C) — can evolve to this once reaction data accumulates
|
||
- Push notification integration for briefing reactions
|