mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-17 04:10:04 +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
@@ -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
|
||||
|
||||
@@ -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 & \"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»,&"))
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user