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

+40 -25
View File
@@ -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
+143
View File
@@ -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", "<html><b>balises</b> & entités &amp; \"guillemets\"</html>")
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»,&amp;"))
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)
}
}
+4
View File
@@ -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":
+39
View File
@@ -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)
}
+42
View File
@@ -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 <bloc>", "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 <bloc>" || blk.Label != "bash: premier <bloc>" {
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())
}
}