From 9157740069287852e8d9a20d0d9782851a592b11 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 13 Apr 2026 23:40:41 -0400 Subject: [PATCH 1/2] ci: gate typecheck/lint/test on ref so main merge commits skip work MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Forgejo Actions doesn't consistently honor `on.push.branches` as a filter — the merge commit landing on main after a release PR was still triggering the full CI workflow on a SHA that had already passed on dev, burning ~1 minute of runner time for no reason. The `build` job already had a ref guard, so no duplicate images were being pushed, but typecheck/lint/test ran unconditionally. Add the same guard (`refs/heads/dev` or `refs/tags/v*`) to those three so main pushes trigger the workflow but every job skips immediately with zero runner time. Also tightened the build job's tag filter from `refs/tags/` to `refs/tags/v` for consistency with the new guards and to avoid ever building from a non-version tag. Co-Authored-By: Claude Opus 4.6 --- .forgejo/workflows/ci.yml | 32 +++++++++++++++++++++++--------- 1 file changed, 23 insertions(+), 9 deletions(-) diff --git a/.forgejo/workflows/ci.yml b/.forgejo/workflows/ci.yml index f488c5e..18f566d 100644 --- a/.forgejo/workflows/ci.yml +++ b/.forgejo/workflows/ci.yml @@ -1,8 +1,12 @@ # CI runs first; build only proceeds if all checks pass. # -# Push to any branch: typecheck + lint + test -# Push to dev: gates + build :dev + : -# Tag v* (release): gates + build :latest + : + : +# Push to dev: typecheck + lint + test + build :dev + : +# Tag v* (release): typecheck + lint + test + build :latest + : + : +# +# main pushes are NOT gated here: a merge to main only happens after +# dev has already passed CI, and the release tag is the sole trigger +# for a production image. Re-running CI on the merge commit just burns +# runner time without changing the outcome. # # To cut a release: # Create a release via the Forgejo UI on main with a v* tag name. @@ -11,6 +15,13 @@ # PRs aren't triggered on purpose — this is a solo dev→main flow, so # gating on branch push is already enough. # +# NOTE on the `if:` guards below: Forgejo Actions does not consistently +# honor `on.push.branches` as a filter — merge commits landing on main +# still trigger the workflow, producing redundant runs on the same SHA +# that was already gated on dev. Every job therefore repeats the ref +# check so main pushes trigger the workflow but every job skips +# immediately (no runner time, no duplicate work). +# # Required secrets (repo → Settings → Secrets → Actions): # REGISTRY_USER — your Forgejo username # REGISTRY_TOKEN — Forgejo PAT with write:packages scope @@ -18,7 +29,7 @@ name: CI & Build on: push: - branches: [dev, main] + branches: [dev] tags: ["v*"] paths: - "src/**" @@ -51,6 +62,8 @@ env: jobs: typecheck: name: TypeScript typecheck + # Skip on main merge-commit pushes — see workflow header comment. + if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v') runs-on: ci-runner steps: - uses: actions/checkout@v6 @@ -77,6 +90,7 @@ jobs: lint: name: Python lint + if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v') runs-on: ci-runner steps: - uses: actions/checkout@v6 @@ -88,6 +102,7 @@ jobs: test: name: Python tests + if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v') runs-on: ci-runner steps: - uses: actions/checkout@v6 @@ -118,11 +133,10 @@ jobs: name: Build & push image needs: [typecheck, lint, test] # Build on dev branch pushes and version tag pushes only. - # main branch pushes run the gates for safety but do not build — - # the release tag (v*) is the sole trigger for a production image. - if: | - github.event_name == 'push' && - (github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/')) + # Mirrors the ref guard on the gate jobs above — main merge-commit + # pushes skip here too, so no production image is ever built from a + # raw main push (only from the v* tag the release creates). + if: github.ref == 'refs/heads/dev' || startsWith(github.ref, 'refs/tags/v') runs-on: ci-runner permissions: contents: read From ba90ad8132ad4e1915fbfc00a50ebfc813182fc9 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 14 Apr 2026 07:54:24 -0400 Subject: [PATCH 2/2] feat(article-discuss): unify /news + briefing entry points, persist summaries to RAG MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both the /news discuss button and the briefing discuss button now call a shared seed_article_discussion() helper that stages the synthetic read_article tool exchange and the conversational seed prompt — behavior stays byte-identical across entry points. /news also auto-starts generation so the chat screen lands on an in-flight stream. First assistant reply in a seeded article conversation is persisted as a Note (tags: article-summary + article topics) and backlinked via rss_items.discussion_note_id, so the knowledge base stops being amnesiac about articles the user has engaged with. Co-Authored-By: Claude Opus 4.6 --- .../0039_add_rss_item_discussion_note.py | 38 ++++++++ frontend/src/api/client.ts | 4 +- src/fabledassistant/models/rss_feed.py | 9 +- src/fabledassistant/routes/briefing.py | 53 ++--------- src/fabledassistant/routes/chat.py | 69 +++++++++----- .../services/article_context.py | 95 ++++++++++++++++++- src/fabledassistant/services/chat.py | 3 + .../services/generation_task.py | 91 ++++++++++++++++++ 8 files changed, 284 insertions(+), 78 deletions(-) create mode 100644 alembic/versions/0039_add_rss_item_discussion_note.py diff --git a/alembic/versions/0039_add_rss_item_discussion_note.py b/alembic/versions/0039_add_rss_item_discussion_note.py new file mode 100644 index 0000000..d8e1df0 --- /dev/null +++ b/alembic/versions/0039_add_rss_item_discussion_note.py @@ -0,0 +1,38 @@ +"""Link rss_items to their discussion-summary note. + +Revision ID: 0039 +Revises: 0038 +Create Date: 2026-04-14 +""" + +from __future__ import annotations + +import sqlalchemy as sa +from alembic import op + +revision = "0039" +down_revision = "0038" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "rss_items", + sa.Column("discussion_note_id", sa.BigInteger(), nullable=True), + ) + op.create_foreign_key( + "fk_rss_items_discussion_note_id", + "rss_items", + "notes", + ["discussion_note_id"], + ["id"], + ondelete="SET NULL", + ) + + +def downgrade() -> None: + op.drop_constraint( + "fk_rss_items_discussion_note_id", "rss_items", type_="foreignkey" + ) + op.drop_column("rss_items", "discussion_note_id") diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index c0585b2..b350ecc 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -426,7 +426,9 @@ export async function deleteRssReaction(rssItemId: number): Promise { return apiDelete(`/api/briefing/rss-reactions/${rssItemId}`); } -export async function openArticleInChat(itemId: number): Promise<{ conversation_id: number }> { +export async function openArticleInChat( + itemId: number, +): Promise<{ conversation_id: number; assistant_message_id?: number; status?: string }> { return apiPost(`/api/chat/from-article/${itemId}`, {}); } diff --git a/src/fabledassistant/models/rss_feed.py b/src/fabledassistant/models/rss_feed.py index 63e013f..0df5f49 100644 --- a/src/fabledassistant/models/rss_feed.py +++ b/src/fabledassistant/models/rss_feed.py @@ -1,6 +1,6 @@ from datetime import datetime, timezone -from sqlalchemy import ARRAY, DateTime, ForeignKey, Index, Integer, Text, UniqueConstraint +from sqlalchemy import ARRAY, BigInteger, DateTime, ForeignKey, Index, Integer, Text, UniqueConstraint from sqlalchemy.orm import Mapped, mapped_column, relationship from fabledassistant.models import Base @@ -66,6 +66,13 @@ class RssItem(Base): classified_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True ) + # Note persisting the first-click discussion summary. Set by the article + # discussion pipeline once the seeded chat completes its first assistant + # reply; links back into RAG so re-discussing the same article lands the + # prior summary in context. + discussion_note_id: Mapped[int | None] = mapped_column( + BigInteger, ForeignKey("notes.id", ondelete="SET NULL"), nullable=True + ) feed: Mapped["RssFeed"] = relationship(back_populates="items") diff --git a/src/fabledassistant/routes/briefing.py b/src/fabledassistant/routes/briefing.py index fc2ebe0..2b050a8 100644 --- a/src/fabledassistant/routes/briefing.py +++ b/src/fabledassistant/routes/briefing.py @@ -532,54 +532,15 @@ async def discuss_article(item_id: int): if get_buffer(conv_id) is not None: return jsonify({"error": "Generation already in progress"}), 409 - # Three-layer cache: context_prepared (post-map-reduce) → content_full - # (raw trafilatura) → fresh fetch. Only the first miss pays the fetch - # cost; only a large uncached article pays the map-reduce cost. Repeat - # clicks on the same article skip straight to the chat turn. - from fabledassistant.services.article_context import prepare_article_context - from fabledassistant.services.rss import get_or_fetch_full_article + # Shared helper handles the three-layer cache (context_prepared → + # content_full → fresh fetch), writes the synthetic read_article tool + # exchange and the conversational seed user prompt into the conversation. + # The /news from-article route calls the same helper so behavior stays + # byte-identical across entry points. + from fabledassistant.services.article_context import seed_article_discussion model = await get_setting(uid, "default_model", "") or "" - - if item.context_prepared: - article_content = item.context_prepared - else: - raw_body = await get_or_fetch_full_article(item) or item.content or "" - article_content = await prepare_article_context( - item.title or "", item.url, raw_body, model, - ) - if article_content: - async with async_session() as session: - fresh = await session.get(RssItem, item.id) - if fresh is not None: - fresh.context_prepared = article_content - await session.commit() - - # Store synthetic assistant message with read_article tool result - 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) - - # Conversational seed — invites a real discussion rather than asking for - # a one-shot summary. The model sees the article context in the tool - # result above and responds to this user turn as the start of an ongoing - # conversation the user will steer with follow-ups. - discuss_prompt = ( - "I want to talk about this article. Start with a substantive summary " - "of what it's arguing and the key evidence it uses, then tell me what " - "stood out to you or seems worth pushing back on. I'll ask follow-ups " - "from there." - ) - await add_message(conv_id, "user", discuss_prompt) + discuss_prompt = await seed_article_discussion(conv_id, item, model) # Reload conversation with fresh messages to build history conv = await get_conversation(uid, conv_id) diff --git a/src/fabledassistant/routes/chat.py b/src/fabledassistant/routes/chat.py index cf9ed73..6b81bdb 100644 --- a/src/fabledassistant/routes/chat.py +++ b/src/fabledassistant/routes/chat.py @@ -510,47 +510,66 @@ async def delete_model_route(): @chat_bp.route("/from-article/", methods=["POST"]) @login_required async def create_conversation_from_article(item_id: int): - """Create a chat conversation seeded with an RSS article's content.""" + """Create a chat conversation seeded for article discussion and auto-run. + + Mirrors the briefing ``discuss_article`` route: creates a fresh + conversation, stages the shared synthetic read_article exchange + seed + prompt, then kicks off generation so the client lands on an in-flight + stream. The Flutter and web chat screens reconnect to the running buffer + on mount. + """ from sqlalchemy import select as _select from fabledassistant.models import async_session as _async_session - from fabledassistant.models.conversation import Message from fabledassistant.models.rss_feed import RssItem, RssFeed + from fabledassistant.services.article_context import seed_article_discussion uid = get_current_user_id() async with _async_session() as session: result = await session.execute( - _select(RssItem, RssFeed.title.label("feed_title")) + _select(RssItem) .join(RssFeed, RssItem.feed_id == RssFeed.id) .where(RssItem.id == item_id, RssFeed.user_id == uid) ) - row = result.first() + item = result.scalars().first() - if row is None: + if item is None: return jsonify({"error": "Article not found"}), 404 - item, feed_title = row - conv_title = (item.title or "Article discussion")[:80] conv = await create_conversation(uid, title=conv_title, conversation_type="chat") - from fabledassistant.services.rss import _fetch_full_article - source = feed_title or "News" - content_body = (await _fetch_full_article(item.url) if item.url else None) or (item.content or "").strip() - seeded_text = f"**{source}**\n\n**{item.title}**" - if content_body: - seeded_text += f"\n\n{content_body}" - if item.url: - seeded_text += f"\n\nSource: {item.url}" + model = await get_setting(uid, "default_model", "") or Config.OLLAMA_MODEL + discuss_prompt = await seed_article_discussion(conv.id, item, model) - async with _async_session() as session: - msg = Message( - conversation_id=conv.id, - role="assistant", - content=seeded_text, - msg_metadata={"rss_item_ids": [item_id]}, - ) - session.add(msg) - await session.commit() + # Reload conversation so we see the two messages the helper just added. + conv = await get_conversation(uid, conv.id) + assert conv is not None - return jsonify({"conversation_id": conv.id}), 201 + history: list[dict] = [] + for msg in conv.messages: + if msg.role == "system": + continue + msg_dict: 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) + + assistant_msg = await add_message(conv.id, "assistant", "", status="generating") + buf = create_buffer(conv.id, assistant_msg.id) + asyncio.create_task(run_generation( + buf, history, model, uid, conv.id, conv.title or "", discuss_prompt, + )) + + return jsonify({ + "conversation_id": conv.id, + "assistant_message_id": assistant_msg.id, + "status": "generating", + }), 202 diff --git a/src/fabledassistant/services/article_context.py b/src/fabledassistant/services/article_context.py index 965a3b6..1a41a94 100644 --- a/src/fabledassistant/services/article_context.py +++ b/src/fabledassistant/services/article_context.py @@ -1,15 +1,21 @@ """Prepare article bodies as conversation-ready context. -Used by the briefing ``discuss-article`` flow. A raw trafilatura extraction -is often too large to drop whole into a chat history without eating the -context window, so this module runs a map-reduce step over oversized -articles and returns a compact, structured context that still preserves the -article's meaning across sections. +Used by the briefing ``discuss-article`` flow and the ``/news`` discuss button. +A raw trafilatura extraction is often too large to drop whole into a chat +history without eating the context window, so this module runs a map-reduce +step over oversized articles and returns a compact, structured context that +still preserves the article's meaning across sections. Small articles pass through unchanged — map-reduce only fires when the raw body exceeds CHAR_BUDGET. The output is cached on ``rss_items.context_prepared`` by the caller, so repeat discuss-clicks on the same article skip this work entirely. + +The module also owns ``seed_article_discussion``, the shared routine that +stages a synthetic ``read_article`` tool exchange plus a conversational seed +prompt into a conversation. Both the briefing and ``/news`` entry points call +it so the two flows stay byte-identical — the only thing that differs between +them is whether the conversation already existed or was freshly created. """ from __future__ import annotations @@ -18,6 +24,9 @@ import asyncio import logging import re +from fabledassistant.models import async_session +from fabledassistant.models.rss_feed import RssItem +from fabledassistant.services.chat import add_message from fabledassistant.services.llm import generate_completion logger = logging.getLogger(__name__) @@ -159,3 +168,79 @@ async def prepare_article_context( for i, summary in enumerate(summaries) ] return header + "\n\n".join(parts) + + +# Conversational seed prompt for article discussions. Kept here so both the +# briefing and /news entry points use the exact same wording. See +# feedback_discuss_prompt_style memory: numbered checklists produce +# assignment-completion responses; this conversational seed opens a dialogue. +ARTICLE_DISCUSS_SEED = ( + "I want to talk about this article. Start with a substantive summary " + "of what it's arguing and the key evidence it uses, then tell me what " + "stood out to you or seems worth pushing back on. I'll ask follow-ups " + "from there." +) + + +async def seed_article_discussion( + conv_id: int, + item: RssItem, + model: str, +) -> str: + """Stage the synthetic read_article tool exchange + conversational seed. + + Used by both the briefing ``discuss_article`` route and the ``/news`` + ``from-article`` conversation creator. Handles the three-layer cache + (``context_prepared`` → ``content_full`` → fresh fetch) and inserts two + messages into ``conv_id``: + + 1. An assistant message with a synthetic ``read_article`` tool_call whose + ``result.content`` carries the prepared article context. The message + also carries ``msg_metadata={"rss_item_id": ...}`` so the post-generation + hook in ``generation_task.py`` can locate it and persist the first + reply as a discussion-summary Note. + 2. A user message with the shared conversational seed prompt. + + Returns the seed prompt string so callers can pass it to ``run_generation`` + as ``user_content``. + """ + # Avoid circulars: rss helper imports article_context indirectly nowhere, + # but keep this local for symmetry with the route-level imports it + # replaces. + from fabledassistant.services.rss import get_or_fetch_full_article + + if item.context_prepared: + article_content = item.context_prepared + else: + raw_body = await get_or_fetch_full_article(item) or item.content or "" + article_content = await prepare_article_context( + item.title or "", item.url, raw_body, model, + ) + if article_content: + async with async_session() as session: + fresh = await session.get(RssItem, item.id) + if fresh is not None: + fresh.context_prepared = article_content + await session.commit() + + 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, + msg_metadata={"rss_item_id": item.id, "article_seed": True}, + ) + await add_message(conv_id, "user", ARTICLE_DISCUSS_SEED) + return ARTICLE_DISCUSS_SEED diff --git a/src/fabledassistant/services/chat.py b/src/fabledassistant/services/chat.py index fbc8fae..e3052b3 100644 --- a/src/fabledassistant/services/chat.py +++ b/src/fabledassistant/services/chat.py @@ -187,6 +187,7 @@ async def add_message( context_note_id: int | None = None, status: str | None = None, tool_calls: list | None = None, + msg_metadata: dict | None = None, ) -> Message: async with async_session() as session: kwargs: dict = dict( @@ -199,6 +200,8 @@ async def add_message( kwargs["status"] = status if tool_calls is not None: kwargs["tool_calls"] = tool_calls + if msg_metadata is not None: + kwargs["msg_metadata"] = msg_metadata msg = Message(**kwargs) session.add(msg) # Touch conversation updated_at diff --git a/src/fabledassistant/services/generation_task.py b/src/fabledassistant/services/generation_task.py index a74bd76..dadad0d 100644 --- a/src/fabledassistant/services/generation_task.py +++ b/src/fabledassistant/services/generation_task.py @@ -36,6 +36,85 @@ _TOOL_CALL_MARKER = re.compile(r"^\s*\[TOOL_CALLS\]\s*", re.IGNORECASE) DB_FLUSH_INTERVAL = 5.0 # seconds between partial DB flushes + +async def _maybe_save_article_discussion_note( + user_id: int, conv_id: int, reply_content: str, +) -> None: + """Persist a seeded article-discussion's first reply as a Note. + + Fires after ``run_generation`` completes. Looks for a synthetic + read_article seed message on the conversation; if found AND the linked + ``rss_items`` row has no ``discussion_note_id`` yet, saves ``reply_content`` + as a Note, tags it, and writes the backlink. Subsequent discuss clicks on + the same article are a no-op (already linked). + + Failures are logged and swallowed — the chat UI should never break because + Note persistence hit a snag. + """ + try: + if not reply_content or not reply_content.strip(): + return + from sqlalchemy import select as _select + from fabledassistant.models.conversation import Message as _Message + from fabledassistant.models.rss_feed import RssItem as _RssItem + from fabledassistant.services.notes import create_note + + async with async_session() as session: + result = await session.execute( + _select(_Message) + .where(_Message.conversation_id == conv_id) + .order_by(_Message.id.asc()) + ) + messages = result.scalars().all() + seed_meta = None + for m in messages: + meta = m.msg_metadata or {} + if meta.get("article_seed") and meta.get("rss_item_id"): + seed_meta = meta + break + if seed_meta is None: + return + item_id = int(seed_meta["rss_item_id"]) + item = await session.get(_RssItem, item_id) + if item is None or item.discussion_note_id is not None: + return + article_title = (item.title or "Untitled article").strip() + article_url = item.url + article_topics = list(item.topics or []) + + note_title = f"Article: {article_title}"[:200] + body_parts = [f"**Source:** {article_url}"] if article_url else [] + body_parts.append(reply_content.strip()) + note_body = "\n\n".join(body_parts) + tags = ["article-summary"] + [t for t in article_topics if t] + note = await create_note( + user_id=user_id, + title=note_title, + body=note_body, + tags=tags, + entity_meta={ + "source": "article_discussion", + "rss_item_id": item_id, + "url": article_url, + "conversation_id": conv_id, + }, + ) + + async with async_session() as session: + fresh = await session.get(_RssItem, item_id) + if fresh is not None and fresh.discussion_note_id is None: + fresh.discussion_note_id = note.id + await session.commit() + logger.info( + "Saved article-discussion summary as note %d for rss_item %d (conv %d)", + note.id, item_id, conv_id, + ) + except Exception: + logger.warning( + "Failed to persist article-discussion note for conv %d", + conv_id, exc_info=True, + ) + # --------------------------------------------------------------------------- # Thinking decision # --------------------------------------------------------------------------- @@ -526,6 +605,18 @@ async def run_generation( msg_count = len(non_system) should_gen_title = not conv_title or (msg_count > 0 and msg_count % 10 == 0) + # Persist article-discussion seed conversations as a Note on their + # first assistant reply. This makes "Discuss" summaries part of RAG + # so the knowledge base stops being amnesiac about articles the user + # has already engaged with. The hook detects a seeded conversation by + # finding a synthetic read_article assistant message whose + # msg_metadata carries ``article_seed: True`` and whose rss_items row + # has no discussion_note_id yet. Fire-and-forget so the done event + # lands immediately. + asyncio.create_task(_maybe_save_article_discussion_note( + user_id, conv_id, buf.content_so_far, + )) + if should_gen_title: # Feed the title model the *raw* conversation turns only — never # the post-build_context ``messages`` list. ``build_context``