feat(journal): chat model has no tools; curator runs them async (Phase 1a)

Backend half of the conversation+curator architecture (Fable #172).
Decouples the journal chat surface from tool calling: the chat model
now sees `tools=[]` and just talks, while a separate curator pass
extracts beats and fires the tool calls.

services/generation_task.py:
- When conversation_type == "journal", pass `tools=[]` to Ollama
  regardless of what the journal tool set would normally provide.
  The chat model literally cannot fire record_moment / create_task /
  etc., so it cannot lie about firing them — the primary failure
  mode this architecture removes.

services/curator.py (new):
- `run_curator_for_conversation(conv_id, since=None)` loads recent
  messages, builds a curator-specific system prompt (extract beats,
  emit tool calls, optionally a one-line summary), and iterates the
  Ollama tool-call loop using the user's background_model so the
  chat model's KV cache survives.
- Same tool registry as a normal journal conversation
  (record_moment, search_notes, update_task, create_task,
  save_person, save_place, etc.). The curator chooses naturally
  among them; no need for a separate curator-specific filter.
- Returns CuratorRunResult with per-call status + a summary line.
- Caps at 4 tool-call rounds — bounded task (extract beats from a
  fixed transcript), shouldn't need more.
- Errors land in result.error rather than raising; the manual
  trigger surface (and later the scheduler) want a structured
  result, not exceptions.

routes/journal.py:
- New POST /api/journal/curator/run/<conv_id> for manual triggers.
  Validates conv ownership before running. Returns the
  CuratorRunResult dict so the UI can show what was captured.

What's not in this commit (deferred to later phases):
- The scheduler that auto-runs the curator (phase 2 — adds the
  `conversations.last_curator_run_at` column + APScheduler job).
- Curator → chat feedback loop (phase 3 — summary gets injected
  into subsequent chat system prompts).
- Right-rail captures panel in JournalView (phase 1b — pure frontend
  work, separate commit for clean review).
- Research surface separation (phase 4).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 09:03:24 -04:00
parent 39ab5d69a9
commit a7002a89a0
3 changed files with 372 additions and 1 deletions
@@ -177,6 +177,13 @@ async def run_generation(
buf.append_event("status", {"status": "Building context..."})
# Phase 1: Resolve the tools list for this user, scoped to conversation type.
#
# Journal conversations get NO tools (2026-05-22 architecture pivot):
# the chat model talks, a separate background curator does tool calls
# asynchronously. See services/curator.py. Removing tools here is the
# mechanical change that makes the architecture real — the chat model
# can no longer fire record_moment / create_task / etc. and therefore
# can no longer lie about firing them.
from fabledassistant.models import async_session as _async_session
from fabledassistant.models.conversation import Conversation as _Conversation
async with _async_session() as _sess:
@@ -184,7 +191,15 @@ async def run_generation(
_conversation_type = (
_conv.conversation_type if _conv and _conv.conversation_type else "chat"
)
tools = await get_tools_for_user(user_id, conversation_type=_conversation_type)
if _conversation_type == "journal":
tools = []
logger.info(
"Conv %d is journal: passing tools=[] to chat model "
"(curator handles tool calls async)",
conv_id,
)
else:
tools = await get_tools_for_user(user_id, conversation_type=_conversation_type)
logger.info(
"Starting generation for conv %d: model=%s, tools=%d",