Files
IdeaSDK/crates/application/src/conversation/paginate.rs
Blomios 40ca3e522f feat(domain,infra,app): rotation/rétention log.jsonl + lecture paginée (LS6)
Backend uniquement (UI React repoussée à LS7) :
- domain : port ConversationArchive + structs SegmentStats/PageCursor/PageDirection/
  TurnSlice/RotationDecision/RotationThresholds, fn pures rotation_plan/clamp_page_limit
  + consts ; re-exports lib.
- infrastructure : impl ConversationArchive pour FsConversationLog (stats/rotate/page
  + helpers), archive segmentée hors chemin chaud.
- application : ReadConversationPage + DTO + ConversationArchiveProvider (conversation/paginate),
  RotateConversationLog (conversation/rotate), exports mod/lib.
- app-tauri : AppConversationArchiveProvider + wiring (state), rotation détachée dans
  launch_agent + commande read_conversation_page (commands), DTOs (dto),
  commande enregistrée (generate_handler!).
- tests (QA, verts) : conversation_log, conversation_rotate_paginate (nouveau),
  dto (module test). Pivots INV-LS6 et cohérence fold-après-rotation verts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 12:58:40 +02:00

149 lines
5.7 KiB
Rust

//! [`ReadConversationPage`] — lecture **humaine** paginée d'une conversation (lot LS6).
//!
//! Lecture archive-aware (segments d'archive + actif) exposée comme un **DTO humain
//! riche** : contrairement au handoff (résumé borné) ou au read-model work-state
//! (aperçus tronqués), la page humaine porte le **texte complet, non borné** de chaque
//! tour — c'est la vue « transcript » destinée à l'utilisateur (le React arrive en LS7).
//!
//! Hors chemin chaud : composé du seul port [`ConversationArchive`] (via un provider par
//! root), jamais d'`append`/de rotation ici.
use std::sync::Arc;
use domain::project::ProjectPath;
use domain::{AgentId, ConversationArchive, ConversationId, PageCursor, TurnId, TurnRole};
use crate::error::AppError;
/// Fournit le [`ConversationArchive`] **lié au project root** courant (lot LS6),
/// calqué sur [`crate::HandoffProvider`]/[`crate::RecordTurnProvider`].
///
/// Les use cases d'archivage/pagination ([`crate::RotateConversationLog`],
/// [`ReadConversationPage`]) sont **uniques** pour tous les projets, alors que les logs
/// sont **par project root** (`<root>/.ideai/conversations/`) et l'adapter `Fs*` fixe sa
/// racine à la construction. Ce port matérialise un archive ciblant le **bon** dossier à
/// chaque appel. `None` ⇒ pas d'archivage/pagination (best-effort absent). Implémenté
/// dans `app-tauri` (seul détenteur des adapters `Fs*`).
pub trait ConversationArchiveProvider: Send + Sync {
/// Construit le [`ConversationArchive`] dont la persistance cible `root`, ou `None`.
fn conversation_archive_for(&self, root: &ProjectPath) -> Option<Arc<dyn ConversationArchive>>;
}
/// Origine d'un tour, pour la vue humaine paginée (miroir de [`domain::input::InputSource`]).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum TurnSource {
/// L'opérateur humain.
Human,
/// Un autre agent (délégation via `idea_ask_agent`).
Agent {
/// L'agent à l'origine du tour.
agent_id: AgentId,
},
}
impl From<domain::input::InputSource> for TurnSource {
fn from(source: domain::input::InputSource) -> Self {
match source {
domain::input::InputSource::Human => Self::Human,
domain::input::InputSource::Agent { agent_id } => Self::Agent { agent_id },
}
}
}
/// Un tour projeté pour la vue humaine (lot LS6) — **texte complet, non borné**.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TurnView {
/// Identifiant stable du tour (sert aussi d'ancre de pagination).
pub id: TurnId,
/// Horodatage (epoch millisecondes).
pub at_ms: u64,
/// Nature du tour (invite, réponse, activité outillée).
pub role: TurnRole,
/// Origine (humain ou agent délégant).
pub source: TurnSource,
/// Le **texte intégral** du tour (jamais tronqué — c'est la vue transcript).
pub text: String,
/// Longueur (en caractères) du texte, pour l'UI.
pub text_len: usize,
}
impl From<domain::ConversationTurn> for TurnView {
fn from(turn: domain::ConversationTurn) -> Self {
let text_len = turn.text.chars().count();
Self {
id: turn.id,
at_ms: turn.at_ms,
role: turn.role,
source: turn.source.into(),
text: turn.text,
text_len,
}
}
}
/// Une page de tours pour la vue humaine (lot LS6).
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct TurnPage {
/// Les tours de la page, **du plus ancien au plus récent**.
pub turns: Vec<TurnView>,
/// `true` s'il existe d'autres tours au-delà de la page dans le sens de progression.
pub has_more: bool,
/// L'id du **dernier** tour de la page (ancre pour la requête suivante) ; `None` si
/// la page est vide.
pub next_anchor: Option<TurnId>,
}
/// Entrée de [`ReadConversationPage::execute`].
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ReadConversationPageInput {
/// Le project root dont dériver l'archive.
pub project_root: ProjectPath,
/// La conversation (paire) à lire.
pub conversation: ConversationId,
/// Le curseur (ancre + sens) de pagination.
pub cursor: PageCursor,
/// La taille de page demandée (`0` ⇒ défaut ; clampée par le domaine).
pub limit: usize,
}
/// Use case de lecture humaine paginée (lot LS6) — consomme le seul
/// [`ConversationArchive`] (résolu par root via le provider).
pub struct ReadConversationPage {
archives: Arc<dyn ConversationArchiveProvider>,
}
impl ReadConversationPage {
/// Construit le use case à partir du provider d'archive par root.
#[must_use]
pub fn new(archives: Arc<dyn ConversationArchiveProvider>) -> Self {
Self { archives }
}
/// Lit une page de `conversation`, mappée vers le DTO humain riche.
///
/// Provider d'archive absent pour ce root ⇒ page **vide** (best-effort, jamais une
/// erreur dure). Le texte de chaque tour est renvoyé **intégralement**.
///
/// # Errors
/// [`AppError::Store`] si la lecture du log échoue.
pub async fn execute(&self, input: ReadConversationPageInput) -> Result<TurnPage, AppError> {
let Some(archive) = self.archives.conversation_archive_for(&input.project_root) else {
return Ok(TurnPage {
turns: Vec::new(),
has_more: false,
next_anchor: None,
});
};
let slice = archive
.page(input.conversation, input.cursor, input.limit)
.await?;
let next_anchor = slice.turns.last().map(|t| t.id);
let turns = slice.turns.into_iter().map(TurnView::from).collect();
Ok(TurnPage {
turns,
has_more: slice.has_more,
next_anchor,
})
}
}