mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-18 20:57:05 +02:00
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:
15 files changed
+1416
-25
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in new issue
Block a user