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>
This commit is contained in:
@ -20,7 +20,7 @@ use async_trait::async_trait;
|
||||
use domain::ports::StoreError;
|
||||
use domain::{
|
||||
ConversationId, ConversationLog, ConversationTurn, Handoff, HandoffStore, HandoffSummarizer,
|
||||
InputSource, TurnId, TurnRole,
|
||||
InputSource, TurnId, TurnRole, HANDOFF_SUMMARY_MAX_CHARS,
|
||||
};
|
||||
|
||||
use application::{AppError, RecordTurn};
|
||||
@ -373,3 +373,109 @@ async fn records_are_isolated_per_conversation() {
|
||||
assert_eq!(hb.up_to, tb.id);
|
||||
assert!(hb.summary_md.contains("beta") && !hb.summary_md.contains("alpha"));
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// LS5 — la borne universelle s'applique côté `record`, indépendamment du résumeur.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Un résumeur **adversarial** qui renvoie toujours un `summary_md` largement
|
||||
/// surdimensionné (au-delà du plafond), au format reparsable, pour prouver que la
|
||||
/// garantie de borne vient de `record` (via `bound_handoff_summary`), PAS du résumeur.
|
||||
#[derive(Default)]
|
||||
struct OversizeSummarizer;
|
||||
|
||||
#[async_trait]
|
||||
impl HandoffSummarizer for OversizeSummarizer {
|
||||
async fn fold(&self, _prev: Option<Handoff>, new_turns: &[ConversationTurn]) -> Handoff {
|
||||
// Beaucoup de lignes de tours bien formées ⇒ summary_md >> plafond.
|
||||
let mut s = String::from("**Objectif :** objectif preserve");
|
||||
for i in 0..1_000 {
|
||||
s.push_str(&format!(
|
||||
"\n- **Response:** ligne surdimensionnee numero {i}"
|
||||
));
|
||||
}
|
||||
let up_to = new_turns
|
||||
.last()
|
||||
.map(|t| t.id)
|
||||
.expect("fold receives at least one turn");
|
||||
Handoff::new(s, up_to, Some("objectif preserve".to_owned()))
|
||||
}
|
||||
}
|
||||
|
||||
/// Avec un résumeur qui produit un summary géant, le handoff **persisté** est borné à
|
||||
/// `HANDOFF_SUMMARY_MAX_CHARS` (garantie universelle), tout en gardant l'ordre `append`-
|
||||
/// d'abord, le fold incrémental (un appel par tour), objectif et curseur préservés.
|
||||
#[tokio::test]
|
||||
async fn record_bounds_persisted_handoff_even_with_oversize_summarizer() {
|
||||
let log = Arc::new(InMemoryConversationLog::default());
|
||||
let handoffs = Arc::new(InMemoryHandoffStore::default());
|
||||
let summarizer = Arc::new(OversizeSummarizer);
|
||||
let uc = RecordTurn::new(log.clone(), handoffs.clone(), summarizer.clone());
|
||||
|
||||
let conv = conv_id(1);
|
||||
let t1 = turn(conv, turn_id(10), "premier tour");
|
||||
|
||||
uc.record(conv, t1.clone()).await.expect("record ok");
|
||||
|
||||
// append-d'abord : le log canonique porte le tour.
|
||||
assert_eq!(log.thread(conv), vec![t1.clone()]);
|
||||
|
||||
// Le handoff persisté est BORNÉ malgré le résumeur surdimensionné.
|
||||
let h = handoffs.loaded(conv).expect("handoff saved");
|
||||
assert!(
|
||||
h.summary_md.chars().count() <= HANDOFF_SUMMARY_MAX_CHARS,
|
||||
"summary persisté borné, obtenu {}",
|
||||
h.summary_md.chars().count()
|
||||
);
|
||||
// Objectif + curseur jamais modifiés par la borne.
|
||||
assert_eq!(h.objective.as_deref(), Some("objectif preserve"));
|
||||
assert_eq!(h.up_to, t1.id);
|
||||
// Reparsable : le préambule d'objectif survit, les lignes de tours sont préfixées.
|
||||
assert!(h.summary_md.contains("**Objectif :** objectif preserve"));
|
||||
for line in h.summary_md.lines().filter(|l| l.starts_with("- **")) {
|
||||
assert!(line.starts_with("- **"));
|
||||
}
|
||||
}
|
||||
|
||||
/// La borne au `record` reste incrémentale sur plusieurs tours : un `fold` par tour
|
||||
/// (jamais de relecture complète), et chaque checkpoint persiste un summary borné.
|
||||
#[tokio::test]
|
||||
async fn record_stays_incremental_and_bounded_across_turns() {
|
||||
use std::sync::Mutex;
|
||||
|
||||
/// Compte les appels `fold` pour prouver l'incrémentalité (un par tour).
|
||||
#[derive(Default)]
|
||||
struct CountingOversize {
|
||||
calls: Mutex<usize>,
|
||||
}
|
||||
#[async_trait]
|
||||
impl HandoffSummarizer for CountingOversize {
|
||||
async fn fold(&self, _prev: Option<Handoff>, new_turns: &[ConversationTurn]) -> Handoff {
|
||||
*self.calls.lock().unwrap() += 1;
|
||||
// L'incrément ne porte qu'un seul tour à chaque checkpoint.
|
||||
assert_eq!(new_turns.len(), 1, "fold incrémental : un tour à la fois");
|
||||
let big = "g".repeat(HANDOFF_SUMMARY_MAX_CHARS * 3);
|
||||
let up_to = new_turns[0].id;
|
||||
Handoff::new(format!("- **Response:** {big}"), up_to, None)
|
||||
}
|
||||
}
|
||||
|
||||
let log = Arc::new(InMemoryConversationLog::default());
|
||||
let handoffs = Arc::new(InMemoryHandoffStore::default());
|
||||
let summarizer = Arc::new(CountingOversize::default());
|
||||
let uc = RecordTurn::new(log.clone(), handoffs.clone(), summarizer.clone());
|
||||
|
||||
let conv = conv_id(1);
|
||||
for n in 1..=5u128 {
|
||||
uc.record(conv, turn(conv, turn_id(n), &format!("tour {n}")))
|
||||
.await
|
||||
.expect("record ok");
|
||||
let h = handoffs.loaded(conv).expect("handoff saved");
|
||||
assert!(
|
||||
h.summary_md.chars().count() <= HANDOFF_SUMMARY_MAX_CHARS,
|
||||
"checkpoint {n} borné, obtenu {}",
|
||||
h.summary_md.chars().count()
|
||||
);
|
||||
}
|
||||
assert_eq!(*summarizer.calls.lock().unwrap(), 5, "un fold par tour");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user