feat(journal): auto-scheduler for curator (Phase 2)

The curator now runs automatically every 15 minutes against any
journal conversation that has user messages newer than its last
curator run. Manual triggers from Phase 1b still work and now also
stamp the timestamp so the scheduler doesn't double-process.

Migration 0048:
- conversations.last_curator_run_at (timestamptz, nullable).
- Partial index ix_conversations_journal_last_curator on the column
  filtered to conversation_type='journal'. The scheduler's candidate
  query is "journal AND (NULL OR stale)" so an index narrowed to
  journal rows is the right shape — index size stays small even on
  instances with many non-journal conversations.

models/conversation.py:
- New `last_curator_run_at` column on Conversation. DateTime imported.

services/curator_scheduler.py (new):
- IntervalTrigger every 15 min via BackgroundScheduler (same pattern
  as journal_scheduler.py).
- _candidate_conversations(): SELECT journal conversations where the
  newest user message is newer than last_curator_run_at (or NULL).
  Capped at 20 per sweep so a backlog after downtime doesn't stall
  the scheduler.
- _sweep() processes candidates sequentially under an asyncio.Lock
  so overlapping ticks can't double-fire on the same conversation.
  Failed runs leave the timestamp alone — natural retry on next sweep.
- start_/stop_curator_scheduler() wired into app.py boot/shutdown.

routes/journal.py:
- Manual /api/journal/curator/run/<conv_id> stamps last_curator_run_at
  on success. Errors don't stamp so the scheduler retries.

What's still pending:
- Phase 3: feedback loop (curator summary into chat context). Currently
  the curator's summary lives in the run result but doesn't reach the
  chat model.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-22 10:07:12 -04:00
parent a73dd17a1b
commit 83f1676d72
5 changed files with 251 additions and 1 deletions
+16
View File
@@ -201,6 +201,22 @@ async def trigger_curator_run(conv_id: int):
from fabledassistant.services.curator import run_curator_for_conversation
result = await run_curator_for_conversation(conv_id)
# 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.
if not result.error:
import datetime as _dt
from sqlalchemy import update as _update
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))
)
await _sess.commit()
return jsonify(result.to_dict())