fc6ebf81eb
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
250 lines
9.7 KiB
Python
250 lines
9.7 KiB
Python
"""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):
|
|
"""Build a Message-like stand-in for the filter helpers."""
|
|
metadata = {"kind": kind} if kind else 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
|
|
|
|
msgs = [
|
|
_msg("assistant", "Good morning — here is today's plan…", kind="daily_prep"),
|
|
_msg("user", "I want to focus on the auth refactor today."),
|
|
_msg("assistant", "Got it. I'll keep tool calls quiet."),
|
|
]
|
|
kept = _filter_messages(msgs)
|
|
contents = [m.content for m in kept]
|
|
assert "Good morning — here is today's plan…" not in contents
|
|
assert "I want to focus on the auth refactor today." in contents
|
|
assert "Got it. I'll keep tool calls quiet." in contents
|
|
|
|
|
|
def test_build_transcript_labels_roles_and_caps_content():
|
|
from fabledassistant.services.journal_closeout import _build_transcript
|
|
|
|
msgs = [
|
|
_msg("user", "hello"),
|
|
_msg("assistant", "hi"),
|
|
_msg("user", "x" * 600),
|
|
]
|
|
out = _build_transcript(msgs)
|
|
lines = out.splitlines()
|
|
assert lines[0] == "USER: hello"
|
|
assert lines[1] == "ASSISTANT: hi"
|
|
# Third line content truncated to 500 chars
|
|
assert lines[2].startswith("USER: ")
|
|
assert len(lines[2]) == len("USER: ") + 500
|
|
|
|
|
|
def test_build_transcript_keeps_only_last_20_messages():
|
|
from fabledassistant.services.journal_closeout import _build_transcript
|
|
|
|
msgs = [_msg("user", f"msg-{i}") for i in range(30)]
|
|
out = _build_transcript(msgs)
|
|
lines = out.splitlines()
|
|
assert len(lines) == 20
|
|
# Newest 20 means msg-10 through msg-29
|
|
assert lines[0] == "USER: msg-10"
|
|
assert lines[-1] == "USER: msg-29"
|
|
|
|
|
|
def test_system_prompt_lists_structured_fields_to_exclude():
|
|
"""The prompt must explicitly tell the LLM not to restate structured
|
|
fields, so the freeform learned_summary stays a narrow lane."""
|
|
from fabledassistant.services.journal_closeout import SYSTEM_PROMPT
|
|
|
|
text = SYSTEM_PROMPT.lower()
|
|
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")
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_for_user_skips_when_no_conversation():
|
|
from fabledassistant.services import journal_closeout
|
|
|
|
with (
|
|
patch.object(journal_closeout, "async_session", return_value=_patch_db([], conv=None)),
|
|
patch.object(journal_closeout, "append_observations", new=AsyncMock()) as mock_append,
|
|
patch.object(journal_closeout, "generate_completion", new=AsyncMock()) as mock_llm,
|
|
):
|
|
await journal_closeout.run_for_user(user_id=42, yesterday=datetime.date(2026, 5, 11))
|
|
|
|
mock_append.assert_not_awaited()
|
|
mock_llm.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_for_user_skips_when_only_prep_message_after_filter():
|
|
"""If only the daily_prep message exists, the in-Python filter pass
|
|
leaves zero messages and we short-circuit before the LLM."""
|
|
from fabledassistant.services import journal_closeout
|
|
|
|
msgs_post_sql = [
|
|
_msg("assistant", "Daily prep block.", kind="daily_prep"),
|
|
]
|
|
with (
|
|
patch.object(journal_closeout, "async_session", return_value=_patch_db(msgs_post_sql, conv=_fake_conv())),
|
|
patch.object(journal_closeout, "append_observations", new=AsyncMock()) as mock_append,
|
|
patch.object(journal_closeout, "generate_completion", new=AsyncMock()) as mock_llm,
|
|
):
|
|
await journal_closeout.run_for_user(user_id=42, yesterday=datetime.date(2026, 5, 11))
|
|
|
|
mock_append.assert_not_awaited()
|
|
mock_llm.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_run_for_user_respects_nothing_to_note_sentinel():
|
|
from fabledassistant.services import journal_closeout
|
|
|
|
msgs = [_msg("user", "hi"), _msg("assistant", "hi back")]
|
|
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="(nothing to note)")),
|
|
patch.object(journal_closeout, "append_observations", new=AsyncMock()) as mock_append,
|
|
):
|
|
await journal_closeout.run_for_user(user_id=42, yesterday=datetime.date(2026, 5, 11))
|
|
|
|
mock_append.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_user_schedule_registers_closeout_when_enabled(monkeypatch):
|
|
from fabledassistant.services import journal_scheduler as sched
|
|
|
|
fake_scheduler = MagicMock()
|
|
fake_scheduler.get_job = MagicMock(return_value=None)
|
|
monkeypatch.setattr(sched, "_scheduler", fake_scheduler)
|
|
monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={
|
|
"prep_enabled": False, # isolate the closeout job
|
|
"closeout_enabled": True,
|
|
"day_rollover_hour": 4,
|
|
}))
|
|
monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC"))
|
|
|
|
await sched.update_user_schedule(user_id=7)
|
|
|
|
added = [c.kwargs.get("id") for c in fake_scheduler.add_job.call_args_list]
|
|
assert "journal_closeout_7" in added
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_update_user_schedule_does_not_register_closeout_when_disabled(monkeypatch):
|
|
from fabledassistant.services import journal_scheduler as sched
|
|
|
|
fake_scheduler = MagicMock()
|
|
fake_scheduler.get_job = MagicMock(return_value=None)
|
|
monkeypatch.setattr(sched, "_scheduler", fake_scheduler)
|
|
monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={
|
|
"prep_enabled": False,
|
|
"closeout_enabled": False,
|
|
"day_rollover_hour": 4,
|
|
}))
|
|
monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC"))
|
|
|
|
await sched.update_user_schedule(user_id=7)
|
|
|
|
added = [c.kwargs.get("id") for c in fake_scheduler.add_job.call_args_list]
|
|
assert "journal_closeout_7" not in added
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_closeout_catchup_skips_when_already_have_entry(monkeypatch):
|
|
from fabledassistant.services import journal_scheduler as sched
|
|
|
|
monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC"))
|
|
monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={
|
|
"closeout_enabled": True,
|
|
"day_rollover_hour": 0, # slot has always passed
|
|
}))
|
|
yesterday = (datetime.datetime.now(datetime.timezone.utc) - datetime.timedelta(days=1)).date()
|
|
fake_profile = SimpleNamespace(observations_raw=[{"date": yesterday.isoformat(), "bullets": "..."}])
|
|
|
|
from fabledassistant.services import user_profile as up
|
|
monkeypatch.setattr(up, "get_profile", AsyncMock(return_value=fake_profile))
|
|
|
|
run_mock = AsyncMock()
|
|
from fabledassistant.services import journal_closeout as jc
|
|
monkeypatch.setattr(jc, "run_for_user", run_mock)
|
|
|
|
await sched._closeout_catchup(user_id=7)
|
|
run_mock.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_closeout_catchup_runs_when_no_entry_yet(monkeypatch):
|
|
from fabledassistant.services import journal_scheduler as sched
|
|
|
|
monkeypatch.setattr(sched, "get_user_timezone", AsyncMock(return_value="UTC"))
|
|
monkeypatch.setattr(sched, "get_journal_config", AsyncMock(return_value={
|
|
"closeout_enabled": True,
|
|
"day_rollover_hour": 0,
|
|
}))
|
|
fake_profile = SimpleNamespace(observations_raw=[])
|
|
|
|
from fabledassistant.services import user_profile as up
|
|
monkeypatch.setattr(up, "get_profile", AsyncMock(return_value=fake_profile))
|
|
|
|
run_mock = AsyncMock()
|
|
from fabledassistant.services import journal_closeout as jc
|
|
monkeypatch.setattr(jc, "run_for_user", run_mock)
|
|
|
|
await sched._closeout_catchup(user_id=7)
|
|
run_mock.assert_awaited_once()
|