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:
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