696 lines
23 KiB
Markdown
696 lines
23 KiB
Markdown
# Article Reading 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:** Add a `read_article` tool so the LLM can fetch any URL, fix the history builder so tool context survives follow-up turns, redesign the Discuss button to inject article content as a persisted tool exchange, and remove the RSS content character cap.
|
||
|
||
**Architecture:** Four independent changes executed in dependency order: (1) content cap removal, (2) `read_article` tool, (3) history builder fix (prerequisite for everything persisting across follow-ups), (4) Discuss endpoint + frontend. Each task is independently committable.
|
||
|
||
**Tech Stack:** Python/Quart, SQLAlchemy async, trafilatura (already installed), httpx (already installed), Vue 3 + TypeScript frontend.
|
||
|
||
---
|
||
|
||
## File map
|
||
|
||
| Action | Path | Responsibility |
|
||
|---|---|---|
|
||
| Modify | `src/fabledassistant/services/rss.py` | Remove `CONTENT_MAX_CHARS` truncation |
|
||
| Modify | `src/fabledassistant/services/tools.py` | Add `_URL_TOOLS` list, add `read_article` to `get_tools_for_user`, add handler in `execute_tool` |
|
||
| Modify | `src/fabledassistant/routes/chat.py` | Fix history builder to replay tool_calls |
|
||
| Modify | `src/fabledassistant/services/chat.py` | Add `tool_calls` parameter to `add_message` |
|
||
| Modify | `src/fabledassistant/routes/briefing.py` | Add `POST /api/briefing/articles/<item_id>/discuss` endpoint |
|
||
| Modify | `frontend/src/views/BriefingView.vue` | Replace `discussArticle()` to call new endpoint |
|
||
| Modify | `tests/test_rss_service.py` | Update truncation test, add no-truncation test |
|
||
| Create | `tests/test_article_reading.py` | Tests for `read_article` tool and history builder |
|
||
|
||
---
|
||
|
||
## Task 1: Remove RSS content cap
|
||
|
||
**Files:**
|
||
- Modify: `src/fabledassistant/services/rss.py:17-18,83,213`
|
||
- Modify: `tests/test_rss_service.py:19-26`
|
||
|
||
The `CONTENT_MAX_CHARS = 50_000` constant and all uses of `[:CONTENT_MAX_CHARS]` are removed.
|
||
Trafilatura extracts only article body text, so content is naturally bounded.
|
||
|
||
- [ ] **Step 1: Update the truncation test to assert no truncation**
|
||
|
||
In `tests/test_rss_service.py`, replace the existing `test_extract_item_truncates_content` test:
|
||
|
||
```python
|
||
def test_extract_item_does_not_truncate_content():
|
||
"""extract_item() should store content without truncation."""
|
||
from fabledassistant.services.rss import extract_item
|
||
long_text = "x" * 100_000
|
||
entry = MagicMock()
|
||
entry.get = lambda k, d="": {"summary": long_text, "title": "", "link": "", "id": "g"}.get(k, d)
|
||
entry.content = []
|
||
entry.published_parsed = None
|
||
item = extract_item(entry)
|
||
assert len(item["content"]) == 100_000
|
||
```
|
||
|
||
- [ ] **Step 2: Run the test to confirm it fails**
|
||
|
||
```bash
|
||
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant
|
||
make test ARGS="tests/test_rss_service.py::test_extract_item_does_not_truncate_content -v"
|
||
```
|
||
|
||
Expected: FAIL (current code truncates to 50_000).
|
||
|
||
- [ ] **Step 3: Remove CONTENT_MAX_CHARS from rss.py**
|
||
|
||
In `src/fabledassistant/services/rss.py`:
|
||
|
||
Remove lines 17–18:
|
||
```python
|
||
# Safety cap on stored content — effectively unlimited for typical articles
|
||
CONTENT_MAX_CHARS = 50_000
|
||
```
|
||
|
||
Change line 83 from:
|
||
```python
|
||
content = _html_to_text(content)[:CONTENT_MAX_CHARS]
|
||
```
|
||
to:
|
||
```python
|
||
content = _html_to_text(content)
|
||
```
|
||
|
||
Change line 213 from:
|
||
```python
|
||
item.content = full_text[:CONTENT_MAX_CHARS]
|
||
```
|
||
to:
|
||
```python
|
||
item.content = full_text
|
||
```
|
||
|
||
- [ ] **Step 4: Run all rss tests**
|
||
|
||
```bash
|
||
make test ARGS="tests/test_rss_service.py -v"
|
||
```
|
||
|
||
Expected: all pass. The `test_extract_item_truncates_content` test name no longer exists (replaced in Step 1).
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/fabledassistant/services/rss.py tests/test_rss_service.py
|
||
git commit -m "feat(rss): remove article content character cap"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 2: Add `read_article` tool
|
||
|
||
**Files:**
|
||
- Modify: `src/fabledassistant/services/tools.py`
|
||
- Create: `tests/test_article_reading.py`
|
||
|
||
The tool uses `_fetch_full_article` from `rss.py` (lazy import inside `execute_tool` to avoid circular dependencies). Added unconditionally to all users via a new `_URL_TOOLS` list.
|
||
|
||
- [ ] **Step 1: Write failing tests**
|
||
|
||
Create `tests/test_article_reading.py`:
|
||
|
||
```python
|
||
import json
|
||
import pytest
|
||
from unittest.mock import AsyncMock, patch
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_read_article_success():
|
||
"""read_article tool returns article content on success."""
|
||
from fabledassistant.services.tools import execute_tool
|
||
with patch(
|
||
"fabledassistant.services.rss._fetch_full_article",
|
||
new=AsyncMock(return_value="Article text here."),
|
||
):
|
||
result = await execute_tool(
|
||
user_id=1,
|
||
tool_name="read_article",
|
||
arguments={"url": "https://example.com/article"},
|
||
)
|
||
assert result["success"] is True
|
||
assert result["type"] == "article_content"
|
||
assert result["url"] == "https://example.com/article"
|
||
assert result["content"] == "Article text here."
|
||
assert result["truncated"] is False
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_read_article_fetch_failure():
|
||
"""read_article tool returns success=False when fetch returns None."""
|
||
from fabledassistant.services.tools import execute_tool
|
||
with patch(
|
||
"fabledassistant.services.rss._fetch_full_article",
|
||
new=AsyncMock(return_value=None),
|
||
):
|
||
result = await execute_tool(
|
||
user_id=1,
|
||
tool_name="read_article",
|
||
arguments={"url": "https://example.com/bad"},
|
||
)
|
||
assert result["success"] is False
|
||
assert "Could not fetch" in result["error"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_read_article_truncates_at_40k():
|
||
"""read_article tool truncates content at 40_000 chars and sets truncated=True."""
|
||
from fabledassistant.services.tools import execute_tool
|
||
long_content = "x" * 50_000
|
||
with patch(
|
||
"fabledassistant.services.rss._fetch_full_article",
|
||
new=AsyncMock(return_value=long_content),
|
||
):
|
||
result = await execute_tool(
|
||
user_id=1,
|
||
tool_name="read_article",
|
||
arguments={"url": "https://example.com/long"},
|
||
)
|
||
assert result["success"] is True
|
||
assert len(result["content"]) == 40_000
|
||
assert result["truncated"] is True
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_read_article_empty_url():
|
||
"""read_article tool returns success=False when url is empty."""
|
||
from fabledassistant.services.tools import execute_tool
|
||
result = await execute_tool(
|
||
user_id=1,
|
||
tool_name="read_article",
|
||
arguments={"url": ""},
|
||
)
|
||
assert result["success"] is False
|
||
```
|
||
|
||
- [ ] **Step 2: Run tests to confirm they fail**
|
||
|
||
```bash
|
||
make test ARGS="tests/test_article_reading.py -v"
|
||
```
|
||
|
||
Expected: all 4 fail with "read_article not handled" or AttributeError.
|
||
|
||
- [ ] **Step 3: Add `_URL_TOOLS` list and register it in `get_tools_for_user`**
|
||
|
||
In `src/fabledassistant/services/tools.py`, add the `_URL_TOOLS` list immediately after the `_SEARCH_TOOLS` block (around line 836):
|
||
|
||
```python
|
||
_URL_TOOLS = [
|
||
{
|
||
"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 and read"}
|
||
},
|
||
"required": ["url"],
|
||
},
|
||
},
|
||
}
|
||
]
|
||
```
|
||
|
||
In `get_tools_for_user` (around line 1034), add `_URL_TOOLS` unconditionally after `_CORE_TOOLS`:
|
||
|
||
```python
|
||
async def get_tools_for_user(user_id: int) -> list[dict]:
|
||
"""Build the tool list for a user based on their configured integrations."""
|
||
tools = list(_CORE_TOOLS)
|
||
tools.extend(_URL_TOOLS)
|
||
tools.extend(_RAG_TOOLS)
|
||
tools.extend(_ENTITY_TOOLS)
|
||
if await is_caldav_configured(user_id):
|
||
tools.extend(_CALDAV_TOOLS)
|
||
if Config.searxng_enabled():
|
||
tools.extend(_SEARCH_TOOLS)
|
||
tools.extend(_RESEARCH_TOOLS)
|
||
tools.extend(_IMAGE_TOOLS)
|
||
logger.debug("User %d: %d tools available", user_id, len(tools))
|
||
return tools
|
||
```
|
||
|
||
- [ ] **Step 4: Add `read_article` handler in `execute_tool`**
|
||
|
||
In `src/fabledassistant/services/tools.py`, in the `execute_tool` function, find the `elif tool_name == "search_web":` block (around line 1771). Add the new handler immediately before it:
|
||
|
||
```python
|
||
elif tool_name == "read_article":
|
||
from fabledassistant.services.rss 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,
|
||
}
|
||
|
||
```
|
||
|
||
- [ ] **Step 5: Run the tests**
|
||
|
||
```bash
|
||
make test ARGS="tests/test_article_reading.py -v"
|
||
```
|
||
|
||
Expected: all 4 pass.
|
||
|
||
- [ ] **Step 6: Run full test suite**
|
||
|
||
```bash
|
||
make test
|
||
```
|
||
|
||
Expected: all pass.
|
||
|
||
- [ ] **Step 7: Commit**
|
||
|
||
```bash
|
||
git add src/fabledassistant/services/tools.py tests/test_article_reading.py
|
||
git commit -m "feat(tools): add read_article tool using trafilatura extraction"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 3: Fix history builder
|
||
|
||
**Files:**
|
||
- Modify: `src/fabledassistant/routes/chat.py:162-166`
|
||
- Modify: `tests/test_article_reading.py` (add history builder tests)
|
||
|
||
The loop that builds `history` for `run_generation` currently drops `tool_calls`. This fix replays the full tool exchange so the LLM sees prior tool results on follow-up turns.
|
||
|
||
- [ ] **Step 1: Add history builder tests**
|
||
|
||
Append to `tests/test_article_reading.py`:
|
||
|
||
```python
|
||
def test_history_builder_plain_messages():
|
||
"""Messages without tool_calls are added as {role, content} unchanged."""
|
||
import json
|
||
messages = [
|
||
type("M", (), {"role": "system", "content": "sys", "tool_calls": None})(),
|
||
type("M", (), {"role": "user", "content": "hello", "tool_calls": None})(),
|
||
type("M", (), {"role": "assistant", "content": "hi", "tool_calls": None})(),
|
||
]
|
||
history = _build_history(messages)
|
||
assert history == [
|
||
{"role": "user", "content": "hello"},
|
||
{"role": "assistant", "content": "hi"},
|
||
]
|
||
|
||
|
||
def test_history_builder_with_tool_calls():
|
||
"""Messages with tool_calls emit an assistant entry + tool result entries."""
|
||
import json
|
||
tool_calls_data = [
|
||
{
|
||
"function": "read_article",
|
||
"arguments": {"url": "https://example.com"},
|
||
"result": {"success": True, "content": "Article text"},
|
||
}
|
||
]
|
||
messages = [
|
||
type("M", (), {"role": "user", "content": "read this", "tool_calls": None})(),
|
||
type("M", (), {
|
||
"role": "assistant",
|
||
"content": "",
|
||
"tool_calls": tool_calls_data,
|
||
})(),
|
||
type("M", (), {"role": "user", "content": "follow up", "tool_calls": None})(),
|
||
]
|
||
history = _build_history(messages)
|
||
assert history[0] == {"role": "user", "content": "read this"}
|
||
assert history[1]["role"] == "assistant"
|
||
assert history[1]["tool_calls"] == [
|
||
{"function": {"name": "read_article", "arguments": {"url": "https://example.com"}}}
|
||
]
|
||
assert history[2] == {"role": "tool", "content": json.dumps({"success": True, "content": "Article text"})}
|
||
assert history[3] == {"role": "user", "content": "follow up"}
|
||
|
||
|
||
def _build_history(messages):
|
||
"""Inline copy of the fixed history builder for testing."""
|
||
import json
|
||
history = []
|
||
for msg in 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)
|
||
return history
|
||
```
|
||
|
||
- [ ] **Step 2: Run the tests to confirm they pass**
|
||
|
||
(These tests use `_build_history` defined inline — they test the logic directly, not the route. They should pass immediately.)
|
||
|
||
```bash
|
||
make test ARGS="tests/test_article_reading.py::test_history_builder_plain_messages tests/test_article_reading.py::test_history_builder_with_tool_calls -v"
|
||
```
|
||
|
||
Expected: both pass.
|
||
|
||
- [ ] **Step 3: Apply the fix to `chat.py`**
|
||
|
||
In `src/fabledassistant/routes/chat.py`, replace lines 162–166:
|
||
|
||
```python
|
||
# Build history from existing messages (excluding system and the placeholder)
|
||
history = []
|
||
for msg in conv.messages:
|
||
if msg.role != "system":
|
||
history.append({"role": msg.role, "content": msg.content})
|
||
```
|
||
|
||
with:
|
||
|
||
```python
|
||
# Build history from existing messages (excluding system and the placeholder).
|
||
# Tool calls from prior turns are replayed as assistant tool_call + tool result
|
||
# messages so the LLM retains tool context on follow-up turns.
|
||
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)
|
||
```
|
||
|
||
`json` is already imported at the top of `chat.py`.
|
||
|
||
- [ ] **Step 4: Run full test suite**
|
||
|
||
```bash
|
||
make test
|
||
```
|
||
|
||
Expected: all pass.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/fabledassistant/routes/chat.py tests/test_article_reading.py
|
||
git commit -m "fix(chat): replay tool_calls in history so tool context survives follow-up turns"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 4: Extend `add_message` to accept `tool_calls`
|
||
|
||
**Files:**
|
||
- Modify: `src/fabledassistant/services/chat.py:183-207`
|
||
|
||
The Discuss endpoint (Task 5) needs to store a synthetic assistant message with `tool_calls`. The existing `add_message` doesn't support this parameter.
|
||
|
||
- [ ] **Step 1: Update `add_message` signature and body**
|
||
|
||
In `src/fabledassistant/services/chat.py`, replace the `add_message` function (lines 183–207):
|
||
|
||
```python
|
||
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:
|
||
async with async_session() as session:
|
||
kwargs: dict = dict(
|
||
conversation_id=conversation_id,
|
||
role=role,
|
||
content=content,
|
||
context_note_id=context_note_id,
|
||
)
|
||
if status is not None:
|
||
kwargs["status"] = status
|
||
if tool_calls is not None:
|
||
kwargs["tool_calls"] = tool_calls
|
||
msg = Message(**kwargs)
|
||
session.add(msg)
|
||
# Touch conversation updated_at
|
||
conv = await session.get(Conversation, conversation_id)
|
||
if conv:
|
||
conv.updated_at = datetime.now(timezone.utc)
|
||
await session.commit()
|
||
await session.refresh(msg)
|
||
return msg
|
||
```
|
||
|
||
- [ ] **Step 2: Run full test suite**
|
||
|
||
```bash
|
||
make test
|
||
```
|
||
|
||
Expected: all pass (existing callers only use positional/keyword args that are unchanged).
|
||
|
||
- [ ] **Step 3: Commit**
|
||
|
||
```bash
|
||
git add src/fabledassistant/services/chat.py
|
||
git commit -m "feat(chat): add tool_calls parameter to add_message"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 5: Add Discuss endpoint and update frontend
|
||
|
||
**Files:**
|
||
- Modify: `src/fabledassistant/routes/briefing.py`
|
||
- Modify: `frontend/src/views/BriefingView.vue`
|
||
|
||
New route: `POST /api/briefing/articles/<item_id>/discuss`. Fetches stored article from DB, stores a synthetic `read_article` tool exchange plus the user message, then triggers generation. Frontend replaces the inline-content approach with a call to this endpoint.
|
||
|
||
- [ ] **Step 1: Add the discuss endpoint to briefing.py**
|
||
|
||
At the top of `src/fabledassistant/routes/briefing.py`, add these imports (after the existing imports):
|
||
|
||
```python
|
||
from fabledassistant.models.rss_feed import RssItem, RssFeed
|
||
from fabledassistant.services.chat import add_message, get_conversation
|
||
from fabledassistant.services.generation_buffer import GenerationState, create_buffer, get_buffer
|
||
from fabledassistant.services.generation_task import run_generation
|
||
from fabledassistant.services.settings import get_setting
|
||
```
|
||
|
||
Note: `get_setting` and `asyncio` are already imported. Add only what is missing.
|
||
|
||
Then add the new route at the end of `briefing.py` (before any final lines), after the `list_news` route:
|
||
|
||
```python
|
||
@briefing_bp.route("/articles/<int:item_id>/discuss", methods=["POST"])
|
||
@_REQUIRE
|
||
async def discuss_article(item_id: int):
|
||
"""Pre-load a briefing article as a read_article tool exchange and trigger generation."""
|
||
uid = g.user.id
|
||
data = await request.get_json() or {}
|
||
conv_id = data.get("conv_id")
|
||
if not conv_id:
|
||
return jsonify({"error": "conv_id is required"}), 400
|
||
|
||
# Verify article belongs to this user (via feed ownership)
|
||
async with async_session() as session:
|
||
result = await session.execute(
|
||
select(RssItem).join(RssFeed, RssItem.feed_id == RssFeed.id)
|
||
.where(RssItem.id == item_id, RssFeed.user_id == uid)
|
||
)
|
||
item = result.scalar_one_or_none()
|
||
if item is None:
|
||
return jsonify({"error": "Article not found"}), 404
|
||
|
||
# Verify conversation belongs to this user
|
||
conv = await get_conversation(uid, conv_id)
|
||
if conv is None:
|
||
return jsonify({"error": "Conversation not found"}), 404
|
||
|
||
# Reject if generation already running
|
||
existing = get_buffer(conv_id)
|
||
if existing and existing.state == GenerationState.RUNNING:
|
||
return jsonify({"error": "Generation already in progress"}), 409
|
||
|
||
article_content = item.content or ""
|
||
|
||
# Store synthetic assistant message: read_article was already called with stored content
|
||
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.")
|
||
|
||
# Reload conversation so history includes the two new messages
|
||
conv = await get_conversation(uid, conv_id)
|
||
|
||
# Build history (using the fixed builder from chat.py logic — duplicated here)
|
||
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)
|
||
|
||
model = await get_setting(uid, "default_model", "") or ""
|
||
from fabledassistant.config import Config as _Config
|
||
if not model:
|
||
model = _Config.OLLAMA_MODEL
|
||
|
||
# Create placeholder assistant message and generation buffer
|
||
assistant_msg = await add_message(conv_id, "assistant", "", status="generating")
|
||
try:
|
||
buf = create_buffer(conv_id, assistant_msg.id)
|
||
except RuntimeError:
|
||
return jsonify({"error": "Generation already in progress"}), 409
|
||
|
||
asyncio.create_task(run_generation(
|
||
buf, history, model,
|
||
uid, conv_id, conv.title,
|
||
"Please summarize and discuss this article.",
|
||
think=True,
|
||
))
|
||
|
||
return jsonify({"assistant_message_id": assistant_msg.id, "status": "generating"}), 202
|
||
```
|
||
|
||
- [ ] **Step 2: Run full test suite**
|
||
|
||
```bash
|
||
make test
|
||
```
|
||
|
||
Expected: all pass.
|
||
|
||
- [ ] **Step 3: Update `discussArticle` in BriefingView.vue**
|
||
|
||
In `frontend/src/views/BriefingView.vue`, replace the `discussArticle` function:
|
||
|
||
```typescript
|
||
async function discussArticle(item: NewsItem) {
|
||
if (!todayConvId.value || chatStore.streaming) return
|
||
if (!isToday.value) selectedConvId.value = todayConvId.value
|
||
await nextTick(() => {
|
||
document.querySelector('.briefing-center')?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||
})
|
||
try {
|
||
await apiPost<{ assistant_message_id: number }>(
|
||
`/api/briefing/articles/${item.id}/discuss`,
|
||
{ conv_id: todayConvId.value },
|
||
)
|
||
} catch {
|
||
return
|
||
}
|
||
// Reload conversation so the new messages appear (including the generating placeholder),
|
||
// then reconnect to the SSE stream using the existing reconnectIfGenerating helper.
|
||
await chatStore.fetchConversation(todayConvId.value)
|
||
await chatStore.reconnectIfGenerating(todayConvId.value)
|
||
}
|
||
```
|
||
|
||
`reconnectIfGenerating` is already exported from `useChatStore`. It finds the assistant message in `status="generating"` state and connects to the SSE stream automatically. No changes to `chat.ts` are needed.
|
||
|
||
- [ ] **Step 4: TypeScript check**
|
||
|
||
```bash
|
||
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant
|
||
npm --prefix frontend run type-check
|
||
```
|
||
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 5: Commit**
|
||
|
||
```bash
|
||
git add src/fabledassistant/routes/briefing.py frontend/src/views/BriefingView.vue frontend/src/stores/chat.ts
|
||
git commit -m "feat(briefing): add discuss endpoint and update frontend to use persisted article context"
|
||
```
|
||
|
||
---
|
||
|
||
## Task 6: Final verification
|
||
|
||
- [ ] **Step 1: Run full test suite**
|
||
|
||
```bash
|
||
cd /home/bvandeusen/Nextcloud/Projects/fabledassistant
|
||
make test
|
||
```
|
||
|
||
Expected: all tests pass.
|
||
|
||
- [ ] **Step 2: TypeScript check**
|
||
|
||
```bash
|
||
npm --prefix frontend run type-check
|
||
```
|
||
|
||
Expected: no errors.
|
||
|
||
- [ ] **Step 3: Push**
|
||
|
||
```bash
|
||
git push origin dev
|
||
```
|