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(
+32
View File
@@ -52,6 +52,10 @@ pub struct FakeGo {
/// Résultat imposé à tout outil (sinon « exec:<nom> »), et archive recall.
pub tool_result: Mutex<Option<String>>,
pub recall: Mutex<Vec<String>>,
/// Blocs servis par /internal/v1/recall/block(s) (lot 46), et lectures
/// reçues (« block <seq> », « blocks »).
pub recall_blocks: Mutex<Vec<serde_json::Value>>,
pub recall_log: Mutex<Vec<String>>,
/// Discussion active annoncée et discussions supprimées.
pub conv_log: Mutex<Vec<String>>,
/// Tours : débuts, vues et fins demandés.
@@ -502,6 +506,34 @@ async fn handle(st: Arc<FakeGo>, req: Request<Incoming>) -> Response<Full<Bytes>
),
None => resp(200, r#"{"busy":false}"#),
},
"/internal/v1/recall/block" => {
let seq = req
.uri()
.query()
.unwrap_or("")
.strip_prefix("seq=")
.unwrap_or("")
.to_string();
st.recall_log.lock().unwrap().push(format!("block {seq}"));
let found = st
.recall_blocks
.lock()
.unwrap()
.iter()
.find(|b| b["seq"].as_u64().map(|n| n.to_string()).as_deref() == Some(seq.as_str()))
.cloned();
match found {
Some(b) => resp(200, b.to_string()),
None => resp(404, r#"{"error":"bloc introuvable"}"#),
}
}
"/internal/v1/recall/blocks" => {
st.recall_log.lock().unwrap().push("blocks".into());
resp(
200,
serde_json::Value::Array(st.recall_blocks.lock().unwrap().clone()).to_string(),
)
}
"/internal/v1/service/prestart" => {
*st.prestarts.lock().unwrap() += 1;
let e = st.tune_busy.lock().unwrap().clone().unwrap_or_default();
+39
View File
@@ -2843,6 +2843,45 @@ async fn job_arriere_plan_par_la_facade() {
assert_eq!(jobs.running(), 0);
}
/// Lot 46 : `recall` et `recall_search` sont exécutés par la façade sur
/// l'archive du Go (rien par /tool/exec).
#[tokio::test]
async fn recall_par_la_facade() {
let calls = sse(&[
serde_json::json!({"choices":[{"delta":{"tool_calls":[
{"index":0,"id":"c1","type":"function","function":{"name":"recall","arguments":"{\"id\":\"R2 \"}"}},
{"index":1,"id":"c2","type":"function","function":{"name":"recall_search","arguments":"{\"query\":\"Gratin\"}"}}
]}}]}),
serde_json::json!({"choices":[{"delta":{},"finish_reason":"tool_calls"}]}),
]);
let (url, seen) = fake_engine(vec![calls, answer("rappelé")]).await;
let (svc, go, _stop, _t) = service_with(Some(&url), |_| {}).await;
*go.recall_blocks.lock().unwrap() = vec![
serde_json::json!({"id":"r1","seq":1,"label":"web_read: gratin","role":"tool","content":"recette du gratin"}),
serde_json::json!({"id":"r2","seq":2,"label":"bash: ls","role":"tool","content":"a.go\nb.go"}),
];
svc.send(serde_json::json!({"message": "rappelle", "cid": "r"}))
.await
.unwrap();
until_done(&svc, 1).await;
assert!(go.tool_log.lock().unwrap().is_empty(), "{:?}", go.tool_log);
let seen = seen.lock().unwrap().clone();
let tools: Vec<String> = seen[1].1["messages"]
.as_array()
.unwrap()
.iter()
.filter(|m| m["role"] == "tool")
.map(|m| m["content"].as_str().unwrap().to_string())
.collect();
assert_eq!(
tools,
[
"[r2 — bash: ls, rappelé verbatim]\na.go\nb.go",
"Blocs trouvés (rappelle le bon avec recall(id)) :\n- r1 — web_read: gratin\n recette du gratin",
]
);
}
/// Lot 45 : `see_image` et `web_screenshot` sont exécutés par la façade
/// (rien par le Go) : sans projecteur, l'image n'est pas lue ; une capture
/// sans url est refusée avant Playwright.
@@ -0,0 +1,615 @@
{
"blocks": [
{
"id": "r1",
"seq": 1,
"label": "web_read: Recette du gratin dauphinois",
"role": "tool",
"content": "Recette du gratin dauphinois\npommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail ",
"created": 0
},
{
"id": "r2",
"seq": 2,
"label": "web_read: Horaires SNCF",
"role": "tool",
"content": "Horaires SNCF\nle train de 14h12 part quai 3, le TRAIN suivant à 15h",
"created": 0
},
{
"id": "r3",
"seq": 3,
"label": "bash: $ ls -la",
"role": "tool",
"content": "\n\n $ ls -la\na.go b.go\tc.go",
"created": 0
},
{
"id": "r4",
"seq": 4,
"label": "assistant: Voici le résumé de l'ÉTÉ : plage, soleil, Été indien.",
"role": "assistant",
"content": "Voici le résumé de l'ÉTÉ : plage, soleil, Été indien.",
"created": 0
},
{
"id": "r5",
"seq": 5,
"label": "user: İSTANBUL et ΣΟΦΙΑ en majuscules",
"role": "user",
"content": "İSTANBUL et ΣΟΦΙΑ en majuscules",
"created": 0
},
{
"id": "r6",
"seq": 6,
"label": "tool: outil sans nom : train de marchandises",
"role": "tool",
"content": "outil sans nom : train de marchandises",
"created": 0
},
{
"id": "r7",
"seq": 7,
"label": "read_file: éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé…",
"role": "tool",
"content": "éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé fin",
"created": 0
},
{
"id": "r8",
"seq": 8,
"label": "web_read: <html><b>balises</b> & entités &amp; \"guillemets\"</html>",
"role": "tool",
"content": "<html><b>balises</b> & entités &amp; \"guillemets\"</html>",
"created": 0
},
{
"id": "r9",
"seq": 9,
"label": "bash",
"role": "tool",
"content": "",
"created": 0
},
{
"id": "r10",
"seq": 10,
"label": "mem_search: très longue première ligne sans fin très longue première ligne sans fi…",
"role": "tool",
"content": "très longue première ligne sans fin très longue première ligne sans fin très longue première ligne sans fin très longue première ligne sans fin très longue première ligne sans fin \nsuite",
"created": 0
},
{
"id": "r11",
"seq": 11,
"label": "bash: lot 0 de la migration : sortie numéro 0",
"role": "tool",
"content": "lot 0 de la migration : sortie numéro 0",
"created": 0
},
{
"id": "r12",
"seq": 12,
"label": "bash: lot 1 de la migration : sortie numéro 1",
"role": "tool",
"content": "lot 1 de la migration : sortie numéro 1",
"created": 0
},
{
"id": "r13",
"seq": 13,
"label": "bash: lot 2 de la migration : sortie numéro 2",
"role": "tool",
"content": "lot 2 de la migration : sortie numéro 2",
"created": 0
},
{
"id": "r14",
"seq": 14,
"label": "bash: lot 3 de la migration : sortie numéro 3",
"role": "tool",
"content": "lot 3 de la migration : sortie numéro 3",
"created": 0
},
{
"id": "r15",
"seq": 15,
"label": "bash: lot 4 de la migration : sortie numéro 4",
"role": "tool",
"content": "lot 4 de la migration : sortie numéro 4",
"created": 0
},
{
"id": "r16",
"seq": 16,
"label": "bash: lot 5 de la migration : sortie numéro 5",
"role": "tool",
"content": "lot 5 de la migration : sortie numéro 5",
"created": 0
},
{
"id": "r17",
"seq": 17,
"label": "bash: lot 6 de la migration : sortie numéro 6",
"role": "tool",
"content": "lot 6 de la migration : sortie numéro 6",
"created": 0
},
{
"id": "r18",
"seq": 18,
"label": "bash: lot 7 de la migration : sortie numéro 7",
"role": "tool",
"content": "lot 7 de la migration : sortie numéro 7",
"created": 0
},
{
"id": "r19",
"seq": 19,
"label": "bash: lot 8 de la migration : sortie numéro 8",
"role": "tool",
"content": "lot 8 de la migration : sortie numéro 8",
"created": 0
},
{
"id": "r20",
"seq": 20,
"label": "bash: lot 9 de la migration : sortie numéro 9",
"role": "tool",
"content": "lot 9 de la migration : sortie numéro 9",
"created": 0
},
{
"id": "r21",
"seq": 21,
"label": "bash: lot 10 de la migration : sortie numéro 10",
"role": "tool",
"content": "lot 10 de la migration : sortie numéro 10",
"created": 0
},
{
"id": "r22",
"seq": 22,
"label": "bash: lot 11 de la migration : sortie numéro 11",
"role": "tool",
"content": "lot 11 de la migration : sortie numéro 11",
"created": 0
},
{
"id": "r23",
"seq": 23,
"label": "bash: lot 12 de la migration : sortie numéro 12",
"role": "tool",
"content": "lot 12 de la migration : sortie numéro 12",
"created": 0
},
{
"id": "r24",
"seq": 24,
"label": "bash: lot 13 de la migration : sortie numéro 13",
"role": "tool",
"content": "lot 13 de la migration : sortie numéro 13",
"created": 0
},
{
"id": "r25",
"seq": 25,
"label": "bash: lot 14 de la migration : sortie numéro 14",
"role": "tool",
"content": "lot 14 de la migration : sortie numéro 14",
"created": 0
},
{
"id": "r26",
"seq": 26,
"label": "bash: lot 15 de la migration : sortie numéro 15",
"role": "tool",
"content": "lot 15 de la migration : sortie numéro 15",
"created": 0
},
{
"id": "r27",
"seq": 27,
"label": "bash: lot 16 de la migration : sortie numéro 16",
"role": "tool",
"content": "lot 16 de la migration : sortie numéro 16",
"created": 0
},
{
"id": "r28",
"seq": 28,
"label": "bash: lot 17 de la migration : sortie numéro 17",
"role": "tool",
"content": "lot 17 de la migration : sortie numéro 17",
"created": 0
},
{
"id": "r29",
"seq": 29,
"label": "bash: lot 18 de la migration : sortie numéro 18",
"role": "tool",
"content": "lot 18 de la migration : sortie numéro 18",
"created": 0
},
{
"id": "r30",
"seq": 30,
"label": "bash: lot 19 de la migration : sortie numéro 19",
"role": "tool",
"content": "lot 19 de la migration : sortie numéro 19",
"created": 0
},
{
"id": "r31",
"seq": 31,
"label": "bash: lot 20 de la migration : sortie numéro 20",
"role": "tool",
"content": "lot 20 de la migration : sortie numéro 20",
"created": 0
},
{
"id": "r32",
"seq": 32,
"label": "bash: lot 21 de la migration : sortie numéro 21",
"role": "tool",
"content": "lot 21 de la migration : sortie numéro 21",
"created": 0
},
{
"id": "r33",
"seq": 33,
"label": "bash: lot 22 de la migration : sortie numéro 22",
"role": "tool",
"content": "lot 22 de la migration : sortie numéro 22",
"created": 0
},
{
"id": "r34",
"seq": 34,
"label": "bash: lot 23 de la migration : sortie numéro 23",
"role": "tool",
"content": "lot 23 de la migration : sortie numéro 23",
"created": 0
},
{
"id": "r35",
"seq": 35,
"label": "bash: lot 24 de la migration : sortie numéro 24",
"role": "tool",
"content": "lot 24 de la migration : sortie numéro 24",
"created": 0
}
],
"steps": [
{
"name": "sans id",
"tool": "recall",
"args": {},
"result": "[erreur] id manquant"
},
{
"name": "id non texte",
"tool": "recall",
"args": {
"id": 7
},
"result": "[erreur] id manquant"
},
{
"name": "id blanc",
"tool": "recall",
"args": {
"id": " "
},
"result": "[erreur] id manquant"
},
{
"name": "r1",
"tool": "recall",
"args": {
"id": "r1"
},
"result": "[r1 — web_read: Recette du gratin dauphinois, rappelé verbatim]\nRecette du gratin dauphinois\npommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail "
},
{
"name": "R2 majuscule",
"tool": "recall",
"args": {
"id": "R2"
},
"result": "[r2 — web_read: Horaires SNCF, rappelé verbatim]\nHoraires SNCF\nle train de 14h12 part quai 3, le TRAIN suivant à 15h"
},
{
"name": "espaces",
"tool": "recall",
"args": {
"id": " 3 "
},
"result": "[r3 — bash: $ ls -la, rappelé verbatim]\n\n\n $ ls -la\na.go b.go\tc.go"
},
{
"name": "sans préfixe",
"tool": "recall",
"args": {
"id": "4"
},
"result": "[r4 — assistant: Voici le résumé de l'ÉTÉ : plage, soleil, Été indien., rappelé verbatim]\nVoici le résumé de l'ÉTÉ : plage, soleil, Été indien."
},
{
"name": "espace après le préfixe",
"tool": "recall",
"args": {
"id": "r 5"
},
"result": "[r5 — user: İSTANBUL et ΣΟΦΙΑ en majuscules, rappelé verbatim]\nİSTANBUL et ΣΟΦΙΑ en majuscules"
},
{
"name": "deux préfixes",
"tool": "recall",
"args": {
"id": "rR6"
},
"result": "[r6 — tool: outil sans nom : train de marchandises, rappelé verbatim]\noutil sans nom : train de marchandises"
},
{
"name": "préfixes à l'envers",
"tool": "recall",
"args": {
"id": "Rr6"
},
"result": "[erreur] bloc \"Rr6\" introuvable — vérifie l'id, ou utilise recall_search pour le retrouver par mots-clés"
},
{
"name": "zéros de tête",
"tool": "recall",
"args": {
"id": "r007"
},
"result": "[r7 — read_file: éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé…, rappelé verbatim]\néééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé fin"
},
{
"name": "contenu long",
"tool": "recall",
"args": {
"id": "r7"
},
"result": "[r7 — read_file: éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé…, rappelé verbatim]\néééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé fin"
},
{
"name": "caractères spéciaux",
"tool": "recall",
"args": {
"id": "r8"
},
"result": "[r8 — web_read: <html><b>balises</b> & entités &amp; \"guillemets\"</html>, rappelé verbatim]\n<html><b>balises</b> & entités &amp; \"guillemets\"</html>"
},
{
"name": "bloc vide",
"tool": "recall",
"args": {
"id": "r9"
},
"result": "[r9 — bash, rappelé verbatim]\n"
},
{
"name": "mal formé",
"tool": "recall",
"args": {
"id": "rr"
},
"result": "[erreur] bloc \"rr\" introuvable — vérifie l'id, ou utilise recall_search pour le retrouver par mots-clés"
},
{
"name": "négatif",
"tool": "recall",
"args": {
"id": "r-1"
},
"result": "[erreur] bloc \"r-1\" introuvable — vérifie l'id, ou utilise recall_search pour le retrouver par mots-clés"
},
{
"name": "signe plus",
"tool": "recall",
"args": {
"id": "+1"
},
"result": "[erreur] bloc \"+1\" introuvable — vérifie l'id, ou utilise recall_search pour le retrouver par mots-clés"
},
{
"name": "zéro",
"tool": "recall",
"args": {
"id": "r0"
},
"result": "[erreur] bloc \"r0\" introuvable — vérifie l'id, ou utilise recall_search pour le retrouver par mots-clés"
},
{
"name": "absent",
"tool": "recall",
"args": {
"id": "r999"
},
"result": "[erreur] bloc \"r999\" introuvable — vérifie l'id, ou utilise recall_search pour le retrouver par mots-clés"
},
{
"name": "démesuré",
"tool": "recall",
"args": {
"id": "r99999999999999999999"
},
"result": "[erreur] bloc \"r99999999999999999999\" introuvable — vérifie l'id, ou utilise recall_search pour le retrouver par mots-clés"
},
{
"name": "guillemets dans l'id",
"tool": "recall",
"args": {
"id": "r\"9\\"
},
"result": "[erreur] bloc \"r\\\"9\\\\\" introuvable — vérifie l'id, ou utilise recall_search pour le retrouver par mots-clés"
},
{
"name": "sans requête",
"tool": "recall_search",
"args": {},
"result": "[erreur] query manquante"
},
{
"name": "requête non texte",
"tool": "recall_search",
"args": {
"query": 3
},
"result": "[erreur] query manquante"
},
{
"name": "requête blanche",
"tool": "recall_search",
"args": {
"query": " "
},
"result": "[erreur] query manquante"
},
{
"name": "mots trop courts",
"tool": "recall_search",
"args": {
"query": "a b ! ?"
},
"result": "[aucun bloc archivé ne correspond]"
},
{
"name": "train horaires",
"tool": "recall_search",
"args": {
"query": "train horaires"
},
"result": "Blocs trouvés (rappelle le bon avec recall(id)) :\n- r2 — web_read: Horaires SNCF\n Horaires SNCF le train de 14h12 part quai 3, le TRAIN suivant à 15h\n- r6 — tool: outil sans nom : train de marchandises\n outil sans nom : train de marchandises"
},
{
"name": "casse",
"tool": "recall_search",
"args": {
"query": "TRAIN"
},
"result": "Blocs trouvés (rappelle le bon avec recall(id)) :\n- r6 — tool: outil sans nom : train de marchandises\n outil sans nom : train de marchandises\n- r2 — web_read: Horaires SNCF\n Horaires SNCF le train de 14h12 part quai 3, le TRAIN suivant à 15h"
},
{
"name": "accents majuscules",
"tool": "recall_search",
"args": {
"query": "ÉTÉ"
},
"result": "Blocs trouvés (rappelle le bon avec recall(id)) :\n- r4 — assistant: Voici le résumé de l'ÉTÉ : plage, soleil, Été indien.\n Voici le résumé de l'ÉTÉ : plage, soleil, Été indien."
},
{
"name": "i turc",
"tool": "recall_search",
"args": {
"query": "İstanbul"
},
"result": "Blocs trouvés (rappelle le bon avec recall(id)) :\n- r5 — user: İSTANBUL et ΣΟΦΙΑ en majuscules\n İSTANBUL et ΣΟΦΙΑ en majuscules"
},
{
"name": "sigma",
"tool": "recall_search",
"args": {
"query": "σοφια"
},
"result": "[aucun bloc archivé ne correspond]"
},
{
"name": "souligné coupé",
"tool": "recall_search",
"args": {
"query": "web_read"
},
"result": "Blocs trouvés (rappelle le bon avec recall(id)) :\n- r8 — web_read: <html><b>balises</b> & entités &amp; \"guillemets\"</html>\n <html><b>balises</b> & entités &amp; \"guillemets\"</html>\n- r2 — web_read: Horaires SNCF\n Horaires SNCF le train de 14h12 part quai 3, le TRAIN suivant à 15h\n- r1 — web_read: Recette du gratin dauphinois\n Recette du gratin dauphinois pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, crème, ail pommes de terre, cr…\n- r7 — read_file: éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé…\n éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé…"
},
{
"name": "extrait borné",
"tool": "recall_search",
"args": {
"query": "fin éé"
},
"result": "Blocs trouvés (rappelle le bon avec recall(id)) :\n- r7 — read_file: éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé…\n éééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééééé…\n- r10 — mem_search: très longue première ligne sans fin très longue première ligne sans fi…\n très longue première ligne sans fin très longue première ligne sans fin très longue première ligne sans fin très longue première ligne sans fin très longue prem…"
},
{
"name": "première ligne longue",
"tool": "recall_search",
"args": {
"query": "suite"
},
"result": "Blocs trouvés (rappelle le bon avec recall(id)) :\n- r10 — mem_search: très longue première ligne sans fin très longue première ligne sans fi…\n très longue première ligne sans fin très longue première ligne sans fin très longue première ligne sans fin très longue première ligne sans fin très longue prem…"
},
{
"name": "limite 1",
"tool": "recall_search",
"args": {
"limit": 1,
"query": "train"
},
"result": "Blocs trouvés (rappelle le bon avec recall(id)) :\n- r6 — tool: outil sans nom : train de marchandises\n outil sans nom : train de marchandises"
},
{
"name": "limite tronquée",
"tool": "recall_search",
"args": {
"limit": 2.7,
"query": "train"
},
"result": "Blocs trouvés (rappelle le bon avec recall(id)) :\n- r6 — tool: outil sans nom : train de marchandises\n outil sans nom : train de marchandises\n- r2 — web_read: Horaires SNCF\n Horaires SNCF le train de 14h12 part quai 3, le TRAIN suivant à 15h"
},
{
"name": "limite négative",
"tool": "recall_search",
"args": {
"limit": -3,
"query": "lot"
},
"result": "Blocs trouvés (rappelle le bon avec recall(id)) :\n- r35 — bash: lot 24 de la migration : sortie numéro 24\n lot 24 de la migration : sortie numéro 24\n- r34 — bash: lot 23 de la migration : sortie numéro 23\n lot 23 de la migration : sortie numéro 23\n- r33 — bash: lot 22 de la migration : sortie numéro 22\n lot 22 de la migration : sortie numéro 22\n- r32 — bash: lot 21 de la migration : sortie numéro 21\n lot 21 de la migration : sortie numéro 21\n- r31 — bash: lot 20 de la migration : sortie numéro 20\n lot 20 de la migration : sortie numéro 20"
},
{
"name": "défaut",
"tool": "recall_search",
"args": {
"query": "migration"
},
"result": "Blocs trouvés (rappelle le bon avec recall(id)) :\n- r35 — bash: lot 24 de la migration : sortie numéro 24\n lot 24 de la migration : sortie numéro 24\n- r34 — bash: lot 23 de la migration : sortie numéro 23\n lot 23 de la migration : sortie numéro 23\n- r33 — bash: lot 22 de la migration : sortie numéro 22\n lot 22 de la migration : sortie numéro 22\n- r32 — bash: lot 21 de la migration : sortie numéro 21\n lot 21 de la migration : sortie numéro 21\n- r31 — bash: lot 20 de la migration : sortie numéro 20\n lot 20 de la migration : sortie numéro 20"
},
{
"name": "vingt au plus",
"tool": "recall_search",
"args": {
"limit": 50,
"query": "lot migration"
},
"result": "Blocs trouvés (rappelle le bon avec recall(id)) :\n- r35 — bash: lot 24 de la migration : sortie numéro 24\n lot 24 de la migration : sortie numéro 24\n- r34 — bash: lot 23 de la migration : sortie numéro 23\n lot 23 de la migration : sortie numéro 23\n- r33 — bash: lot 22 de la migration : sortie numéro 22\n lot 22 de la migration : sortie numéro 22\n- r32 — bash: lot 21 de la migration : sortie numéro 21\n lot 21 de la migration : sortie numéro 21\n- r31 — bash: lot 20 de la migration : sortie numéro 20\n lot 20 de la migration : sortie numéro 20\n- r30 — bash: lot 19 de la migration : sortie numéro 19\n lot 19 de la migration : sortie numéro 19\n- r29 — bash: lot 18 de la migration : sortie numéro 18\n lot 18 de la migration : sortie numéro 18\n- r28 — bash: lot 17 de la migration : sortie numéro 17\n lot 17 de la migration : sortie numéro 17\n- r27 — bash: lot 16 de la migration : sortie numéro 16\n lot 16 de la migration : sortie numéro 16\n- r26 — bash: lot 15 de la migration : sortie numéro 15\n lot 15 de la migration : sortie numéro 15\n- r25 — bash: lot 14 de la migration : sortie numéro 14\n lot 14 de la migration : sortie numéro 14\n- r24 — bash: lot 13 de la migration : sortie numéro 13\n lot 13 de la migration : sortie numéro 13\n- r23 — bash: lot 12 de la migration : sortie numéro 12\n lot 12 de la migration : sortie numéro 12\n- r22 — bash: lot 11 de la migration : sortie numéro 11\n lot 11 de la migration : sortie numéro 11\n- r21 — bash: lot 10 de la migration : sortie numéro 10\n lot 10 de la migration : sortie numéro 10\n- r20 — bash: lot 9 de la migration : sortie numéro 9\n lot 9 de la migration : sortie numéro 9\n- r19 — bash: lot 8 de la migration : sortie numéro 8\n lot 8 de la migration : sortie numéro 8\n- r18 — bash: lot 7 de la migration : sortie numéro 7\n lot 7 de la migration : sortie numéro 7\n- r17 — bash: lot 6 de la migration : sortie numéro 6\n lot 6 de la migration : sortie numéro 6\n- r16 — bash: lot 5 de la migration : sortie numéro 5\n lot 5 de la migration : sortie numéro 5"
},
{
"name": "limite texte ignorée",
"tool": "recall_search",
"args": {
"limit": "3",
"query": "lot"
},
"result": "Blocs trouvés (rappelle le bon avec recall(id)) :\n- r35 — bash: lot 24 de la migration : sortie numéro 24\n lot 24 de la migration : sortie numéro 24\n- r34 — bash: lot 23 de la migration : sortie numéro 23\n lot 23 de la migration : sortie numéro 23\n- r33 — bash: lot 22 de la migration : sortie numéro 22\n lot 22 de la migration : sortie numéro 22\n- r32 — bash: lot 21 de la migration : sortie numéro 21\n lot 21 de la migration : sortie numéro 21\n- r31 — bash: lot 20 de la migration : sortie numéro 20\n lot 20 de la migration : sortie numéro 20"
},
{
"name": "rien ne correspond",
"tool": "recall_search",
"args": {
"query": "motabsolumentabsent"
},
"result": "[aucun bloc archivé ne correspond]"
},
{
"name": "ponctuation",
"tool": "recall_search",
"args": {
"query": "«entités»,&amp;"
},
"result": "Blocs trouvés (rappelle le bon avec recall(id)) :\n- r8 — web_read: <html><b>balises</b> & entités &amp; \"guillemets\"</html>\n <html><b>balises</b> & entités &amp; \"guillemets\"</html>"
}
]
}
+58
View File
@@ -0,0 +1,58 @@
//! Lot 46 : outils `recall` et `recall_search` de l'agent exécutés par la
//! façade, rejoués sur le journal du Go (`internal/loki/parity_recall_test.go`
//! → `tests/parity_recall/recall.json`) : la même archive, servie par le faux
//! Go comme le vrai la sert, les mêmes appels, les mêmes textes.
mod common;
use common::fakego;
use loki_rs::store::Store;
use serde_json::{Map, Value};
#[tokio::test]
async fn recall_comme_le_go() {
let fx: Value =
serde_json::from_str(include_str!("parity_recall/recall.json")).expect("journal");
let tmp = tempfile::tempdir().unwrap();
let (_, go, _stop) = fakego::start(tmp.path().join("home")).await;
*go.recall_blocks.lock().unwrap() = fx["blocks"].as_array().unwrap().clone();
let backend = go.backend.lock().unwrap().unwrap();
let store = Store::new(backend, fakego::JETON.into());
for st in fx["steps"].as_array().unwrap() {
let name = st["name"].as_str().unwrap();
let args: Map<String, Value> = serde_json::from_value(st["args"].clone()).unwrap();
let got = loki_rs::recall::exec(&store, st["tool"].as_str().unwrap(), &args)
.await
.expect("le Go répond");
assert_eq!(got, st["result"].as_str().unwrap(), "{name}");
}
// Une recherche lit l'archive une fois ; un id mal formé ne la lit pas.
let log = go.recall_log.lock().unwrap().clone();
assert!(log.contains(&"block 7".to_string()), "{log:?}");
assert!(!log.iter().any(|l| l == "block "), "{log:?}");
}
/// Go injoignable : `None`, l'appel passe alors par lui (`/tool/exec`).
#[tokio::test]
async fn go_injoignable() {
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let addr = l.local_addr().unwrap();
drop(l);
let store = Store::new(addr, "x".into());
let mut args = Map::new();
args.insert("id".into(), Value::from("r1"));
assert_eq!(loki_rs::recall::exec(&store, "recall", &args).await, None);
let mut args = Map::new();
args.insert("query".into(), Value::from("train"));
assert_eq!(
loki_rs::recall::exec(&store, "recall_search", &args).await,
None
);
// Les refus avant lecture, eux, ne demandent rien au Go.
assert_eq!(
loki_rs::recall::exec(&store, "recall", &Map::new())
.await
.as_deref(),
Some("[erreur] id manquant")
);
}