feat(persistence): couche conversationnelle — cadrage §18/§19 + briques P1→P4
Resync ARCHITECTURE.md (état livré + cadrage persistance/handoff) et premières briques de la couche de persistance conversationnelle (log canonique par paire + handoff incrémental), indépendante du provider — prépare reprise fiable et handoff cross-profile Claude↔Codex. ARCHITECTURE.md - §14.3.2/§17 : M5 marqué livré, verrou « ouvert » périmé, §17 réconcilié (vue = terminal de sortie, pas d'UI chat) ; §18 état livré 2026-06-12 ; §19 cadrage persistance/handoff (log par paire + handoff, 10 lots P1→P10) Domaine (conversation_log.rs, pur) - P1 : ConversationTurn / TurnId / TurnRole + port ConversationLog - P3 : Handoff + port HandoffStore - P4 : port HandoffSummarizer (async, seam OCP pour adapter LLM futur) Infrastructure (conversation_log/) - P2 : FsConversationLog — JSONL append-only par paire, sync_all (durabilité crash), skip ligne corrompue, fichier absent ⇒ vide - P3 : FsHandoffStore — handoff.md front-matter, write atomique tmp+rename - P4 : HeuristicHandoffSummarizer — incrémental, zéro modèle/I/O, fenêtre WINDOW Tests : domaine 12 + infra 24 (conversation_log) verts, suites complètes sans régression. Cycle dev/test : le binôme a débusqué et corrigé un bug de durabilité (append sans flush) au passage. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
217
crates/infrastructure/src/conversation_log/handoff.rs
Normal file
217
crates/infrastructure/src/conversation_log/handoff.rs
Normal file
@ -0,0 +1,217 @@
|
||||
//! [`FsHandoffStore`] — l'adapter `tokio::fs` du port [`HandoffStore`]
|
||||
//! (cadrage « persistance conversationnelle », lot P3).
|
||||
//!
|
||||
//! Le **point de reprise** d'une conversation (ARCHITECTURE §19.2/§19.3) est un
|
||||
//! [`Handoff`] : un résumé cumulatif Markdown borné par un curseur [`TurnId`]. On en
|
||||
//! garde **un seul** par conversation (le dernier), dans un fichier lisible à l'œil :
|
||||
//!
|
||||
//! ```text
|
||||
//! <project_root>/.ideai/conversations/
|
||||
//! └── <conversationId>/
|
||||
//! ├── log.jsonl # le log append-only (lot P2)
|
||||
//! └── handoff.md # le dernier point de reprise (ce module)
|
||||
//! ```
|
||||
//!
|
||||
//! ## Format `handoff.md`
|
||||
//!
|
||||
//! Un **front-matter** YAML délimité par `---`, suivi du corps `summary_md` tel quel :
|
||||
//!
|
||||
//! ```text
|
||||
//! ---
|
||||
//! upTo: 00000000-0000-0000-0000-00000000002a
|
||||
//! objective: livrer le lot P3
|
||||
//! ---
|
||||
//! # Résumé
|
||||
//! …le summary_md, octet pour octet…
|
||||
//! ```
|
||||
//!
|
||||
//! Le front-matter porte le curseur `upTo` (toujours) et `objective` (seulement s'il
|
||||
//! est `Some`). Le corps après le second `---\n` est le `summary_md` **exact** : le
|
||||
//! round-trip (`save` puis `load`) redonne le même [`Handoff`]. `objective` étant
|
||||
//! sérialisé sur une seule ligne, un objectif est interdit de retour-chariot ici ; en
|
||||
//! pratique c'est une phrase courte (un titre de but), jamais du multi-ligne.
|
||||
//!
|
||||
//! ## Robustesse
|
||||
//!
|
||||
//! - **Écriture atomique** : on écrit dans `handoff.md.tmp` puis on `rename` — jamais
|
||||
//! de fichier à moitié écrit visible.
|
||||
//! - **Fichier absent** ⇒ `Ok(None)` (jamais une erreur) : une conversation sans
|
||||
//! reprise est l'état normal au premier tour.
|
||||
//! - **Fichier présent mais illisible** (front-matter absent/incomplet, `upTo`
|
||||
//! manquant ou non-UUID) ⇒ [`StoreError::Serialization`]. Contrairement au log
|
||||
//! JSONL (où une ligne corrompue est sautée), un handoff corrompu est une vraie
|
||||
//! erreur : il n'y a qu'un enregistrement, on ne peut pas « sauter ».
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use domain::conversation::ConversationId;
|
||||
use domain::conversation_log::{Handoff, HandoffStore, TurnId};
|
||||
use domain::ports::StoreError;
|
||||
use domain::project::ProjectPath;
|
||||
|
||||
use super::{CONVERSATIONS_DIR, IDEAI_DIR};
|
||||
|
||||
/// Nom du fichier de handoff, par conversation.
|
||||
const HANDOFF_FILE: &str = "handoff.md";
|
||||
|
||||
/// Nom du fichier temporaire d'écriture atomique (renommé sur `handoff.md`).
|
||||
const HANDOFF_TMP_FILE: &str = "handoff.md.tmp";
|
||||
|
||||
/// Délimiteur de front-matter (en tête et fin de l'entête).
|
||||
const FRONT_MATTER_FENCE: &str = "---";
|
||||
|
||||
/// Adapter `tokio::fs` du store de handoff, un `handoff.md` par conversation.
|
||||
///
|
||||
/// Même convention de construction que [`super::FsConversationLog`] : le **project
|
||||
/// root** est fourni au constructeur, la base `<root>/.ideai/conversations` en dérive,
|
||||
/// et chaque conversation a son sous-dossier `<conversationId>/`.
|
||||
pub struct FsHandoffStore {
|
||||
/// Racine `<project_root>/.ideai/conversations`.
|
||||
base: PathBuf,
|
||||
}
|
||||
|
||||
impl FsHandoffStore {
|
||||
/// Construit l'adapter à partir du **project root**.
|
||||
///
|
||||
/// La base `<root>/.ideai/conversations` en est dérivée ; le dossier de
|
||||
/// conversation est créé paresseusement au premier `save`.
|
||||
#[must_use]
|
||||
pub fn new(root: &ProjectPath) -> Self {
|
||||
let base = PathBuf::from(root.as_str())
|
||||
.join(IDEAI_DIR)
|
||||
.join(CONVERSATIONS_DIR);
|
||||
Self { base }
|
||||
}
|
||||
|
||||
/// `<base>/<conversationId>` — le dossier d'une conversation.
|
||||
fn conversation_dir(&self, conversation: ConversationId) -> PathBuf {
|
||||
self.base.join(conversation.to_string())
|
||||
}
|
||||
|
||||
/// `<base>/<conversationId>/handoff.md` — le fichier de handoff d'une conversation.
|
||||
fn handoff_path(&self, conversation: ConversationId) -> PathBuf {
|
||||
self.conversation_dir(conversation).join(HANDOFF_FILE)
|
||||
}
|
||||
|
||||
/// `<base>/<conversationId>/handoff.md.tmp` — le fichier temporaire d'écriture.
|
||||
fn handoff_tmp_path(&self, conversation: ConversationId) -> PathBuf {
|
||||
self.conversation_dir(conversation).join(HANDOFF_TMP_FILE)
|
||||
}
|
||||
}
|
||||
|
||||
/// Sérialise un [`Handoff`] au format `handoff.md` (front-matter + corps exact).
|
||||
fn serialize(handoff: &Handoff) -> String {
|
||||
let mut out = String::new();
|
||||
out.push_str(FRONT_MATTER_FENCE);
|
||||
out.push('\n');
|
||||
out.push_str(&format!("upTo: {}\n", handoff.up_to));
|
||||
if let Some(objective) = &handoff.objective {
|
||||
out.push_str(&format!("objective: {objective}\n"));
|
||||
}
|
||||
out.push_str(FRONT_MATTER_FENCE);
|
||||
out.push('\n');
|
||||
// Corps : le summary_md tel quel, octet pour octet.
|
||||
out.push_str(&handoff.summary_md);
|
||||
out
|
||||
}
|
||||
|
||||
/// Parse le contenu d'un `handoff.md` en [`Handoff`].
|
||||
///
|
||||
/// Format attendu : `---\n<clé: valeur>*\n---\n<summary_md>`. Le corps après le second
|
||||
/// `---\n` est rendu **exact**. Toute déviation (front-matter absent/incomplet, `upTo`
|
||||
/// manquant ou non-UUID) ⇒ `Err(StoreError::Serialization)`.
|
||||
fn deserialize(content: &str) -> Result<Handoff, StoreError> {
|
||||
let bad = |msg: &str| StoreError::Serialization(format!("handoff.md: {msg}"));
|
||||
|
||||
// Entête : `---\n` exactement en tête.
|
||||
let after_open = content
|
||||
.strip_prefix(FRONT_MATTER_FENCE)
|
||||
.and_then(|rest| rest.strip_prefix('\n'))
|
||||
.ok_or_else(|| bad("front-matter ouvrant `---` absent"))?;
|
||||
|
||||
// Fin de l'entête : la première ligne `---\n` (ou `---` en toute fin).
|
||||
// On sépare les lignes du front-matter du corps.
|
||||
let mut up_to: Option<TurnId> = None;
|
||||
let mut objective: Option<String> = None;
|
||||
|
||||
// Trouver le `---` de fermeture, ligne par ligne.
|
||||
let mut rest = after_open;
|
||||
loop {
|
||||
// Découpe la prochaine ligne (jusqu'au `\n` inclus si présent).
|
||||
let (line, tail) = match rest.find('\n') {
|
||||
Some(idx) => (&rest[..idx], &rest[idx + 1..]),
|
||||
None => (rest, ""),
|
||||
};
|
||||
|
||||
if line == FRONT_MATTER_FENCE {
|
||||
// Fermeture trouvée : `tail` est le corps exact.
|
||||
let up_to = up_to.ok_or_else(|| bad("clé `upTo` absente du front-matter"))?;
|
||||
return Ok(Handoff {
|
||||
summary_md: tail.to_string(),
|
||||
up_to,
|
||||
objective,
|
||||
});
|
||||
}
|
||||
|
||||
// Une ligne de clé: valeur dans le front-matter.
|
||||
let (key, value) = line
|
||||
.split_once(':')
|
||||
.ok_or_else(|| bad("ligne de front-matter sans `:`"))?;
|
||||
let value = value.trim();
|
||||
match key.trim() {
|
||||
"upTo" => {
|
||||
let uuid = uuid::Uuid::parse_str(value)
|
||||
.map_err(|_| bad("`upTo` n'est pas un UUID valide"))?;
|
||||
up_to = Some(TurnId::from_uuid(uuid));
|
||||
}
|
||||
"objective" => objective = Some(value.to_string()),
|
||||
// Clé inconnue : tolérée (extensible), ignorée.
|
||||
_ => {}
|
||||
}
|
||||
|
||||
if tail.is_empty() {
|
||||
// Plus de lignes et toujours pas de `---` fermant.
|
||||
return Err(bad("front-matter fermant `---` absent"));
|
||||
}
|
||||
rest = tail;
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HandoffStore for FsHandoffStore {
|
||||
async fn load(&self, conversation: ConversationId) -> Result<Option<Handoff>, StoreError> {
|
||||
let content = match tokio::fs::read_to_string(self.handoff_path(conversation)).await {
|
||||
Ok(content) => content,
|
||||
// Absent ⇒ pas de reprise (jamais une erreur).
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(None),
|
||||
Err(e) => return Err(StoreError::Io(e.to_string())),
|
||||
};
|
||||
deserialize(&content).map(Some)
|
||||
}
|
||||
|
||||
async fn save(
|
||||
&self,
|
||||
conversation: ConversationId,
|
||||
handoff: Handoff,
|
||||
) -> Result<(), StoreError> {
|
||||
let body = serialize(&handoff);
|
||||
|
||||
let dir = self.conversation_dir(conversation);
|
||||
tokio::fs::create_dir_all(&dir)
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||
|
||||
// Écriture atomique : écrire le tmp puis `rename` sur la cible. Un lecteur ne
|
||||
// voit jamais de fichier à moitié écrit (le rename est atomique sur le FS).
|
||||
let tmp = self.handoff_tmp_path(conversation);
|
||||
tokio::fs::write(&tmp, body.as_bytes())
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||
tokio::fs::rename(&tmp, self.handoff_path(conversation))
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
209
crates/infrastructure/src/conversation_log/mod.rs
Normal file
209
crates/infrastructure/src/conversation_log/mod.rs
Normal file
@ -0,0 +1,209 @@
|
||||
//! [`FsConversationLog`] — l'adapter `tokio::fs` du port [`ConversationLog`]
|
||||
//! (cadrage « persistance conversationnelle », lot P2).
|
||||
//!
|
||||
//! La **source de vérité durable** d'une conversation (ARCHITECTURE §19, D19-1a)
|
||||
//! est un log **append-only**, **un fichier JSONL par conversation (paire)** sous
|
||||
//! le project root :
|
||||
//!
|
||||
//! ```text
|
||||
//! <project_root>/.ideai/conversations/
|
||||
//! └── <conversationId>/
|
||||
//! └── log.jsonl # un ConversationTurn JSON par ligne, dans l'ordre d'ajout
|
||||
//! ```
|
||||
//!
|
||||
//! Chaque ligne est un [`ConversationTurn`] sérialisé en JSON (`serde_json`), suivi
|
||||
//! d'un `\n`. Deux conversations sont **disjointes** : chacune a son propre dossier.
|
||||
//!
|
||||
//! ## Robustesse (survivre à un crash)
|
||||
//!
|
||||
//! Le log doit survivre à une **ligne tronquée** par un crash en plein milieu d'une
|
||||
//! écriture : à la relecture, une ligne **illisible/corrompue est silencieusement
|
||||
//! ignorée** (jamais de panic, jamais d'erreur dure). Un fichier **absent** est une
|
||||
//! conversation vide (un `Vec` vide, pas une erreur). Seules les vraies erreurs d'I/O
|
||||
//! (hors « absent », hors « ligne corrompue ») remontent en [`StoreError::Io`].
|
||||
//!
|
||||
//! ## Concurrence
|
||||
//!
|
||||
//! L'écriture est **sérialisée par conversation** par un `Mutex` async dédié à
|
||||
//! chaque fichier (registre `paths → Arc<tokio::sync::Mutex<()>>`), tenu le temps
|
||||
//! de l'`append`. Deux `append` sur des conversations **différentes** n'entrent
|
||||
//! jamais en contention sur la donnée (verrous distincts) ; ils ne se croisent que
|
||||
//! brièvement sur le registre. (P9 ajoutera un `FileGuard` plus large si un besoin
|
||||
//! réel d'arbitrage lecture/écriture émerge — ici on ne sur-conçoit pas.)
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use async_trait::async_trait;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
use domain::conversation::ConversationId;
|
||||
use domain::conversation_log::{ConversationLog, ConversationTurn, TurnId};
|
||||
use domain::ports::StoreError;
|
||||
use domain::project::ProjectPath;
|
||||
|
||||
mod handoff;
|
||||
mod summarizer;
|
||||
|
||||
pub use handoff::FsHandoffStore;
|
||||
pub use summarizer::{HeuristicHandoffSummarizer, WINDOW};
|
||||
|
||||
/// Dossier `.ideai/` à la racine d'un project root.
|
||||
pub(crate) const IDEAI_DIR: &str = ".ideai";
|
||||
|
||||
/// Sous-dossier des logs de conversation dans `.ideai/`.
|
||||
pub(crate) const CONVERSATIONS_DIR: &str = "conversations";
|
||||
|
||||
/// Nom du fichier log JSONL, par conversation.
|
||||
const LOG_FILE: &str = "log.jsonl";
|
||||
|
||||
/// Adapter `tokio::fs` du log canonique append-only, un `log.jsonl` par conversation.
|
||||
///
|
||||
/// Le **project root** est fourni au constructeur (comme [`crate::FsProjectStore`] et
|
||||
/// l'orchestrateur fichier reçoivent leur racine) : une instance sert toutes les
|
||||
/// conversations d'un même projet. Tous les chemins en dérivent.
|
||||
pub struct FsConversationLog {
|
||||
/// Racine `<project_root>/.ideai/conversations`.
|
||||
base: PathBuf,
|
||||
/// Verrous d'écriture, un par fichier de conversation (sérialise les `append`).
|
||||
write_locks: Mutex<HashMap<ConversationId, Arc<tokio::sync::Mutex<()>>>>,
|
||||
}
|
||||
|
||||
impl FsConversationLog {
|
||||
/// Construit l'adapter à partir du **project root**.
|
||||
///
|
||||
/// La base `<root>/.ideai/conversations` en est dérivée ; les dossiers de
|
||||
/// conversation sont créés paresseusement au premier `append`.
|
||||
#[must_use]
|
||||
pub fn new(root: &ProjectPath) -> Self {
|
||||
let base = PathBuf::from(root.as_str())
|
||||
.join(IDEAI_DIR)
|
||||
.join(CONVERSATIONS_DIR);
|
||||
Self {
|
||||
base,
|
||||
write_locks: Mutex::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// `<base>/<conversationId>` — le dossier d'une conversation.
|
||||
fn conversation_dir(&self, conversation: ConversationId) -> PathBuf {
|
||||
self.base.join(conversation.to_string())
|
||||
}
|
||||
|
||||
/// `<base>/<conversationId>/log.jsonl` — le fichier log d'une conversation.
|
||||
fn log_path(&self, conversation: ConversationId) -> PathBuf {
|
||||
self.conversation_dir(conversation).join(LOG_FILE)
|
||||
}
|
||||
|
||||
/// Renvoie (en le créant au besoin) le verrou d'écriture de `conversation`.
|
||||
fn write_lock(&self, conversation: ConversationId) -> Arc<tokio::sync::Mutex<()>> {
|
||||
self.write_locks
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
.entry(conversation)
|
||||
.or_default()
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// Lit et parse tout le fil de `conversation`, dans l'ordre d'ajout.
|
||||
///
|
||||
/// Fichier absent ⇒ `Vec` vide. Une ligne illisible (UTF-8 invalide ou JSON
|
||||
/// corrompu) est **silencieusement ignorée** : le log survit à une ligne tronquée
|
||||
/// par un crash. Seule une vraie erreur d'I/O remonte.
|
||||
async fn read_all(
|
||||
&self,
|
||||
conversation: ConversationId,
|
||||
) -> Result<Vec<ConversationTurn>, StoreError> {
|
||||
let bytes = match tokio::fs::read(self.log_path(conversation)).await {
|
||||
Ok(bytes) => bytes,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
|
||||
Err(e) => return Err(StoreError::Io(e.to_string())),
|
||||
};
|
||||
// UTF-8 partiel (ex. fichier tronqué) : on décode en lossy plutôt que d'échouer ;
|
||||
// une ligne devenue invalide ne parsera simplement pas en JSON et sera ignorée.
|
||||
let text = String::from_utf8_lossy(&bytes);
|
||||
let turns = text
|
||||
.lines()
|
||||
.filter(|line| !line.trim().is_empty())
|
||||
// Ligne corrompue/illisible ⇒ skip silencieux (jamais d'erreur dure).
|
||||
.filter_map(|line| serde_json::from_str::<ConversationTurn>(line).ok())
|
||||
.collect();
|
||||
Ok(turns)
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ConversationLog for FsConversationLog {
|
||||
async fn append(
|
||||
&self,
|
||||
conversation: ConversationId,
|
||||
turn: ConversationTurn,
|
||||
) -> Result<(), StoreError> {
|
||||
// Sérialiser **avant** d'ouvrir le fichier : une erreur de sérialisation ne doit
|
||||
// pas laisser le fichier ouvert ni écrire de ligne partielle.
|
||||
let mut line =
|
||||
serde_json::to_string(&turn).map_err(|e| StoreError::Serialization(e.to_string()))?;
|
||||
line.push('\n');
|
||||
|
||||
// Écriture sérialisée par conversation : le verrou est tenu le temps de
|
||||
// create_dir_all + open(append) + write, donc deux `append` concurrents sur la
|
||||
// même conversation s'ordonnent (pas d'entrelacement de lignes).
|
||||
let lock = self.write_lock(conversation);
|
||||
let _guard = lock.lock().await;
|
||||
|
||||
let dir = self.conversation_dir(conversation);
|
||||
tokio::fs::create_dir_all(&dir)
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||
|
||||
let mut file = tokio::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(true)
|
||||
.open(self.log_path(conversation))
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||
file.write_all(line.as_bytes())
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||
// Le `File` async de tokio met l'écriture en file vers une tâche blocante ;
|
||||
// droppé sans flush, l'écriture en vol du dernier `append` peut être jetée.
|
||||
// `sync_all` force le drainage **et** la durabilité crash promise par l'en-tête
|
||||
// du module (survivre à un crash en plein milieu d'une écriture).
|
||||
file.sync_all()
|
||||
.await
|
||||
.map_err(|e| StoreError::Io(e.to_string()))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn read(
|
||||
&self,
|
||||
conversation: ConversationId,
|
||||
since: Option<TurnId>,
|
||||
) -> Result<Vec<ConversationTurn>, StoreError> {
|
||||
let all = self.read_all(conversation).await?;
|
||||
let out = match since {
|
||||
None => all,
|
||||
// Curseur **exclusif** : tout ce qui suit strictement le tour `cursor`.
|
||||
// Curseur introuvable ⇒ rien (cohérent avec le double in-memory du port).
|
||||
Some(cursor) => match all.iter().position(|t| t.id == cursor) {
|
||||
Some(idx) => all[idx + 1..].to_vec(),
|
||||
None => Vec::new(),
|
||||
},
|
||||
};
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
async fn last(
|
||||
&self,
|
||||
conversation: ConversationId,
|
||||
n: usize,
|
||||
) -> Result<Vec<ConversationTurn>, StoreError> {
|
||||
if n == 0 {
|
||||
return Ok(Vec::new());
|
||||
}
|
||||
let all = self.read_all(conversation).await?;
|
||||
let start = all.len().saturating_sub(n);
|
||||
Ok(all[start..].to_vec())
|
||||
}
|
||||
}
|
||||
166
crates/infrastructure/src/conversation_log/summarizer.rs
Normal file
166
crates/infrastructure/src/conversation_log/summarizer.rs
Normal file
@ -0,0 +1,166 @@
|
||||
//! [`HeuristicHandoffSummarizer`] — l'adapter zéro-dépendance du port
|
||||
//! [`HandoffSummarizer`] (cadrage « persistance conversationnelle », lot P4).
|
||||
//!
|
||||
//! Replie un [`Handoff`] de façon **incrémentale**, **déterministe**, **sans I/O** et
|
||||
//! **sans modèle** (ARCHITECTURE §19.2/§19.3, ligne P4 du §19.6). C'est le repli
|
||||
//! universel zéro-dépendance ; un `LlmHandoffSummarizer` plus riche viendra le substituer
|
||||
//! en P10 (OCP — le port async est figé pour ça, cf. [`HandoffSummarizer`]).
|
||||
//!
|
||||
//! ## Stratégie heuristique
|
||||
//!
|
||||
//! Le résumé garde deux choses, en ne lisant que **l'incrément** (`new_turns`) — jamais
|
||||
//! tout le fil depuis zéro :
|
||||
//!
|
||||
//! 1. **Un objectif courant** ([`Handoff::objective`]) : repris **tel quel** de `prev`.
|
||||
//! Si `prev` n'en a pas (ou est `None`), on en **extrait** un depuis le **premier
|
||||
//! tour `Prompt`** de l'incrément (sa première ligne non vide, tronquée) — règle simple
|
||||
//! et déterministe : le premier prompt d'un fil énonce typiquement la tâche. Une fois
|
||||
//! fixé, l'objectif ne change plus (on ne le réécrit pas à chaque tour).
|
||||
//!
|
||||
//! 2. **Une fenêtre des [`WINDOW`] derniers tours** rendue en Markdown. Comme on ne
|
||||
//! dispose pas de tout le fil dans `fold`, on **reconstitue** cette fenêtre à partir des
|
||||
//! tours déjà rendus dans `prev.summary_md` (reparsés) **concaténés** à `new_turns`,
|
||||
//! puis on **tronque** aux [`WINDOW`] derniers. La borne est donc toujours respectée,
|
||||
//! même après de nombreux replis successifs.
|
||||
//!
|
||||
//! Le `summary_md` résultant = (ligne d'objectif si présent) + les [`WINDOW`] derniers
|
||||
//! tours formatés. Format d'un tour stable et reparsable (cf. [`render_turn`]).
|
||||
//!
|
||||
//! ## Curseur (`up_to`)
|
||||
//!
|
||||
//! - `new_turns` non vide ⇒ l'id du **dernier** tour de l'incrément.
|
||||
//! - `new_turns` vide ⇒ on renvoie `prev` **inchangé** (rien de neuf à intégrer).
|
||||
//! - `prev = None` et `new_turns` vide ⇒ handoff vide, curseur = **nil UUID**
|
||||
//! ([`TurnId::from_uuid(Uuid::nil())`]) : sentinelle sûre « aucun tour couvert ».
|
||||
//!
|
||||
//! ## Déterminisme
|
||||
//!
|
||||
//! Mêmes entrées ⇒ même sortie (aucune horloge, aucun aléa, aucune I/O), donc trivialement
|
||||
//! testable.
|
||||
|
||||
use async_trait::async_trait;
|
||||
|
||||
use domain::conversation_log::{ConversationTurn, Handoff, HandoffSummarizer, TurnId, TurnRole};
|
||||
|
||||
/// Nombre maximum de tours conservés dans la fenêtre Markdown du résumé.
|
||||
///
|
||||
/// Borne **publique** (et donc lisible par l'agent Test pour vérifier la troncature sans
|
||||
/// la deviner) : au-delà de `WINDOW` tours, seuls les `WINDOW` derniers sont rendus.
|
||||
pub const WINDOW: usize = 20;
|
||||
|
||||
/// Longueur maximale (en caractères) de l'objectif extrait d'un premier prompt.
|
||||
const OBJECTIVE_MAX_CHARS: usize = 200;
|
||||
|
||||
/// Préfixe de la ligne d'objectif dans le `summary_md` (sert au rendu **et** au reparse).
|
||||
const OBJECTIVE_PREFIX: &str = "**Objectif :** ";
|
||||
|
||||
/// Adapter heuristique zéro-dépendance du résumeur de handoff.
|
||||
///
|
||||
/// Sans état (aucun champ) : une seule instance sert toutes les conversations. Construit
|
||||
/// via [`HeuristicHandoffSummarizer::new`] ou [`Default`].
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
pub struct HeuristicHandoffSummarizer;
|
||||
|
||||
impl HeuristicHandoffSummarizer {
|
||||
/// Construit le résumeur heuristique (sans état).
|
||||
#[must_use]
|
||||
pub const fn new() -> Self {
|
||||
Self
|
||||
}
|
||||
}
|
||||
|
||||
/// Rend un tour en une ligne Markdown stable et **reparsable** (cf. [`parse_rendered`]).
|
||||
///
|
||||
/// Format : `- **<role>:** <texte sur une ligne>`. Le texte est aplati (sauts de ligne →
|
||||
/// espaces) pour garder une ligne par tour, ce qui rend la fenêtre reconstituable depuis
|
||||
/// `prev.summary_md`.
|
||||
fn render_turn(turn: &ConversationTurn) -> String {
|
||||
let label = match turn.role {
|
||||
TurnRole::Prompt => "Prompt",
|
||||
TurnRole::Response => "Response",
|
||||
TurnRole::ToolActivity => "Tool",
|
||||
};
|
||||
let flat = flatten(&turn.text);
|
||||
format!("- **{label}:** {flat}")
|
||||
}
|
||||
|
||||
/// Aplati un texte multi-lignes en une seule ligne (sauts de ligne → espace, trim).
|
||||
fn flatten(text: &str) -> String {
|
||||
text.split_whitespace().collect::<Vec<_>>().join(" ")
|
||||
}
|
||||
|
||||
/// Reparse les lignes de tours déjà rendues dans un `summary_md` précédent.
|
||||
///
|
||||
/// Ne récupère que les lignes-tours (préfixe `- **`), en ignorant la ligne d'objectif et
|
||||
/// les blancs. On garde la **ligne brute** (déjà au bon format) : pas besoin de
|
||||
/// reconstruire un `ConversationTurn` complet, on ne manipule que du Markdown.
|
||||
fn parse_rendered(summary_md: &str) -> Vec<String> {
|
||||
summary_md
|
||||
.lines()
|
||||
.filter(|l| l.starts_with("- **"))
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Extrait un objectif candidat du premier tour `Prompt` de l'incrément, le cas échéant.
|
||||
///
|
||||
/// Première ligne non vide du premier prompt, aplatie et tronquée à [`OBJECTIVE_MAX_CHARS`].
|
||||
fn extract_objective(new_turns: &[ConversationTurn]) -> Option<String> {
|
||||
let first_prompt = new_turns.iter().find(|t| t.role == TurnRole::Prompt)?;
|
||||
let flat = flatten(&first_prompt.text);
|
||||
if flat.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let truncated: String = flat.chars().take(OBJECTIVE_MAX_CHARS).collect();
|
||||
Some(truncated)
|
||||
}
|
||||
|
||||
/// Assemble le `summary_md` final = ligne d'objectif (si présent) + fenêtre de tours.
|
||||
fn render_summary(objective: Option<&str>, window: &[String]) -> String {
|
||||
let mut blocks: Vec<String> = Vec::new();
|
||||
if let Some(obj) = objective {
|
||||
blocks.push(format!("{OBJECTIVE_PREFIX}{obj}"));
|
||||
}
|
||||
if !window.is_empty() {
|
||||
blocks.push(window.join("\n"));
|
||||
}
|
||||
blocks.join("\n\n")
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl HandoffSummarizer for HeuristicHandoffSummarizer {
|
||||
async fn fold(&self, prev: Option<Handoff>, new_turns: &[ConversationTurn]) -> Handoff {
|
||||
// Rien de neuf : on rend `prev` inchangé (ou un handoff vide cohérent si None).
|
||||
if new_turns.is_empty() {
|
||||
return prev.unwrap_or_else(|| {
|
||||
Handoff::new(String::new(), TurnId::from_uuid(uuid::Uuid::nil()), None)
|
||||
});
|
||||
}
|
||||
|
||||
// Objectif : repris de `prev` s'il existe, sinon extrait du premier prompt de
|
||||
// l'incrément (règle simple et déterministe ; figé une fois fixé).
|
||||
let objective = prev
|
||||
.as_ref()
|
||||
.and_then(|h| h.objective.clone())
|
||||
.or_else(|| extract_objective(new_turns));
|
||||
|
||||
// Fenêtre : (tours déjà rendus dans prev) ++ (incrément rendu), tronquée aux
|
||||
// WINDOW derniers. On ne relit jamais tout le log : seul l'incrément est nouveau.
|
||||
let mut window: Vec<String> = prev
|
||||
.as_ref()
|
||||
.map(|h| parse_rendered(&h.summary_md))
|
||||
.unwrap_or_default();
|
||||
window.extend(new_turns.iter().map(render_turn));
|
||||
let start = window.len().saturating_sub(WINDOW);
|
||||
let window = &window[start..];
|
||||
|
||||
// Curseur : l'id du dernier tour de l'incrément (new_turns non vide ici).
|
||||
let up_to = new_turns
|
||||
.last()
|
||||
.map(|t| t.id)
|
||||
.unwrap_or_else(|| TurnId::from_uuid(uuid::Uuid::nil()));
|
||||
|
||||
let summary_md = render_summary(objective.as_deref(), window);
|
||||
Handoff::new(summary_md, up_to, objective)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user