4403026797
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
134 lines
4.8 KiB
Python
134 lines
4.8 KiB
Python
"""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)
|