feat(journal): run_for_user orchestrates closeout extraction

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-12 14:34:42 -04:00
parent 552943d6c0
commit 4403026797
2 changed files with 126 additions and 0 deletions
@@ -7,6 +7,20 @@ the bullets to user_profiles.observations_raw via append_observations.
""" """
from __future__ import annotations 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. # Message kinds whose content must NEVER be sent to the closeout LLM.
# These are assistant-authored auto-blocks that would otherwise dominate # These are assistant-authored auto-blocks that would otherwise dominate
# attention and leak back into "what the assistant has learned." # 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" "- Write 2-5 short bullet points. Be specific and factual.\n"
"- If nothing notable, output only: (nothing to note)" "- 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)
+49
View File
@@ -1,6 +1,10 @@
"""Tests for journal closeout extraction helpers.""" """Tests for journal closeout extraction helpers."""
import datetime
from types import SimpleNamespace from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
def _msg(role: str, content: str, kind: str | None = None): 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) 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(): def test_filter_excludes_daily_prep_messages():
from fabledassistant.services.journal_closeout import _filter_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"): for field in ("name", "job title", "industry", "expertise", "response style", "tone", "interests"):
assert field in text, f"system prompt should mention '{field}'" assert field in text, f"system prompt should mention '{field}'"
assert "(nothing to note)" in SYSTEM_PROMPT 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")