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>
99 lines
3.5 KiB
Go
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")
|
|
}
|
|
}
|
|
}
|