"""Memoir ingest scheduling boundary. The real batching logic still lives in ``BackgroundTaskRunner``. This adapter keeps conversation code from depending on that implementation directly. """ from __future__ import annotations from dataclasses import dataclass from typing import Literal, Sequence from app.features.memoir.background_runner import BackgroundTaskRunner MemoirTrigger = Literal[ "turn", "conversation_end", "manual_flush", "evaluation_replay", ] @dataclass(frozen=True) class MemoirPhasePlan: """A visible plan for submitting segments into the memoir pipeline.""" user_id: str segment_ids: tuple[str, ...] trigger: MemoirTrigger class MemoirIngestScheduler: """Small facade over debounce batching and Phase2 flush dispatch.""" def __init__(self, runner: BackgroundTaskRunner | None = None) -> None: self._runner = runner or BackgroundTaskRunner() @property def runner(self) -> BackgroundTaskRunner: """Compatibility escape hatch for existing tests and eval utilities.""" return self._runner async def queue_segment( self, user_id: str, segment_id: str, *, text_char_count: int = 0, trigger: MemoirTrigger = "turn", ) -> MemoirPhasePlan: await self._runner.queue_message( user_id, segment_id, text_char_count=text_char_count, ) return MemoirPhasePlan( user_id=user_id, segment_ids=(segment_id,), trigger=trigger, ) async def flush_pending( self, user_id: str, *, extra_segment_ids: Sequence[str] | None = None, trigger: MemoirTrigger = "manual_flush", ) -> tuple[MemoirPhasePlan, str | None]: ids = tuple(str(x) for x in (extra_segment_ids or ()) if str(x).strip()) task_id = await self._runner.flush_pending( user_id, extra_segment_ids=list(ids), ) return MemoirPhasePlan(user_id=user_id, segment_ids=ids, trigger=trigger), task_id __all__ = ["MemoirIngestScheduler", "MemoirPhasePlan", "MemoirTrigger"]