mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 14:38:12 +02:00
Index inversé dans le bucket chatsearch (chat_search.go) : une fiche légère par discussion (« d| » : version, date d'indexation, aperçu), sa liste de mots (« t| ») et, par mot, la liste des discussions (« p| »). Mots repliés sans accents, correspondance par début de mot, titre compté double, comme chez l'amont. Chiffré quand la mémoire l'est (encryptedBuckets) ; verrouillée, la recherche le dit (state=locked). Adapté au modèle de Loki : toutes les discussions vivent dans bkChat/conv:<id>. L'index suit chaque écriture de l'écrivain différé (chat_persist.go, avant que le lot soit marqué traité : une suppression passe toujours après) et ne touche que les mots ajoutés ou retirés. Il suit aussi les écritures de la façade Rust, qui passent par /internal/v1/store : le Go reste le seul à tenir l'index, la route /api/conversations/search est servie ici et relayée par la façade comme toute route qu'elle ne connaît pas (test de garde des deux côtés). Pas de second écrivain, donc pas de test de parité à tenir. Rattrapage en fond au démarrage, au déverrouillage et quand une recherche trouve l'index incomplet : discussions absentes ou plus anciennes que leur dernière écriture. La discussion ouverte est cherchée dans son journal en RAM quand ce process la tient. L'extrait est pris autour du mot trouvé, relu dans le corps des seuls résultats de la page. Tests repris d'AJEAN (chat_search_test.go), adaptés, plus les écritures de la façade, l'extrait en contexte, la mise à jour incrémentale et l'index périmé. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
729 lines
28 KiB
Go
729 lines
28 KiB
Go
package loki
|
|
|
|
// chat_search_test.go — recherche plein-texte dans les discussions. Repris
|
|
// d'AJEAN (chat_search_test.go, commit 558d3b4d, issue amont #98) et adaptés au
|
|
// modèle de Loki : pas d'archives à part, toutes les discussions vivent dans
|
|
// bkChat/conv:<id> et l'index suit chaque écriture de l'écrivain différé
|
|
// (chat_persist.go) — ou de la façade, par /internal/v1/store. S'y ajoutent les
|
|
// cas propres à Loki : écritures de la façade, extrait en contexte, mise à jour
|
|
// incrémentale, index périmé.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// --- aides --------------------------------------------------------------------
|
|
|
|
// histSetup isole $LOKI_HOME et neutralise la discussion ouverte du process :
|
|
// chaque test pose explicitement celle qu'il veut (histSetLive).
|
|
func histSetup(t *testing.T) {
|
|
t.Helper()
|
|
testHome(t)
|
|
clearMemDEK()
|
|
conv.mu.Lock()
|
|
prevLog := conv.Log
|
|
conv.Log = nil
|
|
conv.mu.Unlock()
|
|
convActiveCache.Store(nil)
|
|
t.Cleanup(func() {
|
|
conv.mu.Lock()
|
|
conv.Log = prevLog
|
|
conv.mu.Unlock()
|
|
convActiveCache.Store(nil)
|
|
clearMemDEK()
|
|
})
|
|
}
|
|
|
|
func histUser(ts int64, s string) LogEvent {
|
|
return LogEvent{TS: ts, Delta: map[string]any{"user": s}}
|
|
}
|
|
|
|
func histContent(ts int64, s string) LogEvent {
|
|
return LogEvent{TS: ts, Delta: map[string]any{"content": s}}
|
|
}
|
|
|
|
func histBody(t *testing.T, log []LogEvent) []byte {
|
|
t.Helper()
|
|
b, err := json.Marshal(map[string]any{"messages": []Message{}, "log": log})
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return b
|
|
}
|
|
|
|
// histConv écrit une discussion PAR LE CHEMIN DE PRODUCTION (l'écrivain
|
|
// différé, qui pose contenu, entrée d'index et index plein-texte), puis fixe
|
|
// son titre et sa date dans l'index des discussions.
|
|
func histConv(t *testing.T, id, project, title string, updated int64, log []LogEvent) {
|
|
t.Helper()
|
|
if project == "" {
|
|
project = defaultProjectSlug
|
|
}
|
|
turns := 0
|
|
for _, ev := range log {
|
|
if _, ok := ev.Delta["user"]; ok {
|
|
turns++
|
|
}
|
|
}
|
|
persistQ.wait(persistQ.enqueueSnap(&convSnap{path: dbPath(), id: id, seq: persistSeq.Add(1),
|
|
body: histBody(t, log), title: title, turns: turns, project: project}))
|
|
histSetMeta(t, convMeta{ID: id, Title: title, Created: updated, Updated: updated, Turns: turns, Project: project})
|
|
}
|
|
|
|
// histSetMeta pose (ou remplace) l'entrée d'une discussion dans l'index des
|
|
// discussions, sans toucher à son contenu.
|
|
func histSetMeta(t *testing.T, m convMeta) {
|
|
t.Helper()
|
|
convIndexMu.Lock()
|
|
defer convIndexMu.Unlock()
|
|
idx := convIndex()
|
|
for i := range idx {
|
|
if idx[i].ID == m.ID {
|
|
idx[i] = m
|
|
convIndexSave(idx)
|
|
return
|
|
}
|
|
}
|
|
convIndexSave(append(idx, m))
|
|
}
|
|
|
|
// histSetLive fait de id la discussion ouverte, avec ce journal en RAM.
|
|
func histSetLive(t *testing.T, id string, log []LogEvent) {
|
|
t.Helper()
|
|
convActiveRemember(id)
|
|
conv.mu.Lock()
|
|
conv.Log = append([]LogEvent(nil), log...)
|
|
conv.mu.Unlock()
|
|
}
|
|
|
|
func histHasHit(res histSearchResult, id string) bool {
|
|
for _, h := range res.Hits {
|
|
if h.ID == id {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func histHitIDs(res histSearchResult) string {
|
|
var ids []string
|
|
for _, h := range res.Hits {
|
|
ids = append(ids, h.ID)
|
|
}
|
|
return strings.Join(ids, ",")
|
|
}
|
|
|
|
func histDocExists(t *testing.T, id string) bool {
|
|
t.Helper()
|
|
_, ok := getStoreBytes(bkChatSearch, histDocKey(id))
|
|
return ok
|
|
}
|
|
|
|
func histPosting(t *testing.T, tok string) []string {
|
|
t.Helper()
|
|
b, ok := getStoreBytes(bkChatSearch, histPostKey(tok))
|
|
if !ok {
|
|
return nil
|
|
}
|
|
var ids []string
|
|
if err := json.Unmarshal(b, &ids); err != nil {
|
|
t.Fatalf("posting %q illisible : %v", tok, err)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
// --- 1. texte trouvé même si le titre ne contient rien ------------------------
|
|
|
|
func TestHistSearchBodyMatch(t *testing.T) {
|
|
histSetup(t)
|
|
histConv(t, "A", "", "Voyage en Bretagne", 100, []LogEvent{histUser(1, "parlons du phare de Brest")})
|
|
histConv(t, "B", "", "Recette", 200, []LogEvent{histUser(1, "préparons une tarte")})
|
|
|
|
res := histSearch("phare", "", 0, 0)
|
|
if res.State != "ok" {
|
|
t.Fatalf("état attendu ok, obtenu %q", res.State)
|
|
}
|
|
if res.Total != 1 || len(res.Hits) != 1 || res.Hits[0].ID != "A" {
|
|
t.Fatalf("A attendu seul, obtenu total=%d %+v", res.Total, res.Hits)
|
|
}
|
|
if res.Hits[0].Snippet == "" {
|
|
t.Fatal("extrait attendu non vide pour A")
|
|
}
|
|
if res.Hits[0].InTitle {
|
|
t.Fatal("A est trouvée par son texte, pas par son titre")
|
|
}
|
|
}
|
|
|
|
// --- 2. un mot du titre pèse plus qu'un mot du texte --------------------------
|
|
|
|
// Le titre compte double (+2/+1, comme recall_search). B est plus RÉCENTE que
|
|
// A : un tri par date seule inverserait l'ordre.
|
|
func TestHistSearchTitleBeatsBody(t *testing.T) {
|
|
histSetup(t)
|
|
histConv(t, "A", "", "Le phare de Brest", 100, []LogEvent{histUser(1, "rien de spécial")})
|
|
histConv(t, "B", "", "Discussion diverse", 200, []LogEvent{histUser(1, "on a vu un phare hier")})
|
|
|
|
res := histSearch("phare", "", 0, 0)
|
|
if got := histHitIDs(res); got != "A,B" {
|
|
t.Fatalf("ordre attendu A,B, obtenu %s", got)
|
|
}
|
|
if !res.Hits[0].InTitle || res.Hits[1].InTitle {
|
|
t.Fatalf("in_title attendu vrai pour A seulement : %+v", res.Hits)
|
|
}
|
|
}
|
|
|
|
// --- 3. pertinence, puis date, puis identifiant -------------------------------
|
|
|
|
func TestHistSearchRelevanceThenDate(t *testing.T) {
|
|
histSetup(t)
|
|
histConv(t, "A", "", "Sans rapport", 200, []LogEvent{histUser(1, "un phare dans le texte")})
|
|
histConv(t, "B", "", "Phare ancien", 100, []LogEvent{histUser(1, "texte neutre")})
|
|
histConv(t, "C", "", "Phare récent", 300, []LogEvent{histUser(1, "texte neutre")})
|
|
|
|
if got := histHitIDs(histSearch("phare", "", 0, 0)); got != "C,B,A" {
|
|
t.Fatalf("ordre attendu C,B,A, obtenu %s", got)
|
|
}
|
|
}
|
|
|
|
// --- 4. la discussion OUVERTE est cherchée dans sa version en RAM -------------
|
|
|
|
// En plein tour, le journal en mémoire est plus frais que la dernière écriture :
|
|
// le mot vient d'arriver, il n'est pas encore dans l'index.
|
|
func TestHistSearchIncludesActiveSession(t *testing.T) {
|
|
histSetup(t)
|
|
histConv(t, "A", "", "Autre sujet", 100, []LogEvent{histUser(1, "on parle boussole")})
|
|
histConv(t, "live1", "", "salut", 200, []LogEvent{histUser(1, "salut")})
|
|
histSetLive(t, "live1", []LogEvent{histUser(1, "salut"), histContent(2, "le phare en direct")})
|
|
|
|
res := histSearch("phare", "", 0, 0)
|
|
if res.Total != 1 || len(res.Hits) != 1 || res.Hits[0].ID != "live1" {
|
|
t.Fatalf("la discussion ouverte devait ressortir seule : %+v", res.Hits)
|
|
}
|
|
if !strings.Contains(res.Hits[0].Snippet, "phare") {
|
|
t.Fatalf("extrait tiré du journal en RAM attendu, obtenu %q", res.Hits[0].Snippet)
|
|
}
|
|
}
|
|
|
|
// --- 5. chercher n'écrit RIEN -------------------------------------------------
|
|
|
|
func TestHistSearchDoesNotWrite(t *testing.T) {
|
|
histSetup(t)
|
|
histConv(t, "live1", "", "salut", 100, []LogEvent{histUser(1, "salut")})
|
|
histSetLive(t, "live1", []LogEvent{histUser(1, "le phare en direct")})
|
|
beforeChat, beforeIdx := allKV(bkChat), allKV(bkChatSearch)
|
|
|
|
_ = histSearch("phare", "", 0, 0)
|
|
|
|
if after := allKV(bkChat); len(after) != len(beforeChat) || after[convKey("live1")] != beforeChat[convKey("live1")] || after[ckIndex] != beforeChat[ckIndex] {
|
|
t.Fatal("la recherche a réécrit une discussion ou l'index des discussions")
|
|
}
|
|
after := allKV(bkChatSearch)
|
|
if len(after) != len(beforeIdx) {
|
|
t.Fatalf("la recherche a modifié l'index plein-texte (%d → %d clés)", len(beforeIdx), len(after))
|
|
}
|
|
for k, v := range beforeIdx {
|
|
if after[k] != v {
|
|
t.Fatalf("la recherche a réécrit %q", k)
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- 6. cloisonnement par projet ----------------------------------------------
|
|
|
|
func TestHistSearchProjectScope(t *testing.T) {
|
|
histSetup(t)
|
|
histConv(t, "A", "alpha", "Projet alpha", 100, []LogEvent{histUser(1, "le phare")})
|
|
histConv(t, "B", "beta", "Projet beta", 100, []LogEvent{histUser(1, "le phare")})
|
|
|
|
if got := histHitIDs(histSearch("phare", "alpha", 0, 0)); got != "A" {
|
|
t.Fatalf("projet alpha : A attendu seul, obtenu %s", got)
|
|
}
|
|
if got := histHitIDs(histSearch("phare", "beta", 0, 0)); got != "B" {
|
|
t.Fatalf("projet beta : B attendu seul, obtenu %s", got)
|
|
}
|
|
// Sans projet donné : celui de la barre latérale, lu en base.
|
|
if err := setActiveProject("beta"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if got := histHitIDs(histSearch("phare", "", 0, 0)); got != "B" {
|
|
t.Fatalf("projet actif beta : B attendu seul, obtenu %s", got)
|
|
}
|
|
}
|
|
|
|
// --- 7. mémoire verrouillée = état explicite, pas « rien trouvé » -------------
|
|
|
|
func TestHistSearchLockedState(t *testing.T) {
|
|
histSetup(t)
|
|
histConv(t, "A", "", "Secret", 100, []LogEvent{histUser(1, "le phare secret")})
|
|
if _, err := EnableMemEncryption("motdepasse-fort"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
clearMemDEK()
|
|
|
|
res := histSearch("phare", "", 0, 0)
|
|
if res.State != "locked" {
|
|
t.Fatalf("état attendu locked, obtenu %q", res.State)
|
|
}
|
|
if len(res.Hits) != 0 || res.Total != 0 {
|
|
t.Fatalf("aucun résultat attendu verrouillé, obtenu %+v", res.Hits)
|
|
}
|
|
}
|
|
|
|
// --- 8. requête sans terme exploitable ----------------------------------------
|
|
|
|
func TestHistSearchNoUsableTerms(t *testing.T) {
|
|
histSetup(t)
|
|
histConv(t, "A", "", "Phare", 100, []LogEvent{histUser(1, "le phare")})
|
|
|
|
res := histSearch("a!", "", 0, 0)
|
|
if res.State != "no_terms" {
|
|
t.Fatalf("état attendu no_terms, obtenu %q", res.State)
|
|
}
|
|
if res.Terms == nil || len(res.Terms) != 0 || res.Total != 0 {
|
|
t.Fatalf("aucun terme (tableau vide, jamais null) ni résultat attendu : terms=%#v total=%d", res.Terms, res.Total)
|
|
}
|
|
}
|
|
|
|
// --- 9. cycle de vie : écriture puis suppression de la discussion -------------
|
|
|
|
func TestHistSearchIndexLifecycle(t *testing.T) {
|
|
histSetup(t)
|
|
histConv(t, "lifeA", "", "A", 100, []LogEvent{histUser(1, "le phare")})
|
|
histConv(t, "lifeB", "", "B", 200, []LogEvent{histUser(1, "le phare")})
|
|
|
|
if !histDocExists(t, "lifeA") || !histDocExists(t, "lifeB") {
|
|
t.Fatal("les deux fiches d| devaient exister après l'écriture")
|
|
}
|
|
if ids := histPosting(t, "phare"); len(ids) != 2 {
|
|
t.Fatalf("posting phare attendu de 2, obtenu %v", ids)
|
|
}
|
|
if err := convDelete("lifeA"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if histDocExists(t, "lifeA") {
|
|
t.Fatal("d|lifeA devait disparaître avec la discussion")
|
|
}
|
|
if _, ok := getStoreBytes(bkChatSearch, histToksKey("lifeA")); ok {
|
|
t.Fatal("t|lifeA devait disparaître avec la discussion")
|
|
}
|
|
if ids := histPosting(t, "phare"); len(ids) != 1 || ids[0] != "lifeB" {
|
|
t.Fatalf("posting phare attendu [lifeB], obtenu %v", ids)
|
|
}
|
|
}
|
|
|
|
// --- 9 bis. mise à jour incrémentale ------------------------------------------
|
|
|
|
// Réécrire une discussion ne touche que les mots qui ont changé — et un mot
|
|
// disparu (journal compacté, discussion vidée) quitte bien l'index.
|
|
func TestHistSearchIncrementalUpdate(t *testing.T) {
|
|
histSetup(t)
|
|
histConv(t, "A", "", "Neutre", 100, []LogEvent{histUser(1, "phare boussole")})
|
|
if ids := histPosting(t, "boussole"); len(ids) != 1 {
|
|
t.Fatalf("boussole attendu indexé, obtenu %v", ids)
|
|
}
|
|
histConv(t, "A", "", "Neutre", 100, []LogEvent{histUser(1, "phare compas")})
|
|
if ids := histPosting(t, "boussole"); len(ids) != 0 {
|
|
t.Fatalf("boussole devait quitter l'index, obtenu %v", ids)
|
|
}
|
|
if ids := histPosting(t, "compas"); len(ids) != 1 || ids[0] != "A" {
|
|
t.Fatalf("compas attendu [A], obtenu %v", ids)
|
|
}
|
|
if ids := histPosting(t, "phare"); len(ids) != 1 {
|
|
t.Fatalf("phare attendu une fois, obtenu %v", ids)
|
|
}
|
|
}
|
|
|
|
// --- 10. l'index est chiffré ----------------------------------------------------
|
|
|
|
func TestHistSearchBucketEncrypted(t *testing.T) {
|
|
histSetup(t)
|
|
histConv(t, "A", "", "Titre secret", 100, []LogEvent{histUser(1, "le phare")})
|
|
if raw := getBytes(bkChatSearch, histDocKey("A")); looksEncrypted(raw) {
|
|
t.Fatal("avant activation, la valeur ne devait pas être chiffrée")
|
|
}
|
|
if _, err := EnableMemEncryption("motdepasse-fort"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
for _, k := range []string{histDocKey("A"), histToksKey("A"), histPostKey("phare")} {
|
|
if !looksEncrypted(getBytes(bkChatSearch, k)) {
|
|
t.Fatalf("%s devait être chiffré (bucket absent d'encryptedBuckets ?)", k)
|
|
}
|
|
}
|
|
// Écrite après l'activation : chiffrée d'emblée, et toujours trouvable.
|
|
histConv(t, "B", "", "Autre", 200, []LogEvent{histUser(1, "le phare encore")})
|
|
if !looksEncrypted(getBytes(bkChatSearch, histPostKey("encore"))) {
|
|
t.Fatal("posting écrit mémoire chiffrée resté en clair")
|
|
}
|
|
if got := histHitIDs(histSearch("phare", "", 0, 0)); got != "B,A" {
|
|
t.Fatalf("B,A attendus mémoire déverrouillée, obtenu %s", got)
|
|
}
|
|
clearMemDEK()
|
|
if _, ok := getStoreBytes(bkChatSearch, histDocKey("A")); ok {
|
|
t.Fatal("index relisible alors que la mémoire est verrouillée")
|
|
}
|
|
}
|
|
|
|
// --- 11. couverture : jamais présenter un index partiel comme complet ---------
|
|
|
|
func TestHistSearchCoverageContract(t *testing.T) {
|
|
histSetup(t)
|
|
log := []LogEvent{histUser(1, "le phare")}
|
|
// Écrites À LA MAIN, sans passer par l'index : des discussions d'avant la
|
|
// fonctionnalité.
|
|
for _, id := range []string{"a", "b", "c"} {
|
|
if err := putStoreBytes(bkChat, convKey(id), histBody(t, log)); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
histSetMeta(t, convMeta{ID: id, Title: id, Updated: 100, Project: defaultProjectSlug})
|
|
}
|
|
if err := histIndexAt(dbPath(), "a", log); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
res := histSearch("phare", "", 0, 0)
|
|
if res.IndexedTotal != 3 || res.Indexed != 1 {
|
|
t.Fatalf("couverture attendue 1/3, obtenue %d/%d", res.Indexed, res.IndexedTotal)
|
|
}
|
|
if res.Total != 1 {
|
|
t.Fatalf("1 seule trouvable avant rattrapage, obtenu %d", res.Total)
|
|
}
|
|
|
|
histSearchIndexBackfill()
|
|
|
|
res = histSearch("phare", "", 0, 0)
|
|
if res.Indexed != 3 || res.IndexedTotal != 3 || res.Total != 3 {
|
|
t.Fatalf("après rattrapage : %d/%d, %d trouvées (3 attendues)", res.Indexed, res.IndexedTotal, res.Total)
|
|
}
|
|
}
|
|
|
|
// --- 11 bis. une fiche plus ancienne que la discussion est à refaire ----------
|
|
|
|
// L'écriture a réussi, l'indexation qui suit a raté (base occupée, process
|
|
// coupé) : la fiche existe mais décrit un état antérieur. Elle ne doit pas
|
|
// compter comme à jour, et le rattrapage doit la refaire.
|
|
func TestHistSearchStaleDocReindexed(t *testing.T) {
|
|
histSetup(t)
|
|
histConv(t, "A", "", "Neutre", 100, []LogEvent{histUser(1, "le phare")})
|
|
future := histReadDocs()["A"].At + 10
|
|
if err := putStoreBytes(bkChat, convKey("A"), histBody(t, []LogEvent{histUser(1, "le phare"), histContent(2, "une boussole")})); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
histSetMeta(t, convMeta{ID: "A", Title: "Neutre", Updated: future, Project: defaultProjectSlug})
|
|
|
|
res := histSearch("boussole", "", 0, 0)
|
|
if res.Total != 0 || res.Indexed != 0 || res.IndexedTotal != 1 {
|
|
t.Fatalf("fiche périmée : 0 trouvée et couverture 0/1 attendues, obtenu %d trouvée(s), %d/%d", res.Total, res.Indexed, res.IndexedTotal)
|
|
}
|
|
histSearchIndexBackfill()
|
|
// Le rattrapage date la fiche de MAINTENANT : on recale la discussion dessus.
|
|
histSetMeta(t, convMeta{ID: "A", Title: "Neutre", Updated: histReadDocs()["A"].At, Project: defaultProjectSlug})
|
|
res = histSearch("boussole", "", 0, 0)
|
|
if res.Total != 1 || res.Indexed != 1 {
|
|
t.Fatalf("après rattrapage : boussole attendue trouvée et index à jour, obtenu %d trouvée(s), %d/%d", res.Total, res.Indexed, res.IndexedTotal)
|
|
}
|
|
}
|
|
|
|
// --- 12. le classement vient de l'index, pas des corps ------------------------
|
|
|
|
// Corps disparu, fiche et postings restés : la discussion reste trouvable, et
|
|
// l'extrait se rabat sur l'aperçu de la fiche.
|
|
func TestHistSearchRanksFromIndexNotBodies(t *testing.T) {
|
|
histSetup(t)
|
|
histConv(t, "A", "", "Titre", 100, []LogEvent{histUser(1, "le phare")})
|
|
if err := putBytes(bkChat, convKey("A"), nil); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res := histSearch("phare", "", 0, 0)
|
|
if res.Total != 1 || res.Hits[0].ID != "A" {
|
|
t.Fatalf("le résultat devait venir de l'index, obtenu %+v", res.Hits)
|
|
}
|
|
if res.Hits[0].Snippet != "le phare" {
|
|
t.Fatalf("aperçu de la fiche attendu, obtenu %q", res.Hits[0].Snippet)
|
|
}
|
|
}
|
|
|
|
// --- 13. la discussion ouverte n'apparaît qu'une fois, son score n'est pas SOMMÉ
|
|
|
|
// Ouverte ET indexée : une seule entrée, et le journal en RAM REMPLACE la copie
|
|
// indexée au lieu de s'y ajouter. Requête « alpha beta », A ouverte :
|
|
//
|
|
// A : titre « alpha » (1) ; texte « alpha beta » (2) → 2*1+2 = 4 ; sommé → 6.
|
|
// B : titre « alpha beta » (2) ; texte « alpha » (1) → 2*2+1 = 5.
|
|
//
|
|
// Remplacement : [B A]. Somme : [A B] — l'ordre s'inverse.
|
|
func TestHistSearchDeduplicatesActiveAgainstIndex(t *testing.T) {
|
|
histSetup(t)
|
|
histConv(t, "A", "", "alpha", 100, []LogEvent{histUser(1, "alpha beta")})
|
|
histConv(t, "B", "", "alpha beta", 200, []LogEvent{histUser(1, "alpha")})
|
|
histSetLive(t, "A", []LogEvent{histUser(1, "alpha beta")})
|
|
|
|
res := histSearch("alpha beta", "", 0, 0)
|
|
if got := histHitIDs(res); got != "B,A" {
|
|
t.Fatalf("une entrée par discussion, score remplacé : B,A attendus, obtenu %s (%+v)", got, res.Hits)
|
|
}
|
|
}
|
|
|
|
// --- 14. pagination : total AVANT découpage -----------------------------------
|
|
|
|
func TestHistSearchPagination(t *testing.T) {
|
|
histSetup(t)
|
|
histConv(t, "A", "", "Phare A", 100, []LogEvent{histUser(1, "le phare")})
|
|
histConv(t, "B", "", "Phare B", 200, []LogEvent{histUser(1, "le phare")})
|
|
histConv(t, "C", "", "Phare C", 300, []LogEvent{histUser(1, "le phare")})
|
|
|
|
page := histSearch("phare", "", 1, 1)
|
|
if len(page.Hits) != 1 || page.Total != 3 || page.Hits[0].ID != "B" {
|
|
t.Fatalf("page 2 de 1 : B et total 3 attendus, obtenu %+v total=%d", page.Hits, page.Total)
|
|
}
|
|
beyond := histSearch("phare", "", 10, 5)
|
|
if len(beyond.Hits) != 0 || beyond.Total != 3 {
|
|
t.Fatalf("au-delà de la fin : 0 résultat et total 3 attendus, obtenu %d / %d", len(beyond.Hits), beyond.Total)
|
|
}
|
|
}
|
|
|
|
// --- 15. repli des accents, dans les DEUX sens --------------------------------
|
|
|
|
func TestHistSearchAccentFolding(t *testing.T) {
|
|
histSetup(t)
|
|
histConv(t, "A", "", "Notes", 100, []LogEvent{histUser(1, "voici le résumé du rapport")})
|
|
histConv(t, "B", "", "Notes", 200, []LogEvent{histUser(1, "voici le resume du dossier")})
|
|
|
|
if n := len(histSearch("resume", "", 0, 0).Hits); n != 2 {
|
|
t.Fatalf("resume devait trouver les 2, obtenu %d", n)
|
|
}
|
|
if n := len(histSearch("RÉSUMÉ", "", 0, 0).Hits); n != 2 {
|
|
t.Fatalf("RÉSUMÉ devait trouver les 2, obtenu %d", n)
|
|
}
|
|
}
|
|
|
|
// --- 16. seul le texte VISIBLE est indexé -------------------------------------
|
|
|
|
func TestHistSearchIndexesVisibleTextOnly(t *testing.T) {
|
|
histSetup(t)
|
|
histConv(t, "A", "", "Neutre", 100, []LogEvent{histContent(1, "voici un zèbre dans la réponse")})
|
|
histConv(t, "B", "", "Neutre", 200, []LogEvent{{TS: 1, Delta: map[string]any{"tool_used": map[string]any{
|
|
"name": "bash", "label": "rg zèbre", "result": "peu importe", "done": true}}}})
|
|
histConv(t, "C", "", "Neutre", 300, []LogEvent{{TS: 1, Delta: map[string]any{"reasoning_content": "je pense au zèbre"}}})
|
|
histConv(t, "D", "", "Neutre", 400, []LogEvent{{TS: 1, Delta: map[string]any{"tool_used": map[string]any{
|
|
"name": "bash", "label": "ls", "result": "le zèbre est dans la sortie", "done": true}}}})
|
|
|
|
res := histSearch("zèbre", "", 0, 0)
|
|
if !histHasHit(res, "A") || !histHasHit(res, "B") {
|
|
t.Fatalf("réponse et libellé d'outil devaient être indexés : %+v", res.Hits)
|
|
}
|
|
if histHasHit(res, "C") || histHasHit(res, "D") {
|
|
t.Fatalf("raisonnement et sortie d'outil ne devaient PAS être indexés : %+v", res.Hits)
|
|
}
|
|
}
|
|
|
|
// --- 17. correspondance par PRÉFIXE -------------------------------------------
|
|
|
|
func TestHistSearchPrefixMatchesBody(t *testing.T) {
|
|
histSetup(t)
|
|
histConv(t, "A", "", "Sujet neutre", 100, []LogEvent{histUser(1, "on passe du temps à compiling le binaire")})
|
|
histConv(t, "B", "", "Autre", 200, []LogEvent{histUser(1, "rien à voir ici")})
|
|
|
|
if got := histHitIDs(histSearch("compil", "", 0, 0)); got != "A" {
|
|
t.Fatalf("compil devait trouver A (« compiling »), obtenu %s", got)
|
|
}
|
|
}
|
|
|
|
// Garde-fous inverses : un préfixe qui ne commence aucun mot ne rend rien, et le
|
|
// MILIEU d'un mot ne correspond pas. « deepseek » est dans un TITRE à dessein :
|
|
// le scan des postings, borné au préfixe « p|eepseek », ne peut pas atteindre
|
|
// « deepseek » ; seul le titre verrait une bascule HasPrefix → Contains.
|
|
func TestHistSearchPrefixRejectsNonPrefix(t *testing.T) {
|
|
histSetup(t)
|
|
histConv(t, "A", "", "compiling", 100, []LogEvent{histUser(1, "compiling le binaire")})
|
|
histConv(t, "B", "", "deepseek", 200, []LogEvent{histUser(1, "on parle deepseek ici")})
|
|
|
|
for _, q := range []string{"compilo", "eepseek"} {
|
|
if res := histSearch(q, "", 0, 0); res.Total != 0 {
|
|
t.Fatalf("%q ne devait rien trouver, obtenu %s", q, histHitIDs(res))
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestHistSearchPrefixMatchesTitle(t *testing.T) {
|
|
histSetup(t)
|
|
histConv(t, "A", "", "Compilation annuelle", 100, []LogEvent{histUser(1, "texte neutre")})
|
|
histConv(t, "B", "", "Sujet divers", 200, []LogEvent{histUser(1, "on finit le compiling")})
|
|
|
|
if got := histHitIDs(histSearch("compil", "", 0, 0)); got != "A,B" {
|
|
t.Fatalf("le titre (+2) doit primer le texte (+1) : A,B attendus, obtenu %s", got)
|
|
}
|
|
}
|
|
|
|
// Un préfixe qui touche plusieurs mots d'une même discussion ne compte qu'une
|
|
// fois : à un incrément par mot, A (3 variantes, plus ancienne) passerait devant.
|
|
func TestHistSearchPrefixCountsTermOnce(t *testing.T) {
|
|
histSetup(t)
|
|
histConv(t, "A", "", "Neutre", 100, []LogEvent{histUser(1, "compiling compilation compiled")})
|
|
histConv(t, "B", "", "Neutre", 200, []LogEvent{histUser(1, "on a fini de compiling")})
|
|
|
|
if got := histHitIDs(histSearch("compil", "", 0, 0)); got != "B,A" {
|
|
t.Fatalf("score par TERME (égalité, B plus récente) : B,A attendus, obtenu %s", got)
|
|
}
|
|
}
|
|
|
|
// La discussion ouverte suit la même règle de préfixe que l'index.
|
|
func TestHistSearchPrefixMatchesLiveSession(t *testing.T) {
|
|
histSetup(t)
|
|
histConv(t, "A", "", "Autre", 100, []LogEvent{histUser(1, "rien de spécial")})
|
|
histConv(t, "live1", "", "salut", 200, []LogEvent{histUser(1, "salut")})
|
|
histSetLive(t, "live1", []LogEvent{histUser(1, "salut"), histContent(2, "on parle de compilation ici")})
|
|
|
|
if got := histHitIDs(histSearch("compil", "", 0, 0)); got != "live1" {
|
|
t.Fatalf("la discussion ouverte devait ressortir sur « compil », obtenu %s", got)
|
|
}
|
|
}
|
|
|
|
// --- 18. couverture : une entrée d'index SANS corps n'est pas « à indexer » ---
|
|
|
|
// Une discussion toute neuve est dans l'index des discussions avant d'avoir
|
|
// jamais été écrite : elle ne recevra pas de fiche tant qu'elle est vide. La
|
|
// compter au dénominateur laissait l'UI annoncer « indexation en cours » pour
|
|
// toujours (constat amont, 13/14 stable).
|
|
func TestHistSearchCoverageIgnoresMetaWithoutBody(t *testing.T) {
|
|
histSetup(t)
|
|
histConv(t, "normale", "", "Sujet", 100, []LogEvent{histUser(1, "le phare")})
|
|
histSetMeta(t, convMeta{ID: "neuve", Title: "", Updated: 200, Project: defaultProjectSlug})
|
|
|
|
res := histSearch("phare", "", 0, 0)
|
|
if res.IndexedTotal != 1 || res.Indexed != 1 {
|
|
t.Fatalf("couverture 1/1 attendue, obtenue %d/%d", res.Indexed, res.IndexedTotal)
|
|
}
|
|
if !histHasHit(res, "normale") || histHasHit(res, "neuve") {
|
|
t.Fatalf("la discussion écrite attendue, la neuve exclue : %+v", res.Hits)
|
|
}
|
|
}
|
|
|
|
// --- 19. l'extrait montre le passage trouvé, pas le début -----------------------
|
|
|
|
// Le début d'une discussion est son titre (convSummary) : l'aperçu seul
|
|
// n'apprendrait rien. L'extrait est pris autour du mot trouvé, loin dans le fil.
|
|
func TestHistSearchSnippetInContext(t *testing.T) {
|
|
histSetup(t)
|
|
long := strings.Repeat("du texte sans rapport ", 40)
|
|
histConv(t, "A", "", "Question de départ", 100, []LogEvent{
|
|
histUser(1, "Question de départ"),
|
|
histContent(2, long+"et voici enfin le Phare du Créac'h, au bout de l'île. "+long),
|
|
})
|
|
res := histSearch("phare", "", 0, 0)
|
|
if len(res.Hits) != 1 {
|
|
t.Fatalf("A attendue, obtenu %+v", res.Hits)
|
|
}
|
|
sn := res.Hits[0].Snippet
|
|
if !strings.Contains(sn, "Phare du Créac'h") {
|
|
t.Fatalf("extrait autour du mot attendu, obtenu %q", sn)
|
|
}
|
|
if !strings.HasPrefix(sn, "…") || !strings.HasSuffix(sn, "…") {
|
|
t.Fatalf("extrait coupé des deux côtés attendu (…), obtenu %q", sn)
|
|
}
|
|
if n := len([]rune(sn)); n > 260 {
|
|
t.Fatalf("extrait trop long (%d runes) : %q", n, sn)
|
|
}
|
|
}
|
|
|
|
// --- 20. écritures de la FAÇADE (LOKI_CHAT=rust) ------------------------------
|
|
|
|
// La façade écrit ses discussions par /internal/v1/store, scellées par ce
|
|
// process : l'index doit les suivre — écriture, réécriture, suppression — sans
|
|
// que la façade ait rien à en savoir.
|
|
func TestHistSearchFacadeStoreWrites(t *testing.T) {
|
|
histSetup(t)
|
|
if _, err := EnableMemEncryption("motdepasse-fort"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
histSetMeta(t, convMeta{ID: "r1", Title: "Écrite par la façade", Updated: 100, Project: defaultProjectSlug})
|
|
put := func(log []LogEvent) {
|
|
t.Helper()
|
|
sealed, err := encodeMemContent(histBody(t, log))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
req := httptest.NewRequest(http.MethodPut, "/internal/v1/store?bucket=chat&key=conv:r1", strings.NewReader(string(sealed)))
|
|
handleInternalStore(rr, req)
|
|
if rr.Code != http.StatusNoContent {
|
|
t.Fatalf("PUT : %d %s", rr.Code, rr.Body.String())
|
|
}
|
|
}
|
|
put([]LogEvent{histUser(1, "le phare vu par la façade")})
|
|
if got := histHitIDs(histSearch("phare", "", 0, 0)); got != "r1" {
|
|
t.Fatalf("discussion de la façade attendue, obtenu %q", got)
|
|
}
|
|
if !looksEncrypted(getBytes(bkChatSearch, histPostKey("phare"))) {
|
|
t.Fatal("index tenu pour la façade resté en clair")
|
|
}
|
|
put([]LogEvent{histUser(1, "maintenant une boussole")})
|
|
if res := histSearch("phare", "", 0, 0); res.Total != 0 {
|
|
t.Fatalf("réécrite sans « phare » : plus rien attendu, obtenu %s", histHitIDs(res))
|
|
}
|
|
rr := httptest.NewRecorder()
|
|
handleInternalStore(rr, httptest.NewRequest(http.MethodDelete, "/internal/v1/store?bucket=chat&key=conv:r1", nil))
|
|
if rr.Code != http.StatusNoContent {
|
|
t.Fatalf("DELETE : %d", rr.Code)
|
|
}
|
|
if histDocExists(t, "r1") || len(histPosting(t, "boussole")) != 0 {
|
|
t.Fatal("discussion supprimée par la façade restée dans l'index")
|
|
}
|
|
// Une autre clé du même bucket (l'index des discussions) n'est pas une discussion.
|
|
histIndexStoreWrite(bkChat, ckIndex, []byte("[]"), false)
|
|
if histDocExists(t, "index") {
|
|
t.Fatal("l'index des discussions a été pris pour une discussion")
|
|
}
|
|
}
|
|
|
|
// La route reste à ce process : la façade ne la sert pas (elle la relaie), et
|
|
// le tunnel du relais ne la lui remet pas.
|
|
func TestHistSearchRouteNotDelegated(t *testing.T) {
|
|
if facadeChatRoutes["/api/conversations/search"] {
|
|
t.Fatal("/api/conversations/search ne doit pas être remise à la façade : l'index est tenu ici")
|
|
}
|
|
}
|
|
|
|
// --- 21. la route HTTP --------------------------------------------------------
|
|
|
|
func TestHandleConvSearch(t *testing.T) {
|
|
histSetup(t)
|
|
histConv(t, "A", "", "Le phare", 100, []LogEvent{histUser(1, "Le phare")})
|
|
|
|
rr := httptest.NewRecorder()
|
|
handleConvSearch(rr, httptest.NewRequest(http.MethodGet, "/api/conversations/search?q=phare", nil))
|
|
var got struct {
|
|
OK bool `json:"ok"`
|
|
State string `json:"state"`
|
|
Conversations []histSearchHit `json:"conversations"`
|
|
Total int `json:"total"`
|
|
Indexed int `json:"indexed"`
|
|
IndexedTotal int `json:"indexed_total"`
|
|
Terms []string `json:"terms"`
|
|
}
|
|
if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil {
|
|
t.Fatalf("réponse illisible : %v (%s)", err, rr.Body.String())
|
|
}
|
|
if !got.OK || got.State != "ok" || got.Total != 1 || len(got.Conversations) != 1 || !got.Conversations[0].InTitle {
|
|
t.Fatalf("réponse inattendue : %+v", got)
|
|
}
|
|
if got.Indexed != 1 || got.IndexedTotal != 1 || strings.Join(got.Terms, ",") != "phare" {
|
|
t.Fatalf("couverture ou termes inattendus : %+v", got)
|
|
}
|
|
|
|
rr = httptest.NewRecorder()
|
|
handleConvSearch(rr, httptest.NewRequest(http.MethodGet, "/api/conversations/search?q=", nil))
|
|
if !strings.Contains(rr.Body.String(), `"conversations":[]`) || !strings.Contains(rr.Body.String(), `"no_terms"`) {
|
|
t.Fatalf("requête vide : tableau vide et no_terms attendus, obtenu %s", rr.Body.String())
|
|
}
|
|
|
|
rr = httptest.NewRecorder()
|
|
handleConvSearch(rr, httptest.NewRequest(http.MethodPost, "/api/conversations/search", nil))
|
|
if rr.Code != http.StatusMethodNotAllowed {
|
|
t.Fatalf("POST : 405 attendu, obtenu %d", rr.Code)
|
|
}
|
|
}
|