mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-15 19:29:16 +02:00
Deux boutons d'export cote a cote ne laissaient aucun choix : tout ou rien. Un bouton unique ouvre desormais une fenetre ou l'on choisit le format (Markdown a lire / JSON a retraiter), ce qu'on emporte (raisonnements, appels d'outils, sorties d'outils, ou journal d'affichage cote JSON) et la portee (tout le fil ou les N derniers echanges). Une ligne dit ce que l'export contiendra, et l'en-tete du fichier signale un export partiel : relu plus tard, il ne doit pas passer pour le fil complet. Memes options en ligne de commande : --last N, --no-reasoning, --no-tools, --no-results, --messages-only. Sans option, l'export reste complet, endpoint compris. « clear chat » descend en derniere position et seul sur sa ligne : c'est la seule action destructive du panneau, elle n'a rien a faire sous le doigt qui visait « refresh ». Au passage, issue #20 : le prompt systeme et la description de l'outil mem_search annoncaient encore la memoire sous MEMORY/, nom d'avant la 0.8. Le modele lisait donc un chemin qui n'existe plus. Corrige, avec les deux commentaires restes au meme nom.
237 lines
7.6 KiB
Go
237 lines
7.6 KiB
Go
package ajean
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// convDeTest pose un fil complet (question, raisonnement, outil, réponse) dans la
|
|
// conversation globale et la remet à zéro en fin de test.
|
|
func convDeTest(t *testing.T) {
|
|
t.Helper()
|
|
t.Cleanup(func() { conv.Reset() })
|
|
conv.mu.Lock()
|
|
conv.Messages = []Message{
|
|
{Role: "user", Content: "combien font 2+2 ?"},
|
|
{Role: "assistant", Content: "4"},
|
|
}
|
|
conv.Log = []LogEvent{
|
|
{Seq: 1, TS: 1, Delta: map[string]any{"user": "combien font 2+2 ?"}},
|
|
{Seq: 2, TS: 2, Delta: map[string]any{"reasoning_content": "addition simple"}},
|
|
{Seq: 3, TS: 3, Delta: map[string]any{"tool_used": map[string]any{
|
|
"name": "bash", "label": "echo 4", "result": "4", "done": true}}},
|
|
{Seq: 4, TS: 4, Delta: map[string]any{"content": "4"}},
|
|
{Seq: 5, TS: 5, Delta: map[string]any{"turn_done": true}},
|
|
}
|
|
conv.Seq = 5
|
|
conv.CtxUsed = 123
|
|
conv.mu.Unlock()
|
|
}
|
|
|
|
func TestExportMarkdownPorteToutLeFil(t *testing.T) {
|
|
convDeTest(t)
|
|
md := conv.ExportMarkdown(defaultExportOpts())
|
|
for _, want := range []string{
|
|
"## Vous", "combien font 2+2 ?",
|
|
"## AJEAN",
|
|
"<summary>Raisonnement</summary>", "addition simple",
|
|
"bash", "echo 4",
|
|
} {
|
|
if !strings.Contains(md, want) {
|
|
t.Errorf("Markdown exporté sans %q :\n%s", want, md)
|
|
}
|
|
}
|
|
// L'en-tête AJEAN ne doit apparaître qu'UNE fois pour un tour, même entrecoupé
|
|
// d'un appel d'outil.
|
|
if n := strings.Count(md, "\n## AJEAN\n"); n != 1 {
|
|
t.Errorf("en-tête assistant répété %d fois", n)
|
|
}
|
|
}
|
|
|
|
func TestExportJSONRelisible(t *testing.T) {
|
|
convDeTest(t)
|
|
b, err := conv.ExportJSON(defaultExportOpts())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var p exportPayload
|
|
if err := json.Unmarshal(b, &p); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(p.Messages) != 2 || len(p.Log) != 5 {
|
|
t.Fatalf("export tronqué : %d messages, %d événements", len(p.Messages), len(p.Log))
|
|
}
|
|
if p.CtxUsed != 123 || p.Version != Version {
|
|
t.Fatalf("métadonnées absentes : ctx=%d version=%q", p.CtxUsed, p.Version)
|
|
}
|
|
}
|
|
|
|
// L'endpoint doit se présenter en TÉLÉCHARGEMENT (et pas s'afficher dans
|
|
// l'onglet), avec un nom de fichier horodaté.
|
|
func TestHandleChatExportEnPieceJointe(t *testing.T) {
|
|
convDeTest(t)
|
|
for _, tc := range []struct{ format, ctype, ext string }{
|
|
{"", "text/markdown", ".md"},
|
|
{"json", "application/json", ".json"},
|
|
} {
|
|
rr := httptest.NewRecorder()
|
|
handleChatExport(rr, httptest.NewRequest("GET", "/api/chat/export?format="+tc.format, nil))
|
|
if rr.Code != 200 {
|
|
t.Fatalf("format=%q : HTTP %d", tc.format, rr.Code)
|
|
}
|
|
if ct := rr.Header().Get("Content-Type"); !strings.HasPrefix(ct, tc.ctype) {
|
|
t.Errorf("format=%q : Content-Type %q", tc.format, ct)
|
|
}
|
|
cd := rr.Header().Get("Content-Disposition")
|
|
if !strings.HasPrefix(cd, "attachment;") || !strings.Contains(cd, tc.ext) {
|
|
t.Errorf("format=%q : Content-Disposition %q", tc.format, cd)
|
|
}
|
|
if rr.Body.Len() == 0 {
|
|
t.Errorf("format=%q : corps vide", tc.format)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Les options taillent l'export, et un export tronqué le DIT (sans quoi, relu
|
|
// plus tard, il passerait pour le fil complet).
|
|
func TestExportOptionsTaillentLeMarkdown(t *testing.T) {
|
|
convDeTest(t)
|
|
o := defaultExportOpts()
|
|
o.Reasoning = false
|
|
md := conv.ExportMarkdown(o)
|
|
if strings.Contains(md, "addition simple") {
|
|
t.Error("raisonnement présent malgré --no-reasoning")
|
|
}
|
|
if !strings.Contains(md, "Export partiel") || !strings.Contains(md, "raisonnements retirés") {
|
|
t.Errorf("export allégé non signalé :\n%s", md)
|
|
}
|
|
if !strings.Contains(md, "echo 4") {
|
|
t.Error("les outils ont disparu alors que seule la réflexion était exclue")
|
|
}
|
|
|
|
o = defaultExportOpts()
|
|
o.Results = false
|
|
if md := conv.ExportMarkdown(o); strings.Contains(md, "```") {
|
|
t.Error("sortie d'outil présente malgré --no-results")
|
|
}
|
|
|
|
// Couper les outils coupe forcément leurs sorties : la sortie n'a plus de
|
|
// bulle où s'accrocher.
|
|
o = exportOptsFromQuery(map[string][]string{"tools": {"0"}, "results": {"1"}})
|
|
if o.Results {
|
|
t.Error("results resté actif alors que tools est coupé")
|
|
}
|
|
md = conv.ExportMarkdown(o)
|
|
if strings.Contains(md, "echo 4") {
|
|
t.Error("outil présent malgré tools=0")
|
|
}
|
|
}
|
|
|
|
// Sans paramètre, l'endpoint reste l'export COMPLET : les options ne doivent pas
|
|
// silencieusement appauvrir ceux qui ne les connaissent pas.
|
|
func TestExportOptsFromQueryDefautComplet(t *testing.T) {
|
|
o := exportOptsFromQuery(map[string][]string{})
|
|
if o != defaultExportOpts() {
|
|
t.Fatalf("défauts modifiés : %+v", o)
|
|
}
|
|
o = exportOptsFromQuery(map[string][]string{"format": {"json"}, "turns": {"2"}, "full": {"0"}})
|
|
if o.Format != "json" || o.Turns != 2 || o.JSONFull {
|
|
t.Fatalf("options mal lues : %+v", o)
|
|
}
|
|
// Une valeur de tours absurde ne doit pas vider l'export.
|
|
if o := exportOptsFromQuery(map[string][]string{"turns": {"-3"}}); o.Turns != 0 {
|
|
t.Fatalf("turns=-3 accepté : %d", o.Turns)
|
|
}
|
|
}
|
|
|
|
func TestExportDerniersEchanges(t *testing.T) {
|
|
convDeTest(t)
|
|
// Un deuxième échange s'ajoute au fil de test.
|
|
conv.mu.Lock()
|
|
conv.Messages = append(conv.Messages,
|
|
Message{Role: "user", Content: "et 3+3 ?"}, Message{Role: "assistant", Content: "6"})
|
|
conv.Log = append(conv.Log,
|
|
LogEvent{Seq: 6, TS: 6, Delta: map[string]any{"user": "et 3+3 ?"}},
|
|
LogEvent{Seq: 7, TS: 7, Delta: map[string]any{"content": "6"}})
|
|
conv.mu.Unlock()
|
|
|
|
o := defaultExportOpts()
|
|
o.Turns = 1
|
|
md := conv.ExportMarkdown(o)
|
|
if strings.Contains(md, "2+2") {
|
|
t.Errorf("le premier échange est encore là avec turns=1 :\n%s", md)
|
|
}
|
|
if !strings.Contains(md, "3+3") || !strings.Contains(md, "1 derniers échanges") {
|
|
t.Errorf("dernier échange absent ou non signalé :\n%s", md)
|
|
}
|
|
// Demander plus d'échanges qu'il n'en existe rend tout le fil, sans erreur.
|
|
o.Turns = 99
|
|
if md := conv.ExportMarkdown(o); !strings.Contains(md, "2+2") {
|
|
t.Error("turns supérieur au nombre d'échanges a tronqué le fil")
|
|
}
|
|
// Même découpe côté JSON.
|
|
o = defaultExportOpts()
|
|
o.Turns = 1
|
|
b, err := conv.ExportJSON(o)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
var p exportPayload
|
|
if err := json.Unmarshal(b, &p); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(p.Messages) != 2 || p.Messages[0].Content != "et 3+3 ?" {
|
|
t.Fatalf("messages mal découpés : %+v", p.Messages)
|
|
}
|
|
// --messages-only laisse tomber le journal. ⚠️ Structure NEUVE : Unmarshal ne
|
|
// remet pas à zéro les champs absents du JSON, un `p` réutilisé garderait le
|
|
// journal de l'appel précédent et le test passerait pour de mauvaises raisons.
|
|
o.JSONFull = false
|
|
b, _ = conv.ExportJSON(o)
|
|
var sansLog exportPayload
|
|
if err := json.Unmarshal(b, &sansLog); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(sansLog.Log) != 0 {
|
|
t.Fatalf("journal présent malgré --messages-only : %d événements", len(sansLog.Log))
|
|
}
|
|
if len(sansLog.Messages) == 0 {
|
|
t.Fatal("messages perdus avec --messages-only")
|
|
}
|
|
}
|
|
|
|
func TestCmdExportEcritLeFichier(t *testing.T) {
|
|
testHome(t)
|
|
convDeTest(t)
|
|
// LoadConversation (appelé par cmdExport) relit la base : on y persiste d'abord
|
|
// le fil, sinon la commande exporterait une conversation vide.
|
|
conv.persist()
|
|
|
|
dir := t.TempDir()
|
|
for _, name := range []string{"fil.md", "fil.json"} {
|
|
p := filepath.Join(dir, name)
|
|
if err := cmdExport([]string{p}); err != nil {
|
|
t.Fatalf("%s : %v", name, err)
|
|
}
|
|
b, err := os.ReadFile(p)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if !strings.Contains(string(b), "2+2") {
|
|
t.Fatalf("%s ne contient pas la conversation :\n%s", name, b)
|
|
}
|
|
}
|
|
// L'extension impose le format, même sans drapeau.
|
|
b, _ := os.ReadFile(filepath.Join(dir, "fil.json"))
|
|
if !json.Valid(b) {
|
|
t.Fatal("fil.json n'est pas du JSON valide")
|
|
}
|
|
if err := cmdExport([]string{"--zzz"}); err == nil {
|
|
t.Fatal("option inconnue acceptée")
|
|
}
|
|
}
|