diff --git a/src/fabledassistant/services/generation_task.py b/src/fabledassistant/services/generation_task.py index 04fa395..c696cac 100644 --- a/src/fabledassistant/services/generation_task.py +++ b/src/fabledassistant/services/generation_task.py @@ -185,6 +185,7 @@ async def run_generation( rag_project_id=rag_project_id, workspace_project_id=workspace_project_id, user_timezone=user_timezone, + conv_id=conv_id, )) messages, context_meta = await context_task diff --git a/src/fabledassistant/services/llm.py b/src/fabledassistant/services/llm.py index 3d55624..9903f96 100644 --- a/src/fabledassistant/services/llm.py +++ b/src/fabledassistant/services/llm.py @@ -453,6 +453,7 @@ async def build_context( rag_project_id: int | None = None, workspace_project_id: int | None = None, user_timezone: str | None = None, + conv_id: int | None = None, ) -> tuple[list[dict], dict]: """Build messages array for Ollama with system prompt and context. @@ -676,7 +677,88 @@ async def build_context( f"\n\n--- Earlier Conversation ---\n{history_summary}\n--- End Earlier Conversation ---" ) + # Inject briefing article content for follow-up Q&A + if conv_id is not None: + from fabledassistant.models import async_session as _async_session + from fabledassistant.models.conversation import Conversation + + async with _async_session() as _sess: + _conv = await _sess.get(Conversation, conv_id) + if _conv and getattr(_conv, "conversation_type", None) == "briefing": + article_context = await _build_briefing_article_context(conv_id) + if article_context: + system_parts.append(article_context) + messages = [{"role": "system", "content": "".join(system_parts)}] messages.extend(history) messages.append({"role": "user", "content": user_message}) return messages, context_meta + + +async def _build_briefing_article_context(conv_id: int) -> str: + """Fetch article content from today's briefing message and return a + formatted context block for injection into the system prompt. + + Looks at the most recent assistant briefing messages for rss_item_ids + in their metadata, then loads those items from the DB. + Capped at 10 articles × 500 chars to keep token use reasonable. + """ + import json as _json + + from sqlalchemy import select, text as _text + + from fabledassistant.models import async_session as _async_session + from fabledassistant.models.conversation import Message + + async with _async_session() as session: + result = await session.execute( + select(Message) + .where( + Message.conversation_id == conv_id, + Message.role == "assistant", + ) + .order_by(Message.created_at.desc()) + .limit(10) + ) + messages = result.scalars().all() + + rss_item_ids: list[int] = [] + for msg in messages: + meta = msg.msg_metadata or {} + if isinstance(meta, str): + try: + meta = _json.loads(meta) + except Exception: + continue + ids = meta.get("rss_item_ids") or [] + if ids: + rss_item_ids = ids + break + + if not rss_item_ids: + return "" + + async with _async_session() as session: + result = await session.execute( + _text(""" + SELECT i.title, i.url, i.content, f.title AS feed_title + FROM rss_items i + JOIN rss_feeds f ON f.id = i.feed_id + WHERE i.id = ANY(:ids) + ORDER BY i.published_at DESC NULLS LAST + LIMIT 10 + """).bindparams(ids=rss_item_ids[:10]) + ) + rows = result.mappings().all() + + if not rows: + return "" + + lines = ["\n\nARTICLE CONTEXT (source articles from today's briefing):"] + for row in rows: + lines.append(f"\n[{row['feed_title']}] {row['title']}") + if row["url"]: + lines.append(f"URL: {row['url']}") + if row["content"]: + lines.append(row["content"][:500]) + return "\n".join(lines) diff --git a/tests/test_news_api.py b/tests/test_news_api.py index b7bec2e..533fc64 100644 --- a/tests/test_news_api.py +++ b/tests/test_news_api.py @@ -56,6 +56,28 @@ def test_list_news_item_serialisation(): assert result["published_at"] == pub.isoformat() + +def test_build_briefing_article_context_metadata_extraction(): + """Verify the rss_item_ids extraction logic from message metadata.""" + import json + + # Simulates the logic in _build_briefing_article_context + def extract_ids(msg_metadata): + meta = msg_metadata or {} + if isinstance(meta, str): + try: + meta = json.loads(meta) + except Exception: + return [] + return meta.get("rss_item_ids") or [] + + assert extract_ids({}) == [] + assert extract_ids(None) == [] + assert extract_ids({"rss_item_ids": [1, 2, 3]}) == [1, 2, 3] + assert extract_ids(json.dumps({"rss_item_ids": [4, 5]})) == [4, 5] + assert extract_ids("not json") == [] + + def test_list_news_null_content_serialisation(): """Null content should produce empty snippet without error.""" item = {