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
+33
View File
@@ -171,6 +171,39 @@ async def list_days():
return jsonify({"days": [d.isoformat() for d in rows]})
@journal_bp.post("/curator/run/<int:conv_id>")
@login_required
async def trigger_curator_run(conv_id: int):
"""Manually run the journal curator over a conversation.
The curator reads recent messages and fires tool calls (record_moment,
update_task, etc.) the chat model can't (chat models have tools=[]).
Returns a summary of what was captured.
See services/curator.py for the architectural background.
"""
user_id = get_current_user_id()
# Confirm the conversation belongs to this user (curator runs against
# arbitrary conv_ids would otherwise leak data across tenants).
from sqlalchemy import select as _select
from fabledassistant.models import async_session as _async_session
from fabledassistant.models.conversation import Conversation as _Conversation
async with _async_session() as _sess:
_res = await _sess.execute(
_select(_Conversation).where(
_Conversation.id == conv_id,
_Conversation.user_id == user_id,
)
)
if _res.scalar_one_or_none() is None:
return jsonify({"error": "Conversation not found"}), 404
from fabledassistant.services.curator import run_curator_for_conversation
result = await run_curator_for_conversation(conv_id)
return jsonify(result.to_dict())
@journal_bp.post("/trigger-prep")
@login_required
async def trigger_prep():