From c5b0344240f13d462c90276f1acffe94dd8247ba Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 12 May 2026 14:32:40 -0400 Subject: [PATCH 01/23] feat(journal): add closeout_enabled to default config Co-Authored-By: Claude Opus 4.7 (1M context) --- src/fabledassistant/services/journal_scheduler.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/fabledassistant/services/journal_scheduler.py b/src/fabledassistant/services/journal_scheduler.py index 8130207..b7f8a24 100644 --- a/src/fabledassistant/services/journal_scheduler.py +++ b/src/fabledassistant/services/journal_scheduler.py @@ -35,6 +35,7 @@ DEFAULT_CONFIG = { "day_rollover_hour": 4, "morning_end_hour": 12, "midday_end_hour": 18, + "closeout_enabled": True, } From e17fc088b204e403674116ab1c435efd310b85a7 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 12 May 2026 14:33:03 -0400 Subject: [PATCH 02/23] feat(journal): journal_closeout._filter_messages excludes daily_prep Co-Authored-By: Claude Opus 4.7 (1M context) --- .../services/journal_closeout.py | 28 +++++++++++++++++++ tests/test_journal_closeout.py | 24 ++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 src/fabledassistant/services/journal_closeout.py create mode 100644 tests/test_journal_closeout.py diff --git a/src/fabledassistant/services/journal_closeout.py b/src/fabledassistant/services/journal_closeout.py new file mode 100644 index 0000000..7e3a47e --- /dev/null +++ b/src/fabledassistant/services/journal_closeout.py @@ -0,0 +1,28 @@ +"""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 + +# 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 diff --git a/tests/test_journal_closeout.py b/tests/test_journal_closeout.py new file mode 100644 index 0000000..e8a27fe --- /dev/null +++ b/tests/test_journal_closeout.py @@ -0,0 +1,24 @@ +"""Tests for journal closeout extraction helpers.""" + +from types import SimpleNamespace + + +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 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 From 2576be9e4939d641d1645f354c71f90952206b27 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 12 May 2026 14:33:27 -0400 Subject: [PATCH 03/23] feat(journal): _build_transcript caps content and message window Co-Authored-By: Claude Opus 4.7 (1M context) --- .remember/tmp/save-session.pid | 2 +- fable-mcp/uv.lock | 2 +- .../services/journal_closeout.py | 12 ++++++++ tests/test_journal_closeout.py | 29 +++++++++++++++++++ 4 files changed, 43 insertions(+), 2 deletions(-) diff --git a/.remember/tmp/save-session.pid b/.remember/tmp/save-session.pid index 937c971..9bc105c 100644 --- a/.remember/tmp/save-session.pid +++ b/.remember/tmp/save-session.pid @@ -1 +1 @@ -2298268 +3158517 diff --git a/fable-mcp/uv.lock b/fable-mcp/uv.lock index 3b2a4f9..046e9b5 100644 --- a/fable-mcp/uv.lock +++ b/fable-mcp/uv.lock @@ -184,7 +184,7 @@ wheels = [ [[package]] name = "fable-mcp" -version = "0.2.6" +version = "0.3.0" source = { editable = "." } dependencies = [ { name = "httpx" }, diff --git a/src/fabledassistant/services/journal_closeout.py b/src/fabledassistant/services/journal_closeout.py index 7e3a47e..843e05c 100644 --- a/src/fabledassistant/services/journal_closeout.py +++ b/src/fabledassistant/services/journal_closeout.py @@ -26,3 +26,15 @@ def _filter_messages(messages): 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 + ) diff --git a/tests/test_journal_closeout.py b/tests/test_journal_closeout.py index e8a27fe..9e96d84 100644 --- a/tests/test_journal_closeout.py +++ b/tests/test_journal_closeout.py @@ -22,3 +22,32 @@ def test_filter_excludes_daily_prep_messages(): 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" From 552943d6c021bd8d8261e3fa1cee83d676889c67 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 12 May 2026 14:33:57 -0400 Subject: [PATCH 04/23] feat(journal): closeout system prompt with structured-field guard Co-Authored-By: Claude Opus 4.7 (1M context) --- src/fabledassistant/services/journal_closeout.py | 16 ++++++++++++++++ tests/test_journal_closeout.py | 11 +++++++++++ 2 files changed, 27 insertions(+) diff --git a/src/fabledassistant/services/journal_closeout.py b/src/fabledassistant/services/journal_closeout.py index 843e05c..5155abe 100644 --- a/src/fabledassistant/services/journal_closeout.py +++ b/src/fabledassistant/services/journal_closeout.py @@ -38,3 +38,19 @@ def _build_transcript(messages) -> str: 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)" +) diff --git a/tests/test_journal_closeout.py b/tests/test_journal_closeout.py index 9e96d84..6cd4b33 100644 --- a/tests/test_journal_closeout.py +++ b/tests/test_journal_closeout.py @@ -51,3 +51,14 @@ def test_build_transcript_keeps_only_last_20_messages(): # 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 From 44030267973df3604dde52e98138759eb5056599 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 12 May 2026 14:34:42 -0400 Subject: [PATCH 05/23] feat(journal): run_for_user orchestrates closeout extraction Co-Authored-By: Claude Opus 4.7 (1M context) --- .../services/journal_closeout.py | 77 +++++++++++++++++++ tests/test_journal_closeout.py | 49 ++++++++++++ 2 files changed, 126 insertions(+) diff --git a/src/fabledassistant/services/journal_closeout.py b/src/fabledassistant/services/journal_closeout.py index 5155abe..467af9f 100644 --- a/src/fabledassistant/services/journal_closeout.py +++ b/src/fabledassistant/services/journal_closeout.py @@ -7,6 +7,20 @@ 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." @@ -54,3 +68,66 @@ SYSTEM_PROMPT = ( "- 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) diff --git a/tests/test_journal_closeout.py b/tests/test_journal_closeout.py index 6cd4b33..c7e81fd 100644 --- a/tests/test_journal_closeout.py +++ b/tests/test_journal_closeout.py @@ -1,6 +1,10 @@ """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): @@ -9,6 +13,29 @@ def _msg(role: str, content: str, kind: str | None = 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 @@ -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"): 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") From 020bd6614bde542278d5d2e3938615a4355f5a1a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 12 May 2026 14:34:58 -0400 Subject: [PATCH 06/23] test(journal): cover closeout skip paths (no conv / prep-only / sentinel) Co-Authored-By: Claude Opus 4.7 (1M context) --- tests/test_journal_closeout.py | 51 ++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/tests/test_journal_closeout.py b/tests/test_journal_closeout.py index c7e81fd..3ef2be8 100644 --- a/tests/test_journal_closeout.py +++ b/tests/test_journal_closeout.py @@ -111,3 +111,54 @@ async def test_run_for_user_happy_path_appends_bullets(): 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() From b88d5ee6b36dedea3b08185f3aec5a9de7fb9fd1 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 12 May 2026 14:35:57 -0400 Subject: [PATCH 07/23] feat(journal): register per-user closeout job at day_rollover_hour Co-Authored-By: Claude Opus 4.7 (1M context) --- .../services/journal_scheduler.py | 67 ++++++++++++++----- tests/test_journal_closeout.py | 40 +++++++++++ 2 files changed, 91 insertions(+), 16 deletions(-) diff --git a/src/fabledassistant/services/journal_scheduler.py b/src/fabledassistant/services/journal_scheduler.py index b7f8a24..b569f94 100644 --- a/src/fabledassistant/services/journal_scheduler.py +++ b/src/fabledassistant/services/journal_scheduler.py @@ -89,30 +89,65 @@ def _run_daily_prep_threadsafe(user_id: int) -> None: asyncio.run_coroutine_threadsafe(_do_daily_prep(user_id), _loop) +async def _do_closeout(user_id: int) -> None: + try: + tz_str = await get_user_timezone(user_id) + tz = _resolve_tz(tz_str) + now = datetime.datetime.now(tz) + # We just rolled into a new day in user-local time. The day that + # just ended is yesterday's calendar date regardless of whether + # rollover_hour is 0 or 4 — APScheduler fires precisely at the + # configured hour so no clock-skew correction is needed. + yesterday = now.date() - datetime.timedelta(days=1) + from fabledassistant.services.journal_closeout import run_for_user + await run_for_user(user_id=user_id, yesterday=yesterday) + except Exception: + logger.exception("Closeout failed for user %d", user_id) + + +def _run_closeout_threadsafe(user_id: int) -> None: + if _loop is None: + return + asyncio.run_coroutine_threadsafe(_do_closeout(user_id), _loop) + + async def update_user_schedule(user_id: int) -> None: - """Add or replace this user's daily-prep job using their current config.""" + """Add or replace this user's daily-prep + closeout jobs from current config.""" if _scheduler is None: return - job_id = f"journal_prep_{user_id}" - if _scheduler.get_job(job_id): - _scheduler.remove_job(job_id) config = await get_journal_config(user_id) - if not config.get("prep_enabled", True): - return - tz_str = await get_user_timezone(user_id) tz = _resolve_tz(tz_str) - prep_hour = int(config.get("prep_hour", 5)) - prep_minute = int(config.get("prep_minute", 0)) - _scheduler.add_job( - _run_daily_prep_threadsafe, - trigger=CronTrigger(hour=prep_hour, minute=prep_minute, timezone=tz), - args=[user_id], - id=job_id, - replace_existing=True, - ) + # ── Prep job ────────────────────────────────────────────────────────── + prep_job_id = f"journal_prep_{user_id}" + if _scheduler.get_job(prep_job_id): + _scheduler.remove_job(prep_job_id) + if config.get("prep_enabled", True): + prep_hour = int(config.get("prep_hour", 5)) + prep_minute = int(config.get("prep_minute", 0)) + _scheduler.add_job( + _run_daily_prep_threadsafe, + trigger=CronTrigger(hour=prep_hour, minute=prep_minute, timezone=tz), + args=[user_id], + id=prep_job_id, + replace_existing=True, + ) + + # ── Closeout job ────────────────────────────────────────────────────── + closeout_job_id = f"journal_closeout_{user_id}" + if _scheduler.get_job(closeout_job_id): + _scheduler.remove_job(closeout_job_id) + if config.get("closeout_enabled", True): + rollover_hour = int(config.get("day_rollover_hour", 4)) + _scheduler.add_job( + _run_closeout_threadsafe, + trigger=CronTrigger(hour=rollover_hour, minute=0, timezone=tz), + args=[user_id], + id=closeout_job_id, + replace_existing=True, + ) async def _register_all_user_jobs() -> None: diff --git a/tests/test_journal_closeout.py b/tests/test_journal_closeout.py index 3ef2be8..d9f788f 100644 --- a/tests/test_journal_closeout.py +++ b/tests/test_journal_closeout.py @@ -162,3 +162,43 @@ async def test_run_for_user_respects_nothing_to_note_sentinel(): 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 From fc6ebf81eb53787b0233d14f8634409b0e2c4077 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 12 May 2026 14:36:29 -0400 Subject: [PATCH 08/23] feat(journal): closeout catch-up on startup when slot already passed Co-Authored-By: Claude Opus 4.7 (1M context) --- .../services/journal_scheduler.py | 34 ++++++++++++++ tests/test_journal_closeout.py | 45 +++++++++++++++++++ 2 files changed, 79 insertions(+) diff --git a/src/fabledassistant/services/journal_scheduler.py b/src/fabledassistant/services/journal_scheduler.py index b569f94..f5bb45b 100644 --- a/src/fabledassistant/services/journal_scheduler.py +++ b/src/fabledassistant/services/journal_scheduler.py @@ -111,6 +111,37 @@ def _run_closeout_threadsafe(user_id: int) -> None: asyncio.run_coroutine_threadsafe(_do_closeout(user_id), _loop) +async def _closeout_catchup(user_id: int) -> None: + """On startup, run yesterday's closeout once if the slot already passed + and no entry for yesterday exists in observations_raw. + """ + try: + tz_str = await get_user_timezone(user_id) + tz = _resolve_tz(tz_str) + config = await get_journal_config(user_id) + if not config.get("closeout_enabled", True): + return + rollover_hour = int(config.get("day_rollover_hour", 4)) + now = datetime.datetime.now(tz) + # Slot hasn't passed yet today → wait for the cron. + if now.hour < rollover_hour: + return + yesterday = (now - datetime.timedelta(days=1)).date() + + from fabledassistant.services.user_profile import get_profile + profile = await get_profile(user_id) + existing_dates = { + (e or {}).get("date") for e in (profile.observations_raw or []) + } + if yesterday.isoformat() in existing_dates: + return + + from fabledassistant.services.journal_closeout import run_for_user + await run_for_user(user_id=user_id, yesterday=yesterday) + except Exception: + logger.exception("Closeout catch-up failed for user %d", user_id) + + async def update_user_schedule(user_id: int) -> None: """Add or replace this user's daily-prep + closeout jobs from current config.""" if _scheduler is None: @@ -155,6 +186,9 @@ async def _register_all_user_jobs() -> None: users = (await session.execute(select(User))).scalars().all() for user in users: await update_user_schedule(user.id) + # Fire catch-up asynchronously so a slow LLM call doesn't block startup + if _loop is not None: + asyncio.run_coroutine_threadsafe(_closeout_catchup(user.id), _loop) def start_journal_scheduler(loop: asyncio.AbstractEventLoop) -> None: diff --git a/tests/test_journal_closeout.py b/tests/test_journal_closeout.py index d9f788f..8b556f8 100644 --- a/tests/test_journal_closeout.py +++ b/tests/test_journal_closeout.py @@ -202,3 +202,48 @@ async def test_update_user_schedule_does_not_register_closeout_when_disabled(mon 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() From 4e9eead3ab3c4b60cc668f2189c504c00f414541 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 12 May 2026 14:36:42 -0400 Subject: [PATCH 09/23] feat(profile): GET /api/profile/observations returns recent raw entries Co-Authored-By: Claude Opus 4.7 (1M context) --- src/fabledassistant/routes/profile.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/fabledassistant/routes/profile.py b/src/fabledassistant/routes/profile.py index 4359599..56ea9d2 100644 --- a/src/fabledassistant/routes/profile.py +++ b/src/fabledassistant/routes/profile.py @@ -59,3 +59,13 @@ async def clear_observations(): uid = get_current_user_id() await clear_learned_data(uid) return jsonify({"status": "ok"}) + + +@profile_bp.route("/observations", methods=["GET"]) +@login_required +async def list_observations(): + uid = get_current_user_id() + profile = await get_profile(uid) + raw = list(profile.observations_raw or []) + # Newest first, last 14 entries + return jsonify({"observations": list(reversed(raw[-14:]))}) From 090b7d83dd9aa548cd2117e6fbcacd1559697767 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 12 May 2026 14:43:27 -0400 Subject: [PATCH 10/23] feat(frontend): API client for profile observations + closeout flag Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/api/client.ts | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index f6e0819..980cc66 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -317,6 +317,7 @@ export interface JournalConfig { day_rollover_hour: number; morning_end_hour?: number; midday_end_hour?: number; + closeout_enabled?: boolean; // Ambient-context fields (carried forward from the briefing config schema) locations?: { home?: JournalLocation; work?: JournalLocation }; temp_unit?: 'C' | 'F'; @@ -696,3 +697,11 @@ export const updateProfile = (data: Partial) => export const consolidateProfile = () => apiPost<{ status: string; learned_summary: string }>('/api/profile/consolidate', {}) export const clearProfileObservations = () => apiDelete('/api/profile/observations') + +export interface ProfileObservationEntry { + date: string + bullets: string +} + +export const listProfileObservations = () => + apiGet<{ observations: ProfileObservationEntry[] }>('/api/profile/observations') From c663532fd45f85a16f995f2884bdeb269715b581 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 12 May 2026 14:43:59 -0400 Subject: [PATCH 11/23] feat(profile): Settings toggle for nightly journal closeout Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/views/SettingsView.vue | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 084ee11..422eb99 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -582,6 +582,17 @@ function toggleProfileWorkDay(day: string) { profile.value.work_schedule = { ...profile.value.work_schedule, days } } +async function onToggleCloseout(enabled: boolean) { + journalConfig.value.closeout_enabled = enabled + try { + await saveJournalConfig(journalConfig.value) + toastStore.show(enabled ? 'Nightly closeout enabled' : 'Nightly closeout disabled') + } catch { + journalConfig.value.closeout_enabled = !enabled // revert UI on failure + toastStore.show('Failed to update closeout setting', 'error') + } +} + async function runConsolidate() { consolidating.value = true try { @@ -600,6 +611,7 @@ const journalConfig = ref({ prep_hour: 5, prep_minute: 0, day_rollover_hour: 4, + closeout_enabled: true, locations: { home: { label: 'Home', address: '' }, work: { label: 'Work', address: '' } }, temp_unit: 'C', }) @@ -620,6 +632,7 @@ async function loadJournalConfig() { prep_hour: cfg.prep_hour ?? 5, prep_minute: cfg.prep_minute ?? 0, day_rollover_hour: cfg.day_rollover_hour ?? 4, + closeout_enabled: cfg.closeout_enabled ?? true, morning_end_hour: cfg.morning_end_hour, midday_end_hour: cfg.midday_end_hour, locations: { @@ -1901,6 +1914,19 @@ function formatUserDate(iso: string): string { The assistant observes patterns from your journal and chat conversations and builds a summary over time. The summary is included in the journal's system prompt so the daily prep can reference what it knows about you. {{ profile.observations_count }} raw observation{{ profile.observations_count !== 1 ? 's' : '' }} stored.

