"""Journal closeout — nightly extraction of profile observations. Runs once per user per day at day_rollover_hour. Reads yesterday's /journal conversation, filters out assistant-authored auto-content (daily prep), asks the background LLM to extract user-side patterns/habits, and appends 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." EXCLUDED_KINDS: set[str] = {"daily_prep"} def _filter_messages(messages): """Drop messages whose msg_metadata.kind is in EXCLUDED_KINDS. Accepts any iterable of message-like objects with `role`, `content`, and `msg_metadata` attributes (real Message rows or SimpleNamespace). """ kept = [] for m in messages: meta = getattr(m, "msg_metadata", None) or {} if meta.get("kind") in EXCLUDED_KINDS: continue kept.append(m) return kept _TRANSCRIPT_WINDOW = 20 _CONTENT_CAP = 500 def _build_transcript(messages) -> str: """Format the last 20 messages as `ROLE: content[:500]` lines.""" tail = list(messages)[-_TRANSCRIPT_WINDOW:] return "\n".join( f"{m.role.upper()}: {m.content[:_CONTENT_CAP]}" for m in tail ) SYSTEM_PROMPT = ( "You are reviewing a day's journal conversation to extract preference " "observations the USER revealed about themselves.\n\n" "Rules:\n" "- Only extract patterns, habits, recurring frustrations, or contextual " "facts the user said or demonstrated.\n" "- DO NOT restate facts that belong in structured fields: name, job title, " "industry, expertise level, response style, tone, interests. Those are " "handled separately.\n" "- DO NOT extract anything from the ASSISTANT turns about the user — only " "what the user themselves stated or demonstrated by their choices.\n" "- 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)