diff --git a/docs/superpowers/specs/2026-10-16-rust-migration-lot46-recall-design.md b/docs/superpowers/specs/2026-10-16-rust-migration-lot46-recall-design.md new file mode 100644 index 0000000..5da22f9 --- /dev/null +++ b/docs/superpowers/specs/2026-10-16-rust-migration-lot46-recall-design.md @@ -0,0 +1,97 @@ +# Lot 46 — Outils recall et recall_search dans la façade + +## Contexte + +La compaction résume les vieux tours d'une discussion. Avant de résumer un +gros bloc (un code, une page, un diff), elle l'archive verbatim sous un id +court (`r7`) que le résumé cite. L'agent a deux outils pour le retrouver : + +- `recall(id)` rend le bloc exact ; +- `recall_search(query)` retrouve un bloc et son id par mots-clés, même + sorti du résumé courant. + +La façade compacte elle-même et archive par le Go +(`/internal/v1/recall/archive`, lot 6) : l'id vient de la séquence de la +base, que seul le Go tient. Les deux outils passaient encore par +`/internal/v1/tool/exec`. + +## Le lot + +`src/recall.rs` porte `toolRecall` et `toolRecallSearch` +(`chat_recall.go`) : + +- **l'id tolérant** (`recallIDToSeq`) : + - espaces, un préfixe `r` puis un préfixe `R`, espaces encore ; + - des chiffres seulement : ni signe, ni dépassement de 64 bits ; + - un id mal formé ne lit pas l'archive ; +- **les mots-clés** (`recallTokens`) : + - mise en minuscules rune par rune, comme `strings.ToLower` (`İ` + donne `i`, `Σ` donne `σ`) ; + - coupés sur tout ce qui n'est ni `a`–`z`, ni `0`–`9`, ni `à`–`ÿ` ; + - deux runes au moins ; +- **le score** : + - un point par terme présent dans le contenu, deux dans le libellé ; + - à égalité, le plus récent d'abord ; + - 5 résultats par défaut, 20 au plus, limite tronquée comme + `int(float64)` ; +- **les textes**, mêmes que le Go : + - id ou requête manquants ; + - bloc introuvable (id cité par `%q`) ; + - bloc rappelé verbatim ; + - liste des résultats avec un extrait d'une ligne de 160 runes ; + - aucun résultat. + +L'archive reste au Go, qui la sert à la façade : + +- `GET /internal/v1/recall/block?seq=N` : le bloc (200), ou 404 s'il est + absent ou chiffré sous une mémoire verrouillée ; +- `GET /internal/v1/recall/blocks` : tous les blocs lisibles, par séquence + croissante. + +`recallGet` et `recallSearch` du Go passent par les mêmes lectures +(`recallGetSeq`, `recallAll`). + +`GoTurnHost::exec_tool` exécute les deux outils avant le repli sur le Go. +Si le Go ne répond pas, l'appel passe par lui. + +## Parité + +`internal/loki/parity_recall_test.go` archive 35 blocs variés : + +- accents et casse Unicode ; +- balises et entités ; +- bloc vide ; +- premières lignes longues ; +- vingt-cinq blocs semblables. + +Il tire ensuite du Go un journal de 41 appels +(`tests/parity_recall/recall.json`), avec les blocs tels que l'archive les +sert : + +- `recall` : + - id absent, non texte, blanc ; + - variantes de préfixe et d'espaces ; + - zéros de tête ; + - mal formé, négatif, signe plus, zéro, absent, démesuré ; + - guillemets dans l'id ; +- `recall_search` : + - requête absente, non texte, blanche ; + - mots trop courts ; + - casse, accents, `İ`, grec hors des mots ; + - souligné coupé ; + - extraits bornés ; + - limites : une, tronquée, négative, par défaut, au plus, en texte + ignorée ; + - aucun résultat ; + - ponctuation. + +`tests/recall.rs` le rejoue : le faux Go sert les mêmes blocs. Il vérifie +aussi le repli quand le Go ne répond pas. + +D'autres tests complètent : + +- `tests/lot6.rs` (`recall_par_la_facade`) : un tour de la façade rappelle + et cherche sans passer par `/tool/exec` ; +- `TestRecallInterneLecture` : les deux lectures du Go (absent, seq + illisible, méthode refusée, ordre) ; +- tests unitaires : ids, mots-clés, score, extraits, textes. diff --git a/internal/loki/chat_recall.go b/internal/loki/chat_recall.go index e439769..959093d 100644 --- a/internal/loki/chat_recall.go +++ b/internal/loki/chat_recall.go @@ -138,6 +138,11 @@ func recallGet(id string) (recallBlock, bool) { if !ok { return recallBlock{}, false } + return recallGetSeq(seq) +} + +// recallGetSeq ramène un bloc par sa séquence (aussi servi à la façade). +func recallGetSeq(seq uint64) (recallBlock, bool) { var blk recallBlock found := false _ = view(bkRecall, func(b *bolt.Bucket) error { @@ -155,6 +160,27 @@ func recallGet(id string) (recallBlock, bool) { return blk, found } +// recallAll : tous les blocs lisibles, par séquence croissante (un bloc +// chiffré sous une mémoire verrouillée n'en fait pas partie). +func recallAll() []recallBlock { + var out []recallBlock + _ = view(bkRecall, func(b *bolt.Bucket) error { + return b.ForEach(func(_, v []byte) error { + var blk recallBlock + dec, err := decodeMemContent(v) + if err != nil { + return nil + } + if json.Unmarshal(dec, &blk) != nil { + return nil + } + out = append(out, blk) + return nil + }) + }) + return out +} + // recallSearch cherche dans toute l'archive par recouvrement de mots-clés. // Score = nombre de termes distincts de la requête présents dans (libellé + // contenu), le libellé comptant double (un mot du titre est plus signifiant). @@ -176,33 +202,22 @@ func recallSearch(query string, limit int) []recallBlock { score int } var hits []scored - _ = view(bkRecall, func(b *bolt.Bucket) error { - return b.ForEach(func(_, v []byte) error { - var blk recallBlock - dec, err := decodeMemContent(v) - if err != nil { - return nil // chiffré et verrouillé : ce bloc ne participe pas + for _, blk := range recallAll() { + label := strings.ToLower(blk.Label) + body := strings.ToLower(blk.Content) + score := 0 + for t := range terms { + if strings.Contains(label, t) { + score += 2 } - if json.Unmarshal(dec, &blk) != nil { - return nil + if strings.Contains(body, t) { + score++ } - label := strings.ToLower(blk.Label) - body := strings.ToLower(blk.Content) - score := 0 - for t := range terms { - if strings.Contains(label, t) { - score += 2 - } - if strings.Contains(body, t) { - score++ - } - } - if score > 0 { - hits = append(hits, scored{blk, score}) - } - return nil - }) - }) + } + if score > 0 { + hits = append(hits, scored{blk, score}) + } + } sort.Slice(hits, func(i, j int) bool { if hits[i].score != hits[j].score { return hits[i].score > hits[j].score diff --git a/internal/loki/parity_recall_test.go b/internal/loki/parity_recall_test.go new file mode 100644 index 0000000..c662c0d --- /dev/null +++ b/internal/loki/parity_recall_test.go @@ -0,0 +1,143 @@ +package loki + +import ( + "bytes" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +// parity_recall_test.go — journal de référence des outils recall et +// recall_search (lot 46 de la migration Rust) : une archive variée (accents, +// casse Unicode, contenus longs, beaucoup de blocs), puis les appels de +// l'agent, des ids tolérés aux limites de recherche. Rangé dans +// rust/loki-rs/tests/parity_recall/recall.json avec les blocs tels que +// l'archive les sert ; tests/recall.rs le rejoue contre le port. +// +// LOKI_PARITY_UPDATE=1 go test ./internal/loki -run TestPariteRecall + +const parityRecallFile = "../../rust/loki-rs/tests/parity_recall/recall.json" + +type parityRecallStep struct { + Name string `json:"name"` + Tool string `json:"tool"` + Args map[string]any `json:"args"` + Result string `json:"result"` +} + +func TestPariteRecall(t *testing.T) { + testHome(t) + arch := func(role, tool, content string) { + t.Helper() + if _, err := archiveRecallBlock(recallLabel(role, tool, content), role, content); err != nil { + t.Fatal(err) + } + } + arch("tool", "web_read", "Recette du gratin dauphinois\n"+strings.Repeat("pommes de terre, crème, ail ", 30)) + arch("tool", "web_read", "Horaires SNCF\nle train de 14h12 part quai 3, le TRAIN suivant à 15h") + arch("tool", "bash", "\n\n $ ls -la\na.go b.go\tc.go") + arch("assistant", "", "Voici le résumé de l'ÉTÉ : plage, soleil, Été indien.") + arch("user", "", "İSTANBUL et ΣΟΦΙΑ en majuscules") + arch("tool", "", "outil sans nom : train de marchandises") + arch("tool", "read_file", strings.Repeat("é", 200)+" fin") + arch("tool", "web_read", "balises & entités & \"guillemets\"") + arch("tool", "bash", "") + arch("tool", "mem_search", strings.Repeat("très longue première ligne sans fin ", 5)+"\nsuite") + for i := 0; i < 25; i++ { + arch("tool", "bash", fmt.Sprintf("lot %d de la migration : sortie numéro %d", i, i)) + } + + var steps []parityRecallStep + run := func(name, tool string, args map[string]any) { + var r string + if tool == "recall" { + r = toolRecall(args) + } else { + r = toolRecallSearch(args) + } + steps = append(steps, parityRecallStep{Name: name, Tool: tool, Args: args, Result: r}) + } + id := func(v any) map[string]any { return map[string]any{"id": v} } + q := func(v any, kv ...any) map[string]any { + m := map[string]any{"query": v} + for i := 0; i+1 < len(kv); i += 2 { + m[kv[i].(string)] = kv[i+1] + } + return m + } + run("sans id", "recall", map[string]any{}) + run("id non texte", "recall", id(7.0)) + run("id blanc", "recall", id(" ")) + run("r1", "recall", id("r1")) + run("R2 majuscule", "recall", id("R2")) + run("espaces", "recall", id(" 3 ")) + run("sans préfixe", "recall", id("4")) + run("espace après le préfixe", "recall", id("r 5")) + run("deux préfixes", "recall", id("rR6")) + run("préfixes à l'envers", "recall", id("Rr6")) + run("zéros de tête", "recall", id("r007")) + run("contenu long", "recall", id("r7")) + run("caractères spéciaux", "recall", id("r8")) + run("bloc vide", "recall", id("r9")) + run("mal formé", "recall", id("rr")) + run("négatif", "recall", id("r-1")) + run("signe plus", "recall", id("+1")) + run("zéro", "recall", id("r0")) + run("absent", "recall", id("r999")) + run("démesuré", "recall", id("r99999999999999999999")) + run("guillemets dans l'id", "recall", id(`r"9\`)) + + run("sans requête", "recall_search", map[string]any{}) + run("requête non texte", "recall_search", q(3.0)) + run("requête blanche", "recall_search", q(" ")) + run("mots trop courts", "recall_search", q("a b ! ?")) + run("train horaires", "recall_search", q("train horaires")) + run("casse", "recall_search", q("TRAIN")) + run("accents majuscules", "recall_search", q("ÉTÉ")) + run("i turc", "recall_search", q("İstanbul")) + run("sigma", "recall_search", q("σοφια")) + run("souligné coupé", "recall_search", q("web_read")) + run("extrait borné", "recall_search", q("fin éé")) + run("première ligne longue", "recall_search", q("suite")) + run("limite 1", "recall_search", q("train", "limit", 1.0)) + run("limite tronquée", "recall_search", q("train", "limit", 2.7)) + run("limite négative", "recall_search", q("lot", "limit", -3.0)) + run("défaut", "recall_search", q("migration")) + run("vingt au plus", "recall_search", q("lot migration", "limit", 50.0)) + run("limite texte ignorée", "recall_search", q("lot", "limit", "3")) + run("rien ne correspond", "recall_search", q("motabsolumentabsent")) + run("ponctuation", "recall_search", q("«entités»,&")) + + blocks := recallAll() + for i := range blocks { + blocks[i].Created = 0 + } + var buf bytes.Buffer + enc := json.NewEncoder(&buf) + enc.SetEscapeHTML(false) + enc.SetIndent("", " ") + if err := enc.Encode(map[string]any{"blocks": blocks, "steps": steps}); err != nil { + t.Fatal(err) + } + got := buf.Bytes() + if os.Getenv("LOKI_PARITY_UPDATE") == "1" { + if err := os.MkdirAll(filepath.Dir(parityRecallFile), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(parityRecallFile, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(parityRecallFile) + if err != nil { + t.Fatalf("journal de référence absent (%v) : LOKI_PARITY_UPDATE=1 pour le créer", err) + } + if !bytes.Equal(got, want) { + t.Errorf("recall ne produit plus le journal rangé dans %s.\n"+ + "LOKI_PARITY_UPDATE=1 pour le régénérer si le changement est voulu.", parityRecallFile) + } +} diff --git a/internal/loki/web_internal.go b/internal/loki/web_internal.go index 3a28ccb..7df4cbd 100644 --- a/internal/loki/web_internal.go +++ b/internal/loki/web_internal.go @@ -107,6 +107,10 @@ func withInternalAPI(next http.Handler) http.Handler { handleInternalEngineStop(w, r) case "/internal/v1/recall/archive": handleInternalRecallArchive(w, r) + case "/internal/v1/recall/block": + handleInternalRecallBlock(w, r) + case "/internal/v1/recall/blocks": + handleInternalRecallBlocks(w, r) case "/internal/v1/turn/start": handleInternalTurnStart(w, r) case "/internal/v1/turn/view": diff --git a/internal/loki/web_internal_recall.go b/internal/loki/web_internal_recall.go index a811693..bd0a16f 100644 --- a/internal/loki/web_internal_recall.go +++ b/internal/loki/web_internal_recall.go @@ -5,6 +5,7 @@ import ( "errors" "io" "net/http" + "strconv" ) // API interne de l'archive recall (lot 6 de la migration Rust). @@ -16,6 +17,12 @@ import ( // // POST /internal/v1/recall/archive {"label":…,"role":…,"content":…} // → 200 {"id":"r7"} · 423 mémoire verrouillée (rien d'écrit en clair) +// +// Les outils recall et recall_search de la façade (lot 46) lisent l'archive +// ici ; le reste (id tolérant, score, mise en forme) est chez elle : +// +// GET /internal/v1/recall/block?seq=7 → 200 bloc · 404 absent ou illisible +// GET /internal/v1/recall/blocks → 200 [blocs lisibles, par séquence] const internalRecallMaxBody = 32 << 20 @@ -46,3 +53,35 @@ func handleInternalRecallArchive(w http.ResponseWriter, r *http.Request) { } sendJSON(w, http.StatusOK, map[string]any{"id": id}) } + +func handleInternalRecallBlock(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) + sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "GET attendu"}) + return + } + seq, err := strconv.ParseUint(r.URL.Query().Get("seq"), 10, 64) + if err != nil { + sendJSON(w, http.StatusBadRequest, map[string]any{"error": "seq illisible"}) + return + } + blk, ok := recallGetSeq(seq) + if !ok { + sendJSON(w, http.StatusNotFound, map[string]any{"error": "bloc introuvable"}) + return + } + sendJSON(w, http.StatusOK, blk) +} + +func handleInternalRecallBlocks(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) + sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"error": "GET attendu"}) + return + } + blks := recallAll() + if blks == nil { + blks = []recallBlock{} + } + sendJSON(w, http.StatusOK, blks) +} diff --git a/internal/loki/web_internal_recall_test.go b/internal/loki/web_internal_recall_test.go index 63ec8cd..ca5c20e 100644 --- a/internal/loki/web_internal_recall_test.go +++ b/internal/loki/web_internal_recall_test.go @@ -1,6 +1,7 @@ package loki import ( + "encoding/json" "strings" "testing" ) @@ -27,3 +28,44 @@ func TestRecallInterneArchive(t *testing.T) { } } } + +// Lot 46 : la façade lit l'archive (un bloc par sa séquence, ou tous les +// blocs lisibles par séquence croissante) ; le reste est chez elle. +func TestRecallInterneLecture(t *testing.T) { + testHome(t) + t.Setenv(internalTokenEnv, jetonTest) + if w := appelOAIInterne(t, "GET", "/internal/v1/recall/blocks", ""); w.Code != 200 || strings.TrimSpace(w.Body.String()) != "[]" { + t.Fatalf("archive vide : %d %s", w.Code, w.Body.String()) + } + for _, c := range []string{"premier ", "second bloc"} { + if _, err := archiveRecallBlock("bash: "+c, "tool", c); err != nil { + t.Fatal(err) + } + } + w := appelOAIInterne(t, "GET", "/internal/v1/recall/block?seq=1", "") + if w.Code != 200 { + t.Fatalf("bloc 1 : %d %s", w.Code, w.Body.String()) + } + var blk recallBlock + if err := json.Unmarshal(w.Body.Bytes(), &blk); err != nil || blk.ID != "r1" || blk.Content != "premier " || blk.Label != "bash: premier " { + t.Fatalf("bloc 1 : %v %+v", err, blk) + } + if w := appelOAIInterne(t, "GET", "/internal/v1/recall/block?seq=9", ""); w.Code != 404 { + t.Fatalf("absent : %d", w.Code) + } + for _, q := range []string{"", "?seq=", "?seq=r1", "?seq=-1"} { + if w := appelOAIInterne(t, "GET", "/internal/v1/recall/block"+q, ""); w.Code != 400 { + t.Fatalf("%q : %d", q, w.Code) + } + } + for _, p := range []string{"/internal/v1/recall/block?seq=1", "/internal/v1/recall/blocks"} { + if w := appelOAIInterne(t, "POST", p, ""); w.Code != 405 { + t.Fatalf("POST %s : %d", p, w.Code) + } + } + w = appelOAIInterne(t, "GET", "/internal/v1/recall/blocks", "") + var all []recallBlock + if err := json.Unmarshal(w.Body.Bytes(), &all); err != nil || len(all) != 2 || all[0].Seq != 1 || all[1].Content != "second bloc" { + t.Fatalf("tous : %v %s", err, w.Body.String()) + } +} diff --git a/rust/loki-rs/src/chat/host.rs b/rust/loki-rs/src/chat/host.rs index 5ff33ef..3d71b63 100644 --- a/rust/loki-rs/src/chat/host.rs +++ b/rust/loki-rs/src/chat/host.rs @@ -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. diff --git a/rust/loki-rs/src/lib.rs b/rust/loki-rs/src/lib.rs index a1ee2a7..4040d2c 100644 --- a/rust/loki-rs/src/lib.rs +++ b/rust/loki-rs/src/lib.rs @@ -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; diff --git a/rust/loki-rs/src/recall.rs b/rust/loki-rs/src/recall.rs new file mode 100644 index 0000000..bfdadd1 --- /dev/null +++ b/rust/loki-rs/src/recall.rs @@ -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 { + 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 { + 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, query: &str, limit: i64) -> Vec { + 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::>().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, blk: Option) -> 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) -> 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) -> Option { + 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 = serde_json::from_value(v).ok()?; + Some(search_text(&search(blocks, q, arg_limit(args)))) + } + _ => None, + } +} + +#[cfg(test)] +mod tests; diff --git a/rust/loki-rs/src/recall/tests.rs b/rust/loki-rs/src/recall/tests.rs new file mode 100644 index 0000000..ae902d4 --- /dev/null +++ b/rust/loki-rs/src/recall/tests.rs @@ -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 = 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 = 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 = 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" + ); +} diff --git a/rust/loki-rs/src/store.rs b/rust/loki-rs/src/store.rs index 8d74dc3..2727932 100644 --- a/rust/loki-rs/src/store.rs +++ b/rust/loki-rs/src/store.rs @@ -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( diff --git a/rust/loki-rs/tests/common/fakego.rs b/rust/loki-rs/tests/common/fakego.rs index a97ab71..c76e841 100644 --- a/rust/loki-rs/tests/common/fakego.rs +++ b/rust/loki-rs/tests/common/fakego.rs @@ -52,6 +52,10 @@ pub struct FakeGo { /// Résultat imposé à tout outil (sinon « exec: »), et archive recall. pub tool_result: Mutex>, pub recall: Mutex>, + /// Blocs servis par /internal/v1/recall/block(s) (lot 46), et lectures + /// reçues (« block », « blocks »). + pub recall_blocks: Mutex>, + pub recall_log: Mutex>, /// Discussion active annoncée et discussions supprimées. pub conv_log: Mutex>, /// Tours : débuts, vues et fins demandés. @@ -502,6 +506,34 @@ async fn handle(st: Arc, req: Request) -> Response ), 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(); diff --git a/rust/loki-rs/tests/lot6.rs b/rust/loki-rs/tests/lot6.rs index 9be8e31..e7542dd 100644 --- a/rust/loki-rs/tests/lot6.rs +++ b/rust/loki-rs/tests/lot6.rs @@ -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 = 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. diff --git a/rust/loki-rs/tests/parity_recall/recall.json b/rust/loki-rs/tests/parity_recall/recall.json new file mode 100644 index 0000000..396ee7b --- /dev/null +++ b/rust/loki-rs/tests/parity_recall/recall.json @@ -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: balises & entités & \"guillemets\"", + "role": "tool", + "content": "balises & entités & \"guillemets\"", + "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: balises & entités & \"guillemets\", rappelé verbatim]\nbalises & entités & \"guillemets\"" + }, + { + "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: balises & entités & \"guillemets\"\n balises & entités & \"guillemets\"\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»,&" + }, + "result": "Blocs trouvés (rappelle le bon avec recall(id)) :\n- r8 — web_read: balises & entités & \"guillemets\"\n balises & entités & \"guillemets\"" + } + ] +} diff --git a/rust/loki-rs/tests/recall.rs b/rust/loki-rs/tests/recall.rs new file mode 100644 index 0000000..cfc1a6a --- /dev/null +++ b/rust/loki-rs/tests/recall.rs @@ -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 = 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") + ); +}