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)