# 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
```