+ + +
{{ profile.learned_summary }}
No learned summary yet. Observations accumulate from journal and chat conversations.
From bb650ba563cd4483ebbe958f1eae2303c3e729b4 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 12 May 2026 14:44:51 -0400 Subject: [PATCH 12/23] feat(profile): Settings panel with recent journal observations Co-Authored-By: Claude Opus 4.7 (1M context) --- frontend/src/views/SettingsView.vue | 41 ++++++++++++++++++++++++++++- 1 file changed, 40 insertions(+), 1 deletion(-) diff --git a/frontend/src/views/SettingsView.vue b/frontend/src/views/SettingsView.vue index 422eb99..1fd2d78 100644 --- a/frontend/src/views/SettingsView.vue +++ b/frontend/src/views/SettingsView.vue @@ -4,7 +4,7 @@ import { ref, computed, watch, onMounted } from "vue"; import { useSettingsStore } from "@/stores/settings"; import { useAuthStore } from "@/stores/auth"; import { useToastStore } from "@/stores/toast"; -import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getFableMcpInfo, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getVoiceStatus, getVoiceList, synthesiseSpeech, getProfile, updateProfile, consolidateProfile, clearProfileObservations, getJournalConfig, saveJournalConfig, geocodeAddress, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type VoiceStatusResult, type VoiceEntry, type VoiceBlendEntry, type UserProfile, type JournalConfig } from "@/api/client"; +import { apiGet, apiPost, apiPut, apiDelete, listGroups, createGroup, deleteGroup, listGroupMembers, addGroupMember, removeGroupMember, searchUsers, getFableMcpInfo, listApiKeys, createApiKey as apiCreateApiKey, revokeApiKey as apiRevokeApiKey, getVoiceStatus, getVoiceList, synthesiseSpeech, getProfile, updateProfile, consolidateProfile, clearProfileObservations, listProfileObservations, getJournalConfig, saveJournalConfig, geocodeAddress, type ApiKeyEntry, type GroupEntry, type GroupMember, type UserSearchResult, type VoiceStatusResult, type VoiceEntry, type VoiceBlendEntry, type UserProfile, type JournalConfig, type ProfileObservationEntry } from "@/api/client"; import { usePushStore } from "@/stores/push"; import type { User } from "@/types/auth"; import PaginationBar from "@/components/PaginationBar.vue"; @@ -549,6 +549,26 @@ const profileSaving = ref(false) const profileSaved = ref(false) const consolidating = ref(false) const clearingObs = ref(false) +const observations = ref([]) +const observationsExpanded = ref(false) +const observationsLoading = ref(false) +const observationsLoaded = ref(false) + +async function toggleObservations() { + observationsExpanded.value = !observationsExpanded.value + if (observationsExpanded.value && !observationsLoaded.value) { + observationsLoading.value = true + try { + const res = await listProfileObservations() + observations.value = res.observations + observationsLoaded.value = true + } catch { + toastStore.show('Failed to load observations', 'error') + } finally { + observationsLoading.value = false + } + } +} async function loadProfile() { try { profile.value = await getProfile() } catch { /* non-critical */ } @@ -702,6 +722,8 @@ async function clearObservations() { profile.value.learned_summary = '' profile.value.observations_count = 0 profile.value.observations_updated_at = null + observations.value = [] + observationsLoaded.value = false toastStore.show('Learned data cleared') } catch { toastStore.show('Failed to clear observations', 'error') } finally { clearingObs.value = false } @@ -1929,6 +1951,23 @@ function formatUserDate(iso: string): string {
{{ profile.learned_summary }}
No learned summary yet. Observations accumulate from journal and chat conversations.
+ +
+ +
+
Loading…
+
No observations yet.
+
+
+
{{ entry.date }}
+
{{ entry.bullets }}
+
+
+
+
+