Files
IdeA/crates/infrastructure/src/conversation_log/summarizer.rs
Blomios 13a953cb05 feat(domain,infra,app): borne summary_md du handoff + durcissement seam LLM (LS5)
Clôt le must-have perf du handoff :
- domain : HANDOFF_SUMMARY_MAX_CHARS + bound_handoff_summary (helpers privés), re-export lib.
- infrastructure : TURN_LINE_MAX_CHARS + élision dans render_turn (summarizer), re-exports.
- application : borne entre fold et save (conversation/record), borne défensive
  dans resolve_handoff (agent/lifecycle) + module test.
- seam LLM prêt mais NON activé (aucun LLM câblé).
- tests (QA, verts) : conversation_log, conversation_record, agent_lifecycle.

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

188 lines
8.2 KiB
Rust

//! [`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;
/// Longueur maximale (en caractères) du **texte aplati** d'un tour rendu sur une ligne
/// (lot LS5). C'est le correctif central du trou de perf : sans borne, un gros
/// `Response` produit une ligne géante qui gonfle le `summary_md` (et donc le contexte
/// injecté à chaque lancement). Au-delà, le texte est tronqué avec une élision `…` ; le
/// préfixe `- **role:**` reste **intact** (la ligne reste reparsable). Borne **publique**
/// pour que l'agent Test la vérifie sans la deviner.
pub const TURN_LINE_MAX_CHARS: usize = 240;
/// 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",
};
// Borne LS5 : le texte aplati est tronqué à `TURN_LINE_MAX_CHARS` (élision `…`) pour
// éviter qu'un gros Response ne produise une ligne géante. Le préfixe reste intact.
let flat = elide(&flatten(&turn.text), TURN_LINE_MAX_CHARS);
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(" ")
}
/// Tronque `text` à `max_chars` caractères (char-boundary safe), en ajoutant une
/// élision `…` quand il a effectivement été coupé. En deçà, renvoie le texte tel quel.
fn elide(text: &str, max_chars: usize) -> String {
if text.chars().count() <= max_chars {
text.to_owned()
} else {
let kept: String = text.chars().take(max_chars).collect();
format!("{kept}")
}
}
/// 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)
}
}