rony-llm-agent/pkg/rag/e2e_local_test.go
Victor Vargas 1c0c86de10 feat(rag): memory taxonomy (episodic/semantic/procedural) + episodic auto-capture
Fragments now carry a memory type in metadata (legacy fragments count as
procedural) with SearchByType filtering, and EpisodeCapture summarizes a
finished turn with the local LLM and stores it as episodic memory, so the
agent can answer "what did we do yesterday?". Includes an E2E test against
a live llama.cpp server (gated) and taxonomy unit tests.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-15 14:50:31 -07:00

99 lines
3.5 KiB
Go

package rag_test
import (
"context"
"net/http"
"path/filepath"
"testing"
"time"
llm_llamacpp "github.com/VictorVargas/rony-llm-agent/pkg/llm/providers/llamacpp"
"github.com/VictorVargas/rony-llm-agent/pkg/rag"
"github.com/VictorVargas/rony-llm-agent/pkg/rag/backends/sqlitevec"
"github.com/VictorVargas/rony-llm-agent/pkg/rag/embeddings"
)
// TestEpisodeCapture_EndToEndLocalServer exercises the full auto-capture
// path — real LLM summarization, sqlitevec storage, taxonomy-filtered
// recall — against the llama.cpp server Rony actually uses. Skipped when no
// server is listening on localhost:8080, so it never breaks CI or offline
// runs; with the server up it's the proof the feature works for real, not
// just against stubs.
func TestEpisodeCapture_EndToEndLocalServer(t *testing.T) {
probe, err := (&http.Client{Timeout: 2 * time.Second}).Get("http://localhost:8080/v1/models")
if err != nil {
t.Skipf("no local llama.cpp server on :8080: %v", err)
}
probe.Body.Close()
client, err := llm_llamacpp.New(llm_llamacpp.Config{})
if err != nil {
t.Fatalf("llamacpp client: %v", err)
}
backend, err := sqlitevec.New(filepath.Join(t.TempDir(), "e2e_memory.db"))
if err != nil {
t.Fatalf("sqlitevec: %v", err)
}
defer backend.Close()
// Same embedder wiring the harness uses: llama.cpp embeddings when the
// server exposes them, transparent FTS5 fallback otherwise.
embedder, err := embeddings.NewLlamaCpp(embeddings.LlamaCppConfig{})
if err != nil {
t.Fatalf("embedder: %v", err)
}
mem, err := rag.New(rag.Config{Backend: backend, Embedder: embedder})
if err != nil {
t.Fatalf("memory: %v", err)
}
capture := &rag.EpisodeCapture{
Memory: mem,
LLM: client,
ProjectID: "e2e-test",
Prompt: "Resume el siguiente intercambio entre un usuario y un asistente de IA en 1-2 frases, en pasado, " +
"enfocándote en qué se pidió y qué se hizo. Responde ÚNICAMENTE con el resumen. /no_think",
}
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
defer cancel()
userInput := "¿Puedes optimizar el cliente OpenAI del proyecto? El streaming no funciona."
reply := "Encontré que Stream() enviaba stream:false y descartaba el modelo configurado. " +
"Reescribí el cliente: ahora hace streaming real, acumula tool calls y reporta el usage. Los tests pasan."
if err := capture.Capture(ctx, userInput, reply, "read", "edit", "bash"); err != nil {
t.Fatalf("capture failed against the real server: %v", err)
}
// Recall it back, restricted to episodic memory.
episodes, err := mem.SearchByType(ctx, "optimización del cliente OpenAI streaming", 5, rag.MemoryEpisodic)
if err != nil {
t.Fatalf("search failed: %v", err)
}
if len(episodes) == 0 {
t.Fatal("expected the captured episode to be recallable via SearchByType(episodic)")
}
ep := episodes[0]
if ep.Type != rag.MemoryEpisodic {
t.Fatalf("expected episodic type, got %q", ep.Type)
}
if ep.Metadata["tools"] != "read,edit,bash" {
t.Fatalf("expected tools metadata, got %q", ep.Metadata["tools"])
}
if ep.Metadata["date"] == "" {
t.Fatal("expected a date on the episode")
}
t.Logf("captured episode: %s", ep.Content)
// A procedural-only search must NOT return the episode.
procs, err := mem.SearchByType(ctx, "optimización del cliente OpenAI streaming", 5, rag.MemoryProcedural)
if err != nil {
t.Fatalf("procedural search failed: %v", err)
}
for _, p := range procs {
if p.ID == ep.ID {
t.Fatal("episode leaked into a procedural-only search")
}
}
}