9.4 KiB
Article Reading Design
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: Allow the LLM to fetch and read the full text of any URL on demand, fix conversation history so tool context survives follow-up turns, and make the briefing Discuss button inject article content as a persisted tool exchange rather than raw user-message text.
Architecture: Four self-contained changes — history reconstruction fix (prerequisite), read_article tool, Discuss endpoint, and content cap removal.
Tech Stack: Python/Quart backend, trafilatura (already installed), SQLAlchemy async, Vue 3 frontend.
Problem summary
Three interrelated issues observed in briefing conversations:
- Missing
read_articletool — when a user pastes a URL, the LLM callssearch_web(a SearXNG text search), which returns generic site descriptions instead of article content. - History reconstruction bug —
routes/chat.py:166builds thehistorylist with onlyrole+content, silently dropping alltool_callsand their results from prior turns. Tool context is lost on every follow-up. - Discuss button UX — inlines raw article text into the user message bubble. Feels clumsy, and the model sometimes searches notes on follow-ups anyway because the article isn't clearly marked as "loaded" context.
Components
1. History reconstruction fix
File: src/fabledassistant/routes/chat.py
The loop at line ~164 that builds history must be updated to replay tool exchanges:
history = []
for msg in conv.messages:
if msg.role == "system":
continue
msg_dict = {"role": msg.role, "content": msg.content or ""}
if msg.tool_calls:
msg_dict["tool_calls"] = [
{"function": {"name": tc["function"], "arguments": tc["arguments"]}}
for tc in msg.tool_calls
]
history.append(msg_dict)
for tc in msg.tool_calls:
history.append({"role": "tool", "content": json.dumps(tc.get("result", {}))})
else:
history.append(msg_dict)
The tool_calls JSONB column already stores [{function, arguments, result}] per call. No schema change needed.
2. read_article tool
Files: src/fabledassistant/services/research.py, src/fabledassistant/services/tools.py, src/fabledassistant/services/rss.py
Move _fetch_full_article from rss.py to research.py (imported back into rss.py to avoid breaking existing calls). This makes it available to execute_tool without a circular import.
Tool definition added to _TOOLS in tools.py:
{
"type": "function",
"function": {
"name": "read_article",
"description": (
"Fetch and read the full text of a web page or article from a URL. "
"Use when the user shares a URL and wants you to read it, "
"or to get the full content of a linked page. "
"Do not use search_web for URLs — use this tool instead."
),
"parameters": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "The URL to fetch"}
},
"required": ["url"],
},
},
}
execute_tool handler:
elif tool_name == "read_article":
from fabledassistant.services.research import _fetch_full_article
url = arguments.get("url", "").strip()
if not url:
return {"success": False, "error": "No URL provided"}
content = await _fetch_full_article(url)
if not content:
return {"success": False, "error": f"Could not fetch article content from {url}"}
TOOL_CONTENT_CAP = 40_000
truncated = len(content) > TOOL_CONTENT_CAP
return {
"success": True,
"type": "article_content",
"url": url,
"content": content[:TOOL_CONTENT_CAP],
"truncated": truncated,
}
3. add_message — add tool_calls parameter
File: src/fabledassistant/services/chat.py
add_message needs to accept and store tool_calls so the Discuss endpoint can create synthetic messages:
async def add_message(
conversation_id: int,
role: str,
content: str,
context_note_id: int | None = None,
status: str | None = None,
tool_calls: list | None = None,
) -> Message:
Set msg.tool_calls = tool_calls when provided.
4. Discuss endpoint
File: src/fabledassistant/routes/briefing.py
New route: POST /api/briefing/articles/<int:item_id>/discuss
Request body: {"conv_id": <int>}
Steps:
- Look up
rss_itemsrow byitem_id— verify it belongs to the user via feed ownership. Return 404 if not found. - Look up conversation by
conv_id— verify it belongs to the user. Return 404 if not found. - If generation already running for
conv_id→ return 409. - Fetch stored content:
article_content = item.content or item.snippet or "" - Store synthetic assistant message (status=
"complete", role="assistant", content="", tool_calls as below):synthetic_tool_calls = [{ "function": "read_article", "arguments": {"url": item.url}, "result": { "success": True, "type": "article_content", "url": item.url, "content": article_content, "truncated": False, }, }] await add_message(conv_id, "assistant", "", status="complete", tool_calls=synthetic_tool_calls) - Store user message:
await add_message(conv_id, "user", "Please summarize and discuss this article.") - Build
historyfromconv.messages(using the fixed builder above). - Create assistant placeholder, create buffer, launch
run_generationas normal. - Return
{"assistant_message_id": ..., "status": "generating"}202.
5. Frontend: BriefingView.vue
File: frontend/src/views/BriefingView.vue
Replace discussArticle():
async function discussArticle(item: NewsItem) {
if (!todayConvId.value) return
if (!isToday.value) selectedConvId.value = todayConvId.value
await nextTick(() => {
document.querySelector('.briefing-center')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
})
await apiClient.post(`/api/briefing/articles/${item.id}/discuss`, {
conv_id: todayConvId.value,
})
// Re-fetch conversation so the new messages appear, then start SSE streaming.
// The existing chatStore.fetchConversation + startStreaming pattern handles this.
await chatStore.fetchConversation(todayConvId.value)
chatStore.startStreaming(todayConvId.value)
}
The exact method names (fetchConversation, startStreaming) should match what BriefingView.vue already uses for the reply flow — confirm during implementation.
The article no longer appears as wall-of-text in the user bubble. The chat UI shows it as a read_article tool call card (already handled by ToolCallCard.vue).
6. Content cap removal
File: src/fabledassistant/services/rss.py
Remove [:CONTENT_MAX_CHARS] from:
content = _html_to_text(content)[:CONTENT_MAX_CHARS]inextract_item()item.content = full_text[:CONTENT_MAX_CHARS]in the enrichment task
The CONTENT_MAX_CHARS constant can be removed entirely. Trafilatura extracts only article body text (typically 2K–15K chars for news articles), so content is naturally bounded.
Data flow
User pastes a URL in chat
- User sends message with a URL
- LLM calls
read_article(url) execute_toolcalls_fetch_full_article(url)→ trafilatura extracts clean text- Tool result appended in-memory as
{role: "tool", content: json} - LLM responds based on article content
- Generation saves assistant message with
tool_calls=[{function:"read_article", arguments, result}] - Follow-up turns: history builder replays tool_call + tool result → article stays in context
User clicks Discuss on a briefing article
- Frontend calls
POST /api/briefing/articles/{item_id}/discusswith{conv_id} - Backend fetches stored article text from DB (no network request)
- Backend stores synthetic assistant message with
read_articletool result - Backend stores user message
"Please summarize and discuss this article." - Generation runs — LLM sees pre-loaded article in history
- Follow-ups retain context via fixed history builder
Error handling
| Scenario | Behaviour |
|---|---|
_fetch_full_article returns None (network/extraction failure) |
Tool returns {success: False, error: "Could not fetch article content from [url]"} — LLM reports conversationally |
Discuss: item_id not found or wrong user |
404 |
Discuss: conv_id not found or wrong user |
404 |
| Discuss: article has no stored content | Falls back to empty string — LLM works with what it has |
| Discuss: generation already running | 409 |
Messages with tool_calls = None |
History builder unchanged — no regression for existing conversations |
Tests
- Unit:
_fetch_full_articlereturnsNone→read_articletool result hassuccess: False - Unit: History builder with a stored message that has
tool_calls→ output includes assistant tool_call dict + a{role: "tool"}dict - Unit: History builder with messages where
tool_calls = None→ output unchanged from current behaviour - Integration:
POST /api/briefing/articles/{item_id}/discuss→ two messages stored (synthetic assistant + user message), generation triggered, returns 202