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:
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user