Lot 46 : outils recall et recall_search exécutés par la façade

Les deux outils qui rappellent un bloc archivé par la compaction ne passent
plus par /tool/exec du Go : src/recall.rs porte toolRecall et
toolRecallSearch (id tolérant comme recallIDToSeq, mots-clés en minuscules
rune par rune comme strings.ToLower, score libellé double et plus récent
d'abord, limites, extraits et textes du Go). L'archive reste au Go, seul à
tenir la séquence de la base, qui la sert à la façade
(GET /internal/v1/recall/block?seq=N et /internal/v1/recall/blocks, blocs
chiffrés sous une mémoire verrouillée exclus) ; ses propres outils passent
par les mêmes lectures. Go injoignable : l'appel passe par lui.

Parité : journal de 41 appels sur une archive de 35 blocs tiré du Go
(parity_recall_test.go → tests/parity_recall/recall.json), rejoué par
tests/recall.rs ; un tour de la façade qui rappelle et cherche
(tests/lot6.rs) ; TestRecallInterneLecture côté Go.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016tcGfJWefW5kUGoC2wL8cv
This commit is contained in:
Claude committed 2026-10-16 06:54:35 +00:00
1 parent db1f20c0a4
commit 92e9adee7d
15 files changed
+1416 -25

No files matched your search

+10
View File
@@ -718,6 +718,16 @@ impl TurnHost for GoTurnHost {
..Default::default()
};
}
// Mémoire longue de la conversation (lot 46) : l'archive est au Go,
// l'id, le score et la mise en forme ici ; Go injoignable : par lui.
if crate::recall::is_tool(name)
&& let Some(result) = crate::recall::exec(&self.shared.store, name, args).await
{
return ToolOutcome {
result,
..Default::default()
};
}
// Outils d'image (lot 45) : see_image dans le dossier de la
// discussion, web_screenshot par Playwright ; l'image relayée au
// modèle s'il la voit.
+1
View File
@@ -41,6 +41,7 @@ pub mod presets_api;
pub mod projects_api;
pub mod proxy;
pub mod push;
pub mod recall;
pub mod serve;
pub mod settings;
pub mod store;
+194
View File
@@ -0,0 +1,194 @@
//! Outils `recall` et `recall_search` de l'agent (lot 46) — port de
//! `toolRecall` / `toolRecallSearch` (`chat_recall.go`).
//!
//! La compaction archive verbatim les gros blocs qu'elle résume ; l'archive
//! reste au Go (numérotée par la séquence de sa base), qui sert les blocs
//! lisibles (`/internal/v1/recall/block`, `/internal/v1/recall/blocks`).
//! Ici : l'id tolérant, le score par mots-clés, la mise en forme.
use std::collections::BTreeSet;
use serde::Deserialize;
use serde_json::{Map, Value};
use crate::store::Store;
/// `recallSearchDefault`, `recallSearchMax`.
const SEARCH_DEFAULT: usize = 5;
const SEARCH_MAX: usize = 20;
/// `recallBlock`.
#[derive(Debug, Clone, Default, PartialEq, Deserialize)]
#[serde(default)]
pub struct Block {
pub id: String,
pub seq: u64,
pub label: String,
pub role: String,
pub content: String,
}
/// Les outils de ce module.
pub fn is_tool(name: &str) -> bool {
name == "recall" || name == "recall_search"
}
/// `recallIDToSeq` : « r7 », « R7 », « 7 », espaces tolérés.
pub fn id_to_seq(id: &str) -> Option<u64> {
let s = id.trim();
let s = s.strip_prefix('r').unwrap_or(s);
let s = s.strip_prefix('R').unwrap_or(s);
let s = s.trim();
// `strconv.ParseUint(s, 10, 64)` : des chiffres seulement (ni signe).
if s.is_empty() || !s.bytes().all(|b| b.is_ascii_digit()) {
return None;
}
s.parse().ok()
}
/// `strings.ToLower` du Go : rune par rune, sans casse contextuelle.
pub fn go_lower(s: &str) -> String {
s.chars()
.map(|c| {
let mut l = c.to_lowercase();
match (l.next(), l.next()) {
(Some(a), None) => a,
// « İ » (U+0130), seule minuscule de deux runes : `i` en Go.
_ => 'i',
}
})
.collect()
}
/// `recallTokens` : mots d'au moins deux runes, coupés sur tout ce qui n'est
/// ni a–z, ni 0–9, ni à–ÿ.
pub fn tokens(s: &str) -> BTreeSet<String> {
go_lower(s)
.split(|c: char| {
!(c.is_ascii_lowercase() || c.is_ascii_digit() || ('à'..='ÿ').contains(&c))
})
.filter(|w| w.chars().count() >= 2)
.map(str::to_string)
.collect()
}
/// `recallSearch` : score = termes présents, le libellé comptant double ; à
/// égalité, le plus récent d'abord.
pub fn search(blocks: Vec<Block>, query: &str, limit: i64) -> Vec<Block> {
let terms = tokens(query);
if terms.is_empty() {
return Vec::new();
}
let limit = if limit <= 0 {
SEARCH_DEFAULT
} else {
(limit as usize).min(SEARCH_MAX)
};
let mut hits: Vec<(Block, usize)> = blocks
.into_iter()
.filter_map(|b| {
let label = go_lower(&b.label);
let body = go_lower(&b.content);
let score: usize = terms
.iter()
.map(|t| {
2 * usize::from(label.contains(t.as_str()))
+ usize::from(body.contains(t.as_str()))
})
.sum();
(score > 0).then_some((b, score))
})
.collect();
hits.sort_by(|a, b| b.1.cmp(&a.1).then(b.0.seq.cmp(&a.0.seq)));
hits.truncate(limit);
hits.into_iter().map(|h| h.0).collect()
}
/// `recallSnippet` : une ligne, bornée à `n` runes.
pub fn snippet(s: &str, n: usize) -> String {
let s = s.split_whitespace().collect::<Vec<_>>().join(" ");
if s.chars().count() > n {
let head: String = s.chars().take(n).collect();
return format!("{}…", head.trim());
}
s
}
/// `toolRecall`, le bloc lu (`None` : absent ou illisible).
pub fn recall_text(args: &Map<String, Value>, blk: Option<Block>) -> String {
let id = args.get("id").and_then(Value::as_str).unwrap_or("");
let Some(b) = blk else {
return format!(
"[erreur] bloc {} introuvable — vérifie l'id, ou utilise recall_search pour le retrouver par mots-clés",
crate::gojson::quote(id)
);
};
format!("[{} — {}, rappelé verbatim]\n{}", b.id, b.label, b.content)
}
/// `toolRecallSearch`, sur les résultats déjà triés.
pub fn search_text(hits: &[Block]) -> String {
if hits.is_empty() {
return "[aucun bloc archivé ne correspond]".into();
}
let mut out = String::from("Blocs trouvés (rappelle le bon avec recall(id)) :\n");
for h in hits {
out += &format!("- {} — {}\n {}\n", h.id, h.label, snippet(&h.content, 160));
}
out.trim_end_matches('\n').to_string()
}
/// `limit` du Go : `args["limit"].(float64)` tronqué.
fn arg_limit(args: &Map<String, Value>) -> i64 {
args.get("limit")
.and_then(Value::as_f64)
.map_or(0, |f| f as i64)
}
/// Exécute `recall` ou `recall_search` sur l'archive du Go. `None` : le Go
/// n'a pas répondu (l'appel passe alors par lui).
pub async fn exec(store: &Store, name: &str, args: &Map<String, Value>) -> Option<String> {
match name {
"recall" => {
let id = args.get("id").and_then(Value::as_str).unwrap_or("");
if id.trim().is_empty() {
return Some("[erreur] id manquant".into());
}
let Some(seq) = id_to_seq(id) else {
return Some(recall_text(args, None));
};
let (status, v) = store
.internal_get(&format!("/internal/v1/recall/block?seq={seq}"))
.await
.ok()?;
let blk = match status.as_u16() {
200 => serde_json::from_value(v).ok(),
404 => None,
_ => return None,
};
Some(recall_text(args, blk))
}
"recall_search" => {
let q = args.get("query").and_then(Value::as_str).unwrap_or("");
if q.trim().is_empty() {
return Some("[erreur] query manquante".into());
}
if tokens(q).is_empty() {
return Some(search_text(&[]));
}
let (status, v) = store
.internal_get("/internal/v1/recall/blocks")
.await
.ok()?;
if status.as_u16() != 200 {
return None;
}
let blocks: Vec<Block> = serde_json::from_value(v).ok()?;
Some(search_text(&search(blocks, q, arg_limit(args))))
}
_ => None,
}
}
#[cfg(test)]
mod tests;
+91
View File
@@ -0,0 +1,91 @@
//! Tests unitaires de recall (lot 46) ; la parité avec le Go est dans
//! `tests/recall.rs`.
use super::*;
fn blk(seq: u64, label: &str, content: &str) -> Block {
Block {
id: format!("r{seq}"),
seq,
label: label.into(),
role: "tool".into(),
content: content.into(),
}
}
#[test]
fn id_tolerant() {
for s in ["r7", "R7", " r7 ", "7", "rR7", "r 7", "007"] {
assert_eq!(id_to_seq(s), Some(7), "{s}");
}
for s in [
"",
"rr",
"abc",
"r-1",
"+7",
"r+7",
"7a",
"Rr7",
"99999999999999999999",
] {
assert_eq!(id_to_seq(s), None, "{s}");
}
}
#[test]
fn mots_cles() {
let t: Vec<String> = tokens("Le TRAIN de 14h12, quai n°3 — Été ÿ x")
.into_iter()
.collect();
assert_eq!(t, ["14h12", "de", "le", "quai", "train", "été"]);
assert_eq!(go_lower("İSTANBUL Σ"), "istanbul σ");
assert!(tokens("a b ! ?").is_empty());
}
#[test]
fn score_et_ordre() {
let blocks = vec![
blk(1, "web_read: recette", "gratin dauphinois"),
blk(2, "web_read: horaires", "le train part quai 3"),
blk(3, "bash", "train train"),
blk(4, "autre", "rien"),
];
let ids: Vec<String> = search(blocks.clone(), "train horaires", 0)
.into_iter()
.map(|b| b.id)
.collect();
assert_eq!(ids, ["r2", "r3"]);
// À égalité, le plus récent d'abord ; limite respectée.
let ids: Vec<String> = search(blocks, "web_read", 1)
.into_iter()
.map(|b| b.id)
.collect();
assert_eq!(ids, ["r2"]);
}
#[test]
fn extraits() {
assert_eq!(snippet(" a\n\tb c ", 160), "a b c");
assert_eq!(snippet("abcdef", 3), "abc…");
assert_eq!(snippet("ab cdef", 3), "ab…");
}
#[test]
fn textes() {
let mut args = Map::new();
args.insert("id".into(), Value::from("r\"9"));
assert_eq!(
recall_text(&args, None),
"[erreur] bloc \"r\\\"9\" introuvable — vérifie l'id, ou utilise recall_search pour le retrouver par mots-clés"
);
assert_eq!(
recall_text(&args, Some(blk(9, "bash: ls", "a\nb"))),
"[r9 — bash: ls, rappelé verbatim]\na\nb"
);
assert_eq!(search_text(&[]), "[aucun bloc archivé ne correspond]");
assert_eq!(
search_text(&[blk(2, "x", "y z")]),
"Blocs trouvés (rappelle le bon avec recall(id)) :\n- r2 — x\n y z"
);
}
+11
View File
@@ -104,6 +104,17 @@ impl Store {
serde_json::from_slice(&bytes).map_err(|e| e.to_string())
}
/// GET à l'API interne, statut compris (404 à distinguer d'une panne).
/// Corps vide ou illisible → `Value::Null`.
pub async fn internal_get(
&self,
path: &str,
) -> Result<(StatusCode, serde_json::Value), String> {
let (status, bytes) = self.call(Method::GET, path, Vec::new()).await?;
let v = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null);
Ok((status, v))
}
/// POST JSON à l'API interne, statut compris (refus métier à distinguer :
/// 409, 410…). Corps vide ou illisible → `Value::Null`.
pub async fn internal_post(