diff --git a/src/fabledassistant/services/journal_closeout.py b/src/fabledassistant/services/journal_closeout.py index 5155abe..467af9f 100644 --- a/src/fabledassistant/services/journal_closeout.py +++ b/src/fabledassistant/services/journal_closeout.py @@ -7,6 +7,20 @@ the bullets to user_profiles.observations_raw via append_observations. """ from __future__ import annotations +import datetime +import logging + +from sqlalchemy import or_, select + +from fabledassistant.config import Config +from fabledassistant.models import async_session +from fabledassistant.models.conversation import Conversation, Message +from fabledassistant.services.llm import generate_completion +from fabledassistant.services.settings import get_setting +from fabledassistant.services.user_profile import append_observations + +logger = logging.getLogger(__name__) + # Message kinds whose content must NEVER be sent to the closeout LLM. # These are assistant-authored auto-blocks that would otherwise dominate # attention and leak back into "what the assistant has learned." @@ -54,3 +68,66 @@ SYSTEM_PROMPT = ( "- Write 2-5 short bullet points. Be specific and factual.\n" "- If nothing notable, output only: (nothing to note)" ) + + +async def run_for_user(user_id: int, yesterday: datetime.date) -> None: + """Extract preference observations from yesterday's journal conversation. + + Skips silently when there is nothing meaningful to extract. + """ + async with async_session() as session: + conv_result = await session.execute( + select(Conversation).where( + Conversation.user_id == user_id, + Conversation.conversation_type == "journal", + Conversation.day_date == yesterday, + ) + ) + conv = conv_result.scalar_one_or_none() + if conv is None: + logger.debug("closeout: no journal conv for user %d on %s", user_id, yesterday) + return + + msg_result = await session.execute( + select(Message) + .where( + Message.conversation_id == conv.id, + Message.role.in_(("user", "assistant")), + or_( + Message.msg_metadata.is_(None), + ~Message.msg_metadata["kind"].astext.in_(EXCLUDED_KINDS), + ), + ) + .order_by(Message.created_at) + ) + messages = list(msg_result.scalars().all()) + + # Defensive second-pass filter (covers any message with metadata the + # SQL JSON path can't reach, e.g. older rows where kind nesting differs). + messages = _filter_messages(messages) + + if len(messages) < 2: + logger.debug("closeout: not enough messages for user %d (%d)", user_id, len(messages)) + return + + transcript = _build_transcript(messages) + model = await get_setting(user_id, "background_model", Config.OLLAMA_BACKGROUND_MODEL) + + try: + output = (await generate_completion( + [ + {"role": "system", "content": SYSTEM_PROMPT}, + {"role": "user", "content": transcript}, + ], + model, + )).strip() + except Exception: + logger.warning("closeout LLM failed for user %d", user_id, exc_info=True) + return + + if not output or "(nothing to note)" in output.lower(): + logger.debug("closeout: nothing to note for user %d", user_id) + return + + await append_observations(user_id, output) + logger.info("closeout: appended observations for user %d (%s)", user_id, yesterday) diff --git a/tests/test_journal_closeout.py b/tests/test_journal_closeout.py index 6cd4b33..c7e81fd 100644 --- a/tests/test_journal_closeout.py +++ b/tests/test_journal_closeout.py @@ -1,6 +1,10 @@ """Tests for journal closeout extraction helpers.""" +import datetime from types import SimpleNamespace +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest def _msg(role: str, content: str, kind: str | None = None): @@ -9,6 +13,29 @@ def _msg(role: str, content: str, kind: str | None = None): return SimpleNamespace(role=role, content=content, msg_metadata=metadata) +def _fake_conv(conv_id: int = 1): + return SimpleNamespace(id=conv_id) + + +def _patch_db(messages, conv=None): + """Build a context manager that fakes async_session() and the two + select() calls (conversation lookup + messages query).""" + session = AsyncMock() + session.execute = AsyncMock() + conv_result = MagicMock() + conv_result.scalar_one_or_none = MagicMock(return_value=conv) + msg_result = MagicMock() + scalars = MagicMock() + scalars.all = MagicMock(return_value=list(messages)) + msg_result.scalars = MagicMock(return_value=scalars) + session.execute.side_effect = [conv_result, msg_result] + + session_ctx = MagicMock() + session_ctx.__aenter__ = AsyncMock(return_value=session) + session_ctx.__aexit__ = AsyncMock(return_value=None) + return session_ctx + + def test_filter_excludes_daily_prep_messages(): from fabledassistant.services.journal_closeout import _filter_messages @@ -62,3 +89,25 @@ def test_system_prompt_lists_structured_fields_to_exclude(): for field in ("name", "job title", "industry", "expertise", "response style", "tone", "interests"): assert field in text, f"system prompt should mention '{field}'" assert "(nothing to note)" in SYSTEM_PROMPT + + +@pytest.mark.asyncio +async def test_run_for_user_happy_path_appends_bullets(): + from fabledassistant.services import journal_closeout + + yesterday = datetime.date(2026, 5, 11) + msgs = [ + _msg("assistant", "Good morning — here's today.", kind="daily_prep"), + _msg("user", "Skip the news section — I never read it."), + _msg("assistant", "Noted. Will drop it."), + ] + + with ( + patch.object(journal_closeout, "async_session", return_value=_patch_db(msgs, conv=_fake_conv())), + patch.object(journal_closeout, "get_setting", new=AsyncMock(return_value="bg-model")), + patch.object(journal_closeout, "generate_completion", new=AsyncMock(return_value="- User skips news section")), + patch.object(journal_closeout, "append_observations", new=AsyncMock()) as mock_append, + ): + await journal_closeout.run_for_user(user_id=42, yesterday=yesterday) + + mock_append.assert_awaited_once_with(42, "- User skips news section")