Files
Loki/rust/loki-rs/src/recall.rs
T
Claude 92e9adee7d 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
2026-10-16 06:54:35 +00:00

195 lines
6.3 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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;