//! [`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 : `- **:** `. 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::>().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 { 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 { 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 = 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, 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 = 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) } }