From fa97ade8e39a53a5f55172b35172b1b14fc11a70 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Fri, 22 May 2026 10:09:33 -0400 Subject: [PATCH] feat(journal): curator summary feeds back into chat context (Phase 3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The architecture loop closes. Curator extracts beats and writes a ≤240-char summary; the next chat turn loads that summary into the journal system prompt so the chat model — which has no tools and cannot retrieve anything itself — gains awareness of recent topics captured by the curator. Migration 0049: - conversations.curator_summary (text, nullable). Last-write-wins; no history of prior summaries. models/conversation.py: - New curator_summary column on Conversation. services/curator_scheduler.py: - _stamp_last_run() takes an optional summary kwarg; persists it when non-empty (clobbering the previous summary). Empty summary keeps the existing one rather than overwriting useful context with "". - _sweep() passes result.summary through. routes/journal.py: - Manual /api/journal/curator/run/ writes curator_summary alongside last_curator_run_at on success. services/journal_pipeline.py: - build_journal_system_prompt() gains an optional `conv_id` param. When provided, appends a "CURATOR NOTES" block at the end of the system prompt with the conversation's stored summary. Positioned after ambient context so the chat model treats it as current awareness rather than background. services/llm.py: - Threads conv_id through to build_journal_system_prompt. This is the last commit of the conversation+curator architecture arc (Fable #172): - Phase 1a (a7002a8): chat=tools[], curator service backend - Phase 1b (a73dd17): right-rail captures panel + manual trigger - Phase 2 (83f1676): auto-scheduler every 15 min - Phase 3 (this): curator summary → chat context feedback loop Operator can now device-test the architecture end-to-end: have a journal conversation (model can't lie about tool calls because it has none), wait for the scheduler or hit "Process captures", see moments appear in the right rail, then continue the conversation and notice the chat model staying topic-aware via the summary block. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../0049_conversation_curator_summary.py | 36 +++++++++++++++++ src/fabledassistant/models/conversation.py | 6 +++ src/fabledassistant/routes/journal.py | 11 ++++-- .../services/curator_scheduler.py | 21 +++++++--- .../services/journal_pipeline.py | 39 ++++++++++++++++++- src/fabledassistant/services/llm.py | 1 + 6 files changed, 102 insertions(+), 12 deletions(-) create mode 100644 alembic/versions/0049_conversation_curator_summary.py diff --git a/alembic/versions/0049_conversation_curator_summary.py b/alembic/versions/0049_conversation_curator_summary.py new file mode 100644 index 0000000..6e1cea0 --- /dev/null +++ b/alembic/versions/0049_conversation_curator_summary.py @@ -0,0 +1,36 @@ +"""conversations.curator_summary — feeds curator output back to the chat model + +Revision ID: 0049 +Revises: 0048 +Create Date: 2026-05-22 + +Phase 3 of the conversation+curator architecture (Fable #172). The +curator's final summary line (≤ 240 chars) is persisted here so the +NEXT chat turn can include it in the system prompt. This is the +feedback loop that closes the architecture — without it, the chat +model has no awareness of what the curator extracted, and conversations +risk circling back to topics already captured. + +NULL means "no curator pass has produced a summary yet." The chat +pipeline reads this column when building the journal system prompt; +missing column = no injection, no failure. +""" +from alembic import op +import sqlalchemy as sa + + +revision = "0049" +down_revision = "0048" +branch_labels = None +depends_on = None + + +def upgrade() -> None: + op.add_column( + "conversations", + sa.Column("curator_summary", sa.Text, nullable=True), + ) + + +def downgrade() -> None: + op.drop_column("conversations", "curator_summary") diff --git a/src/fabledassistant/models/conversation.py b/src/fabledassistant/models/conversation.py index c6b251e..3227811 100644 --- a/src/fabledassistant/models/conversation.py +++ b/src/fabledassistant/models/conversation.py @@ -31,6 +31,12 @@ class Conversation(Base, TimestampMixin): last_curator_run_at: Mapped[datetime.datetime | None] = mapped_column( DateTime(timezone=True), nullable=True, default=None ) + # Curator's most recent one-line summary of what was captured (Phase 3, + # Fable #172). Injected into the next chat turn's system prompt so the + # chat model stays aware of recent topics without needing tools to + # retrieve. ≤ 240 chars enforced by curator service. Last-write-wins; + # we don't keep history of prior summaries. + curator_summary: Mapped[str | None] = mapped_column(Text, nullable=True, default=None) messages: Mapped[list["Message"]] = relationship( back_populates="conversation", diff --git a/src/fabledassistant/routes/journal.py b/src/fabledassistant/routes/journal.py index ee99dc0..dd74b73 100644 --- a/src/fabledassistant/routes/journal.py +++ b/src/fabledassistant/routes/journal.py @@ -205,15 +205,18 @@ async def trigger_curator_run(conv_id: int): # Stamp last_curator_run_at on success so the scheduler doesn't # immediately re-process the same conversation on its next sweep. # Errored runs intentionally leave the timestamp alone so the - # scheduler retries them. + # scheduler retries them. Persist the curator's summary too when + # non-empty (Phase 3 feedback loop) — empty summary keeps the + # existing one rather than clobbering useful context. if not result.error: import datetime as _dt from sqlalchemy import update as _update + _values: dict = {"last_curator_run_at": _dt.datetime.now(_dt.timezone.utc)} + if result.summary: + _values["curator_summary"] = result.summary.strip()[:240] async with _async_session() as _sess: await _sess.execute( - _update(_Conversation) - .where(_Conversation.id == conv_id) - .values(last_curator_run_at=_dt.datetime.now(_dt.timezone.utc)) + _update(_Conversation).where(_Conversation.id == conv_id).values(**_values) ) await _sess.commit() diff --git a/src/fabledassistant/services/curator_scheduler.py b/src/fabledassistant/services/curator_scheduler.py index 2b28751..798ce6d 100644 --- a/src/fabledassistant/services/curator_scheduler.py +++ b/src/fabledassistant/services/curator_scheduler.py @@ -86,13 +86,22 @@ async def _candidate_conversations() -> list[int]: return [row[0] for row in rows.all()] -async def _stamp_last_run(conv_id: int) -> None: - """Bump last_curator_run_at to now() after a successful pass.""" +async def _stamp_last_run(conv_id: int, summary: str | None = None) -> None: + """Bump last_curator_run_at to now() after a successful pass. + + When `summary` is non-empty, also persists it to `curator_summary` + so the chat model picks it up on subsequent turns (Phase 3 feedback + loop). An empty summary means the curator had nothing meaningful + to capture — we still bump the timestamp (the run succeeded), but + leave the existing summary in place rather than clobbering useful + context with "". + """ + values: dict = {"last_curator_run_at": datetime.datetime.now(timezone.utc)} + if summary: + values["curator_summary"] = summary.strip()[:240] async with async_session() as session: await session.execute( - update(Conversation) - .where(Conversation.id == conv_id) - .values(last_curator_run_at=datetime.datetime.now(timezone.utc)) + update(Conversation).where(Conversation.id == conv_id).values(**values) ) await session.commit() @@ -129,7 +138,7 @@ async def _sweep() -> None: conv_id, result.error, ) continue - await _stamp_last_run(conv_id) + await _stamp_last_run(conv_id, summary=result.summary) except Exception: logger.exception( "Curator sweep crashed on conv %d (will retry next sweep)", diff --git a/src/fabledassistant/services/journal_pipeline.py b/src/fabledassistant/services/journal_pipeline.py index 000d2e9..af60376 100644 --- a/src/fabledassistant/services/journal_pipeline.py +++ b/src/fabledassistant/services/journal_pipeline.py @@ -165,11 +165,14 @@ async def build_journal_system_prompt( user_id: int, day_date: datetime.date, user_timezone: str, + conv_id: int | None = None, ) -> str: """Static-then-dynamic system prompt. Static prefix (persona + calibration) is identical on every request, - preserving Ollama KV cache. Dynamic suffix changes per-day. + preserving Ollama KV cache. Dynamic suffix changes per-day, plus a + per-conversation curator_summary if one is available (Phase 3 + feedback loop, Fable #172). """ static_block = f"{JOURNAL_PERSONA}\n\n{JOURNAL_CALIBRATION}" @@ -188,7 +191,39 @@ async def build_journal_system_prompt( f"\n\nRECENT JOURNAL CONTEXT (last 48h):\n{ambient}" if ambient else "" ) - return f"{static_block}\n\n{tz_block}{profile_section}{ambient_section}" + # Curator feedback (Phase 3): a one-line summary of what the curator + # extracted from this conversation on its most recent pass. Lets the + # chat model stay aware of topics it can no longer surface via tool + # calls (chat is tools=[]). + curator_section = "" + if conv_id is not None: + curator_section = await _curator_summary_block(conv_id) + + return ( + f"{static_block}\n\n{tz_block}" + f"{profile_section}{ambient_section}{curator_section}" + ) + + +async def _curator_summary_block(conv_id: int) -> str: + """Render the conversation's stored curator_summary as a system block. + + Empty when no curator pass has produced a summary yet. Importantly, + the block is positioned AFTER ambient context (and the static block) + so the chat model treats it as up-to-date awareness, not background. + """ + from sqlalchemy import select + from fabledassistant.models import async_session + from fabledassistant.models.conversation import Conversation + + async with async_session() as session: + result = await session.execute( + select(Conversation.curator_summary).where(Conversation.id == conv_id) + ) + summary = result.scalar_one_or_none() + if not summary: + return "" + return f"\n\nCURATOR NOTES (recent captures from this conversation): {summary.strip()}" async def _ambient_moments_block( diff --git a/src/fabledassistant/services/llm.py b/src/fabledassistant/services/llm.py index 17f952d..cd31175 100644 --- a/src/fabledassistant/services/llm.py +++ b/src/fabledassistant/services/llm.py @@ -590,6 +590,7 @@ async def build_context( user_id=user_id, day_date=day_date, user_timezone=user_timezone or "UTC", + conv_id=conv_id, ) messages_out: list[dict] = [{"role": "system", "content": system_content}] messages_out.extend(history)