diff --git a/NOTICE.md b/NOTICE.md index 601ad1d..c9dbb68 100644 --- a/NOTICE.md +++ b/NOTICE.md @@ -52,6 +52,14 @@ et le **chiffrement de la mémoire** (`mem_crypto.go`, `mem_vault.go`, **pas** reprise : elle vise ajean.link, le service de l'auteur amont. Loki expose à la place un export/import de paquet chiffré en fichier local. +La **recherche plein-texte des discussions** (`chat_search.go`) reprend celle +de l'historique d'AJEAN (commit `558d3b4d`, issue amont #98, PR #99 d'Olioli4) : +index inversé chiffré comme les discussions, tokens repliés sans accents, +correspondance par début de mot, titre compté double. Adaptée au modèle de +Loki (toutes les discussions dans `bkChat`, index tenu à chaque écriture, y +compris celles de la façade Rust) et à sa barre latérale (« rechercher +partout », extrait autour du passage trouvé). + ## Moteur Strata Le second moteur de Loki (`internal/loki/backend_strata.go`) est diff --git a/internal/loki/chat_persist.go b/internal/loki/chat_persist.go index 9ea3534..2f97572 100644 --- a/internal/loki/chat_persist.go +++ b/internal/loki/chat_persist.go @@ -173,6 +173,9 @@ func (p *convPersister) run() { sort.Slice(snaps, func(i, j int) bool { return snaps[i].seq < snaps[j].seq }) wrote, err := persistCommitByPath(snaps, res) + // Index plein-texte (chat_search.go) AVANT de marquer le lot traité : un + // flush attend donc aussi l'index, et une suppression passe toujours après. + histIndexWritten(wrote) p.mu.Lock() for _, j := range res { diff --git a/internal/loki/chat_search.go b/internal/loki/chat_search.go new file mode 100644 index 0000000..159a2b3 --- /dev/null +++ b/internal/loki/chat_search.go @@ -0,0 +1,852 @@ +package loki + +// chat_search.go — recherche PLEIN-TEXTE dans les discussions (reprise d'AJEAN, +// commit 558d3b4d, issue amont #98 / PR #99). +// +// Avant, la barre latérale ne filtrait que les TITRES, côté navigateur. Relire +// le corps de chaque discussion à chaque frappe est exclu : une discussion pèse +// vite des centaines de Ko, chiffrée de surcroît. On tient donc un INDEX INVERSÉ +// dans bkChatSearch, à trois familles de clés : +// +// « d| » → histSearchDoc : la fiche d'une discussion (version, date +// d'indexation, aperçu). Petite : la couverture et l'aperçu se +// lisent sans charger la liste des mots. +// « t| » → []string : les mots DISTINCTS de la discussion, repliés et +// triés. Sert seulement à la mise à jour (différence avec +// l'état précédent) et à la suppression. +// « p| » → []string d'identifiants triés (sémantique d'ENSEMBLE). +// +// Écart avec l'amont : AJEAN range la liste des mots dans la fiche « d| » et +// lit TOUTES les fiches à chaque requête pour la couverture. Ici la fiche reste +// petite, et une mise à jour ne touche que les mots ajoutés ou retirés depuis la +// fois précédente — la discussion ouverte est réindexée à chaque écriture, pas +// seulement à l'archivage comme chez l'amont. +// +// QUI ÉCRIT L'INDEX. Ce process, toujours, même quand la façade Rust tient la +// conversation (LOKI_CHAT=rust) : elle n'a pas accès à la base et écrit ses +// discussions par /internal/v1/store (web_internal.go), qui indexe au passage +// (histIndexStoreWrite). L'index suit donc la même discussion quel que soit le +// process qui la tient, sans second écrivain ni test de parité à tenir : la +// route de recherche (/api/conversations/search) est servie ICI, la façade la +// relaie comme toute route qu'elle ne connaît pas (proxy.rs, forward). +// +// ACCENTS : l'index stocke des tokens REPLIÉS (« résumé » → « resume »). C'est +// délibérément différent de la sortie de recallTokens, que l'outil recall_search +// du modèle consomme telle quelle — ne pas « corriger » cette apparente +// incohérence : replier l'index (et la requête, symétriquement) est ce qui fait +// qu'une recherche « resume » trouve « résumé » et inversement. + +import ( + "bytes" + "encoding/json" + "net/http" + "os" + "sort" + "strconv" + "strings" + "sync/atomic" + "time" + "unicode" + "unicode/utf8" + + bolt "go.etcd.io/bbolt" +) + +const ( + // histSearchIndexVersion : une fiche dont la version diffère est traitée + // comme non indexée et reconstruite en entier. + histSearchIndexVersion = 1 + // histSearchMaxTerms borne le nombre de postings lus par requête. + histSearchMaxTerms = 16 + // histSearchDefaultLimit / histSearchMaxLimit : taille d'une page de + // résultats. L'extrait en contexte relit le corps de CHAQUE résultat de la + // page : la borne protège la requête d'un « limit=100000 ». + histSearchDefaultLimit = 30 + histSearchMaxLimit = 100 +) + +// histSearchDoc = la fiche d'index d'une discussion. +type histSearchDoc struct { + V int `json:"v"` + At int64 `json:"at"` // indexée à (Unix, s) — comparée à convMeta.Updated + Apercu string `json:"apercu,omitempty"` // début du texte visible, borné (recallSnippet) +} + +// histSearchHit = une discussion trouvée, telle que la reçoit l'UI. +type histSearchHit struct { + ID string `json:"id"` + Title string `json:"title"` + Created int64 `json:"created"` + Updated int64 `json:"updated"` + Turns int `json:"turns"` + Score int `json:"score"` + InTitle bool `json:"in_title"` + Snippet string `json:"snippet"` +} + +// histSearchResult = le résultat complet d'une recherche. +type histSearchResult struct { + State string `json:"state"` // ok | locked | no_terms + Hits []histSearchHit `json:"conversations"` + Total int `json:"total"` // correspondances AVANT pagination + Indexed int `json:"indexed"` // discussions du projet réellement indexées + IndexedTotal int `json:"indexed_total"` // discussions indexables du projet + Terms []string `json:"terms"` // termes cherchés (repliés, triés) +} + +func histDocKey(id string) string { return "d|" + id } +func histToksKey(id string) string { return "t|" + id } +func histPostKey(tok string) string { return "p|" + tok } + +// foldHistAccents replie les diacritiques d'un token. Appliqué SYMÉTRIQUEMENT +// à l'indexation et à la requête (voir le commentaire de tête). +func foldHistAccents(s string) string { + var b strings.Builder + b.Grow(len(s)) + for _, r := range s { + switch r { + case 'à', 'â', 'ä', 'á', 'ã', 'å': + b.WriteByte('a') + case 'é', 'è', 'ê', 'ë': + b.WriteByte('e') + case 'î', 'ï', 'í', 'ì': + b.WriteByte('i') + case 'ô', 'ö', 'ó', 'ò', 'õ': + b.WriteByte('o') + case 'ù', 'û', 'ü', 'ú': + b.WriteByte('u') + case 'ç': + b.WriteByte('c') + case 'ÿ': + b.WriteByte('y') + case 'œ': + b.WriteString("oe") + case 'æ': + b.WriteString("ae") + default: + b.WriteRune(r) + } + } + return b.String() +} + +// histTokens dérive les mots d'index d'une chaîne : recallTokens (réutilisé, +// jamais modifié), puis repli des accents et dédoublonnage, triés. Deux tokens +// distincts peuvent se replier sur le même (« cote » et « côté »). +func histTokens(s string) []string { + set := recallTokens(s) + seen := make(map[string]bool, len(set)) + out := make([]string, 0, len(set)) + for t := range set { + f := foldHistAccents(t) + if seen[f] { + continue + } + seen[f] = true + out = append(out, f) + } + sort.Strings(out) + return out +} + +// histTokenSet : variante « ensemble » de histTokens (titre, discussion ouverte). +func histTokenSet(s string) map[string]bool { + out := map[string]bool{} + for _, t := range histTokens(s) { + out[t] = true + } + return out +} + +// histTermMatches est LA règle de correspondance : un token correspond à un +// terme quand il COMMENCE par lui, jamais au milieu du mot (« eepseek » ne +// trouve pas « deepseek »). Servie au titre, à la discussion ouverte et à +// l'extrait ; le scan des postings applique la même règle au niveau de la clé +// (préfixe « p| »). +func histTermMatches(token, term string) bool { + return strings.HasPrefix(token, term) +} + +// histSetMatches : un terme correspond-il à l'un des tokens d'un ensemble ? +// Vrai au plus UNE fois par terme : le score compte les TERMES, pas les tokens. +func histSetMatches(set map[string]bool, term string) bool { + for tok := range set { + if histTermMatches(tok, term) { + return true + } + } + return false +} + +// histQueryTerms replie, trie et plafonne les termes de la requête. +func histQueryTerms(q string) []string { + terms := histTokens(q) + if len(terms) > histSearchMaxTerms { + terms = terms[:histSearchMaxTerms] + } + return terms +} + +// histVisibleText concatène le texte VISIBLE d'un journal d'affichage, seule +// source indexée. On part du journal (vue UI) et NON des Messages : leur +// contenu peut être multimodal et porte le CORPS des sorties d'outil, qu'on ne +// veut pas indexer. On retient le message de l'utilisateur (« user »), la +// réponse (« content ») et le libellé d'outil (« tool_used.label »). Exclus : +// raisonnement, résultat d'outil, fichiers, deltas de contrôle. +func histVisibleText(log []LogEvent) string { + var b strings.Builder + for _, ev := range log { + if s, ok := ev.Delta["user"].(string); ok { + b.WriteString(s) + b.WriteByte('\n') + } + if s, ok := ev.Delta["content"].(string); ok { + b.WriteString(s) + b.WriteByte('\n') + } + if tu, ok := ev.Delta["tool_used"].(map[string]any); ok { + if l, ok := tu["label"].(string); ok { + b.WriteString(l) + b.WriteByte('\n') + } + } + } + return b.String() +} + +// histFirstVisible : premier texte visible d'un journal (aperçu de la fiche). +func histFirstVisible(log []LogEvent) string { + for _, ev := range log { + if s, ok := ev.Delta["user"].(string); ok && strings.TrimSpace(s) != "" { + return s + } + if s, ok := ev.Delta["content"].(string); ok && strings.TrimSpace(s) != "" { + return s + } + if tu, ok := ev.Delta["tool_used"].(map[string]any); ok { + if l, ok := tu["label"].(string); ok && strings.TrimSpace(l) != "" { + return l + } + } + } + return "" +} + +// histLogOf extrait le journal d'affichage du JSON d'une Conversation, sans +// rien décoder d'autre. +func histLogOf(body []byte) ([]LogEvent, bool) { + var c struct { + Log []LogEvent `json:"log"` + } + if json.Unmarshal(body, &c) != nil { + return nil, false + } + return c.Log, true +} + +// histIsWordRune : même découpage que recallTokens (sur la chaîne en minuscules). +func histIsWordRune(r rune) bool { + return r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r >= 'à' && r <= 'ÿ' +} + +// histContextSnippet renvoie un extrait du texte autour du PREMIER mot qui +// correspond à l'un des termes (même règle de préfixe), ou "" sans +// correspondance. L'aperçu de la fiche ne montre que le début d'une +// discussion — c'est-à-dire, le plus souvent, son titre. +func histContextSnippet(text string, terms []string) string { + const before, after = 60, 140 + runes := []rune(text) + lower := make([]rune, len(runes)) // rune par rune : les positions restent alignées + for i, r := range runes { + lower[i] = unicode.ToLower(r) + } + for i := 0; i < len(lower); { + if !histIsWordRune(lower[i]) { + i++ + continue + } + j := i + for j < len(lower) && histIsWordRune(lower[j]) { + j++ + } + if j-i >= 2 { + w := foldHistAccents(string(lower[i:j])) + for _, t := range terms { + if !histTermMatches(w, t) { + continue + } + from, to := max(0, i-before), min(len(runes), j+after) + // Couper sur une espace pour ne pas commencer ni finir au milieu d'un mot. + for from > 0 && from < i && runes[from-1] != ' ' && runes[from-1] != '\n' { + from++ + } + for to < len(runes) && to > j && runes[to] != ' ' && runes[to] != '\n' { + to-- + } + s := strings.Join(strings.Fields(string(runes[from:to])), " ") + if from > 0 { + s = "…" + s + } + if to < len(runes) { + s += "…" + } + return s + } + } + i = j + } + return "" +} + +// --- accès bas niveau au bucket (chiffré comme les autres) -------------------- + +// histTxGetJSON lit et déchiffre une valeur dans une transaction ouverte. +// decodeMemContent est sûr ici : sur du clair il ne lit rien, sur du chiffré +// il n'appelle que currentDEK (mutex en RAM, jamais la base). +func histTxGetJSON(b *bolt.Bucket, key string, dst any) bool { + raw := b.Get([]byte(key)) + if raw == nil { + return false + } + plain, err := decodeMemContent(raw) + if err != nil { + return false // chiffré + verrouillé : illisible, pas « absent » + } + return json.Unmarshal(plain, dst) == nil +} + +// histTxPutJSON sérialise, chiffre (encodeur résolu HORS transaction : bbolt +// n'est pas rentrant, voir memEncoderNow) et écrit. +func histTxPutJSON(b *bolt.Bucket, key string, v any, encode func([]byte) ([]byte, error)) error { + raw, err := json.Marshal(v) + if err != nil { + return err + } + enc, err := encode(raw) + if err != nil { + return err + } + return b.Put([]byte(key), enc) +} + +// histPost ajoute sid à la liste du token (ensemble trié, idempotent). +func histPost(b *bolt.Bucket, tok, sid string, encode func([]byte) ([]byte, error)) error { + var ids []string + _ = histTxGetJSON(b, histPostKey(tok), &ids) + i := sort.SearchStrings(ids, sid) + if i < len(ids) && ids[i] == sid { + return nil + } + ids = append(ids, "") + copy(ids[i+1:], ids[i:]) + ids[i] = sid + return histTxPutJSON(b, histPostKey(tok), ids, encode) +} + +// histUnpost retire sid de la liste du token ; la clé disparaît si elle se vide. +func histUnpost(b *bolt.Bucket, tok, sid string, encode func([]byte) ([]byte, error)) error { + var ids []string + if !histTxGetJSON(b, histPostKey(tok), &ids) { + return nil + } + out := make([]string, 0, len(ids)) + for _, x := range ids { + if x != sid { + out = append(out, x) + } + } + if len(out) == len(ids) { + return nil + } + if len(out) == 0 { + return b.Delete([]byte(histPostKey(tok))) + } + return histTxPutJSON(b, histPostKey(tok), out, encode) +} + +// --- maintenance : indexation / désindexation --------------------------------- + +// histIndexAt (ré)indexe une discussion dans la base path, en UNE transaction : +// mots retirés depuis la dernière fois, fiche, mots ajoutés. Une fiche d'une +// autre version (ou absente) fait tout reposer. Les sémantiques d'ensemble +// rendent l'opération idempotente. Mémoire chiffrée mais verrouillée : erreur, +// rien n'est écrit en clair (le rattrapage reprendra au déverrouillage). +func histIndexAt(path, id string, log []LogEvent) error { + if id == "" { + return nil + } + encode, err := memEncoderNow() // HORS transaction (voir memEncoderNow) + if err != nil { + return err + } + toks := histTokens(histVisibleText(log)) + doc := histSearchDoc{V: histSearchIndexVersion, At: time.Now().Unix(), + Apercu: recallSnippet(histFirstVisible(log), 200)} + cacheBust(bkChatSearch) + return withDBAt(path, func(d *bolt.DB) error { + return d.Update(func(tx *bolt.Tx) error { + b, err := tx.CreateBucketIfNotExists([]byte(bkChatSearch)) + if err != nil { + return err + } + var prevDoc histSearchDoc + var prev []string + full := !histTxGetJSON(b, histDocKey(id), &prevDoc) || prevDoc.V != histSearchIndexVersion + _ = histTxGetJSON(b, histToksKey(id), &prev) + had := make(map[string]bool, len(prev)) + for _, t := range prev { + had[t] = true + } + now := make(map[string]bool, len(toks)) + for _, t := range toks { + now[t] = true + } + for _, t := range prev { + if !now[t] { + if err := histUnpost(b, t, id, encode); err != nil { + return err + } + } + } + for _, t := range toks { + if full || !had[t] { + if err := histPost(b, t, id, encode); err != nil { + return err + } + } + } + if err := histTxPutJSON(b, histToksKey(id), toks, encode); err != nil { + return err + } + return histTxPutJSON(b, histDocKey(id), doc, encode) + }) + }) +} + +// histUnindexAt retire une discussion de l'index. Si sa liste de mots est +// lisible, ses postings partent avec ; sinon (verrouillé) seules ses clés +// « d| » et « t| » sont supprimées, en laissant des identifiants ORPHELINS +// dans les postings — tolérés : la recherche écarte tout identifiant absent de +// l'index des discussions. +func histUnindexAt(path, id string) { + if id == "" { + return + } + encode, err := memEncoderNow() + cacheBust(bkChatSearch) + _ = withDBAt(path, func(d *bolt.DB) error { + return d.Update(func(tx *bolt.Tx) error { + b := tx.Bucket([]byte(bkChatSearch)) + if b == nil { + return nil + } + var prev []string + if err == nil && histTxGetJSON(b, histToksKey(id), &prev) { + for _, t := range prev { + if err := histUnpost(b, t, id, encode); err != nil { + return err + } + } + } + if err := b.Delete([]byte(histToksKey(id))); err != nil { + return err + } + return b.Delete([]byte(histDocKey(id))) + }) + }) +} + +// histIndexWritten indexe les instantanés que l'écrivain vient de poser +// (chat_persist.go). Appelé DANS la boucle de l'écrivain, avant qu'elle ne +// marque le lot comme traité : un flush attend donc aussi l'index — c'est ce +// qui garantit qu'une suppression (forget, flush, puis histUnindexAt) passe +// toujours après la dernière indexation de la discussion. +func histIndexWritten(snaps []*convSnap) { + for _, s := range snaps { + if log, ok := histLogOf(s.body); ok { + _ = histIndexAt(s.path, s.id, log) // erreur avalée : le rattrapage reprendra + } + } +} + +// histIndexStoreWrite suit les écritures de la façade dans /internal/v1/store : +// une discussion écrite (bkChat/conv:) est réindexée, une discussion +// effacée (DELETE, ou valeur vide) est retirée. Le contenu arrive scellé : +// decodeMemContent l'ouvre, la clé ne quitte pas ce process. Synchrone : la +// façade n'envoie la suppression d'une discussion qu'après la réponse à son +// écriture, l'ordre est donc tenu sans file. +func histIndexStoreWrite(bucket, key string, val []byte, deleted bool) { + if bucket != bkChat || !strings.HasPrefix(key, "conv:") { + return + } + id := strings.TrimPrefix(key, "conv:") + if deleted || len(val) == 0 { + histUnindexAt(dbPath(), id) + return + } + plain, err := decodeMemContent(val) + if err != nil { + return + } + if log, ok := histLogOf(plain); ok { + _ = histIndexAt(dbPath(), id, log) + } +} + +// --- rattrapage --------------------------------------------------------------- + +var histBackfillRunning atomic.Bool + +// startHistSearchBackfill lance le rattrapage en tâche de fond, au démarrage +// (après un délai : laisser moteur et lien démarrer d'abord) et au +// déverrouillage de la mémoire. Un seul à la fois. +func startHistSearchBackfill() { startHistSearchBackfillAfter(20 * time.Second) } + +func startHistSearchBackfillAfter(d time.Duration) { + if !histBackfillRunning.CompareAndSwap(false, true) { + return + } + path := dbPath() + go func() { + defer histBackfillRunning.Store(false) + time.Sleep(d) + // La base visée au lancement, et elle seule : sous les tests, LOKI_HOME + // change d'un test à l'autre — on ne rattrape pas dans la base d'un autre, + // ni ne recrée celle d'un test terminé. + if _, err := os.Stat(path); err != nil || dbPath() != path { + return + } + histSearchIndexBackfill() + }() +} + +// histNeedsIndex : la discussion manque-t-elle à l'index, ou y est-elle plus +// ancienne que sa dernière écriture (une indexation ratée après l'écriture) ? +func histNeedsIndex(docs map[string]histSearchDoc, id string, updated int64) bool { + d, ok := docs[id] + return !ok || d.V != histSearchIndexVersion || d.At < updated +} + +// histReadDocs lit toutes les fiches « d| » lisibles (petites : version, date, +// aperçu). +func histReadDocs() map[string]histSearchDoc { + out := map[string]histSearchDoc{} + _ = view(bkChatSearch, func(b *bolt.Bucket) error { + c := b.Cursor() + pfx := []byte("d|") + for k, v := c.Seek(pfx); k != nil && bytes.HasPrefix(k, pfx); k, v = c.Next() { + plain, err := decodeMemContent(v) + if err != nil { + continue + } + var d histSearchDoc + if json.Unmarshal(plain, &d) == nil { + out[string(k[len(pfx):])] = d + } + } + return nil + }) + return out +} + +// histConvIDs : identifiants des discussions qui ont un CORPS en base (clés +// « conv: » de bkChat), sans lire les valeurs. +func histConvIDs() map[string]bool { + out := map[string]bool{} + _ = view(bkChat, func(b *bolt.Bucket) error { + c := b.Cursor() + pfx := []byte("conv:") + for k, _ := c.Seek(pfx); k != nil && bytes.HasPrefix(k, pfx); k, _ = c.Next() { + out[string(k[len(pfx):])] = true + } + return nil + }) + return out +} + +// histSearchIndexBackfill indexe les discussions absentes de l'index, ou dont +// la fiche est plus ancienne que leur dernière écriture. S'arrête net +// verrouillé (corps illisibles) : il reprendra au déverrouillage. +func histSearchIndexBackfill() { + if memLocked() { + return + } + updated := map[string]int64{} + for _, m := range convIndex() { + updated[m.ID] = m.Updated + } + docs := histReadDocs() + ids := make([]string, 0) + for id := range histConvIDs() { + if histNeedsIndex(docs, id, updated[id]) { + ids = append(ids, id) + } + } + sort.Strings(ids) + for _, id := range ids { + b, err := getStoreBytesErr(bkChat, convKey(id)) + if err != nil { + if memLocked() { + return + } + continue // illisible pour une autre raison (abîmée) : on saute + } + log, ok := histLogOf(b) + if !ok { + continue + } + if err := histIndexAt(dbPath(), id, log); err != nil { + return + } + time.Sleep(30 * time.Millisecond) // ne pas monopoliser la base + } +} + +// --- recherche ---------------------------------------------------------------- + +// histSearchProject : le projet dont la barre latérale montre les discussions. +// Lu en base et non dans le cache de ce process : quand la façade tient la +// conversation, c'est elle qui change de projet. +func histSearchProject() string { + if s := strings.TrimSpace(getStr(bkState, stActiveProjectKey)); s != "" { + return s + } + return defaultProjectSlug +} + +// histSearch est le cœur de la recherche, sur les discussions du projet +// `project` ("" = projet actif). Pertinence d'abord (score = 2 × termes du titre +// + termes du texte, même ratio que recall_search), puis plus récente, puis +// identifiant. offset/limit découpent APRÈS le tri ; limit <= 0 = tout. +func histSearch(q, project string, offset, limit int) histSearchResult { + res := histSearchResult{State: "ok", Hits: make([]histSearchHit, 0), Terms: make([]string, 0)} + terms := histQueryTerms(q) + if len(terms) == 0 { + res.State = "no_terms" + return res + } + // Verrouillé décidé AVANT toute lecture : « illisible » et « absent » se + // confondent au fond. Pour une recherche, un vide serait un mensonge. + if memLocked() { + res.State = "locked" + return res + } + res.Terms = terms + if project == "" { + project = histSearchProject() + } + metaByID := map[string]convMeta{} + for _, m := range convIndex() { + if convProjectOf(m) == project { + metaByID[m.ID] = m + } + } + + // Discussion ouverte : quand CE process la tient, son journal en RAM est + // plus frais que sa dernière écriture (un tour en cours). Copié sous c.mu + // puis relâché : on tokenise hors verrou, jamais en bloquant une génération. + // Quand la façade la tient, le journal d'ici est périmé : l'index (alimenté + // par ses écritures) fait foi. + var liveID string + var liveLog []LogEvent + if !facadeChat() { + liveID = convActiveCached() + conv.mu.Lock() + liveLog = append([]LogEvent(nil), conv.Log...) + conv.mu.Unlock() + if _, ok := metaByID[liveID]; !ok || len(liveLog) == 0 { + liveID, liveLog = "", nil + } + } + + titleHits := map[string]int{} + for id, m := range metaByID { + set := histTokenSet(m.Title) + n := 0 + for _, t := range terms { + if histSetMatches(set, t) { + n++ + } + } + if n > 0 { + titleHits[id] = n + } + } + + // UNE vue sur l'index : postings des termes (scan PRÉFIXE : les clés sont + // triées, toutes celles qui commencent par « p| » se suivent) et + // fiches des discussions du projet (couverture, aperçu). Aucun corps lu. + bodyHits := map[string]int{} + docs := map[string]histSearchDoc{} + _ = view(bkChatSearch, func(b *bolt.Bucket) error { + for _, t := range terms { + prefix := []byte(histPostKey(t)) + matched := map[string]bool{} // un terme ne compte qu'UNE fois par discussion + c := b.Cursor() + for k, v := c.Seek(prefix); k != nil && bytes.HasPrefix(k, prefix); k, v = c.Next() { + plain, err := decodeMemContent(v) + if err != nil { + continue + } + var ids []string + if json.Unmarshal(plain, &ids) != nil { + continue + } + for _, sid := range ids { + matched[sid] = true + } + } + for sid := range matched { + bodyHits[sid]++ + } + } + for id := range metaByID { + var d histSearchDoc + if histTxGetJSON(b, histDocKey(id), &d) && d.V == histSearchIndexVersion { + docs[id] = d + } + } + return nil + }) + + // Discussion ouverte : son journal en RAM REMPLACE sa copie indexée (et non + // +=) — une seule entrée par identifiant, jamais de double compte. + if liveID != "" { + set := histTokenSet(histVisibleText(liveLog)) + n := 0 + for _, t := range terms { + if histSetMatches(set, t) { + n++ + } + } + bodyHits[liveID] = n + } + + score := func(id string) int { return 2*titleHits[id] + bodyHits[id] } + hits := make([]histSearchHit, 0) + for id, m := range metaByID { + if sc := score(id); sc > 0 { + hits = append(hits, histSearchHit{ID: id, Title: m.Title, Created: m.Created, + Updated: m.Updated, Turns: m.Turns, Score: sc, InTitle: titleHits[id] > 0}) + } + } + // Les identifiants des postings absents de l'index des discussions (supprimées + // verrouillé, ou d'un autre projet) ne sont jamais parcourus : seul metaByID l'est. + sort.Slice(hits, func(i, j int) bool { + if hits[i].Score != hits[j].Score { + return hits[i].Score > hits[j].Score + } + if hits[i].Updated != hits[j].Updated { + return hits[i].Updated > hits[j].Updated + } + return hits[i].ID < hits[j].ID + }) + res.Total = len(hits) + if limit > 0 { + off := max(0, min(offset, len(hits))) + hits = hits[off:min(off+limit, len(hits))] + } + histFillSnippets(hits, terms, docs, liveID, liveLog) + res.Hits = hits + + // Couverture : le dénominateur est l'ensemble des discussions RÉELLEMENT + // indexables du projet — une entrée d'index sans corps en base (discussion + // toute neuve, jamais écrite) ne le sera jamais, la compter laisserait + // l'UI annoncer « indexation en cours » pour toujours (constat amont). + // Invariant : Indexed <= IndexedTotal. + bodies := histConvIDs() + for id, m := range metaByID { + if !bodies[id] && id != liveID { + continue + } + res.IndexedTotal++ + if id == liveID { + res.Indexed++ + continue + } + if !histNeedsIndex(docs, id, m.Updated) { + res.Indexed++ + } + } + return res +} + +// histFillSnippets pose l'extrait de chaque résultat de la PAGE : le passage +// autour du premier mot trouvé, relu dans le corps (une seule ouverture de la +// base pour toute la page) ; à défaut — corps illisible, mot trouvé seulement +// dans le titre — l'aperçu de la fiche. Le classement, lui, ne dépend jamais +// des corps. +func histFillSnippets(hits []histSearchHit, terms []string, docs map[string]histSearchDoc, liveID string, liveLog []LogEvent) { + if len(hits) == 0 { + return + } + texts := map[string]string{} + if liveID != "" { + texts[liveID] = histVisibleText(liveLog) + } + _ = view(bkChat, func(b *bolt.Bucket) error { + for _, h := range hits { + if _, ok := texts[h.ID]; ok { + continue + } + raw := b.Get([]byte(convKey(h.ID))) + if raw == nil { + continue + } + plain, err := decodeMemContent(raw) + if err != nil { + continue + } + if log, ok := histLogOf(plain); ok { + texts[h.ID] = histVisibleText(log) + } + } + return nil + }) + for i := range hits { + s := "" + if txt, ok := texts[hits[i].ID]; ok { + s = histContextSnippet(txt, terms) + } + if s == "" { + s = docs[hits[i].ID].Apercu + } + if s == "" && liveID == hits[i].ID { + s = recallSnippet(histFirstVisible(liveLog), 200) + } + hits[i].Snippet = s + } +} + +// handleConvSearch : GET /api/conversations/search?q=&offset=&limit=. +// Discussions du projet actif dont le titre OU le texte contient les mots +// cherchés (préfixes), avec un extrait. Toujours servie par ce process, même +// quand la façade tient la conversation (voir le commentaire de tête). +func handleConvSearch(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + w.Header().Set("Allow", http.MethodGet) + sendJSON(w, http.StatusMethodNotAllowed, map[string]any{"ok": false, "error": "GET attendu"}) + return + } + q := r.URL.Query() + if utf8.RuneCountInString(q.Get("q")) > 500 { + sendJSON(w, http.StatusBadRequest, map[string]any{"ok": false, "error": "requête trop longue"}) + return + } + offset, _ := strconv.Atoi(q.Get("offset")) + limit, err := strconv.Atoi(q.Get("limit")) + if err != nil || limit <= 0 { + limit = histSearchDefaultLimit + } + limit = min(limit, histSearchMaxLimit) + res := histSearch(q.Get("q"), "", max(0, offset), limit) + // Index incomplet (discussions d'avant la fonctionnalité, indexation ratée) : + // on relance le rattrapage plutôt que d'attendre le prochain démarrage. L'UI + // annonce la couverture partielle en attendant. + if res.State == "ok" && res.Indexed < res.IndexedTotal { + startHistSearchBackfillAfter(time.Second) + } + sendJSON(w, http.StatusOK, map[string]any{"ok": true, "state": res.State, "terms": res.Terms, + "conversations": res.Hits, "total": res.Total, + "indexed": res.Indexed, "indexed_total": res.IndexedTotal}) +} diff --git a/internal/loki/chat_search_test.go b/internal/loki/chat_search_test.go new file mode 100644 index 0000000..93094f3 --- /dev/null +++ b/internal/loki/chat_search_test.go @@ -0,0 +1,728 @@ +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: 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) + } +} diff --git a/internal/loki/chat_sessions.go b/internal/loki/chat_sessions.go index 5112574..96f51c2 100644 --- a/internal/loki/chat_sessions.go +++ b/internal/loki/chat_sessions.go @@ -330,6 +330,7 @@ func convDelete(id string) error { convIndexSave(next) convIndexMu.Unlock() _ = putBytes(bkChat, convKey(id), nil) + histUnindexAt(dbPath(), id) // son index plein-texte part avec elle (chat_search.go) // Mode code : les jobs d'arrière-plan de la discussion s'arrêtent avec // elle, et ses critères/mode/puce partent avec ses messages. stopConvJobs(id) diff --git a/internal/loki/mem_store.go b/internal/loki/mem_store.go index d973bd4..44917b7 100644 --- a/internal/loki/mem_store.go +++ b/internal/loki/mem_store.go @@ -163,14 +163,15 @@ func bucketFullyEncrypted(bucket string) bool { // chiffrement est actif. Ici : les discussions (bkChat porte l'index, la // discussion active et le journal de chacune), les blocs archivés au compactage // (bkRecall — du verbatim de conversation, donc aussi sensible que le fil) et -// les trackers (bkTracker, 3ᵉ type de mémoire), et les résultats complets des -// outils gardés pour « voir plus » (bkToolRes — des extraits du fil). +// les trackers (bkTracker, 3ᵉ type de mémoire), les résultats complets des +// outils gardés pour « voir plus » (bkToolRes — des extraits du fil) et l'index +// plein-texte des discussions (bkChatSearch — leurs mots, chat_search.go). // // Les autres buckets (config, prefs, state, tasks) restent EN CLAIR : ils // portent des réglages, pas des données personnelles — et surtout le coffre // lui-même vit dans bkState. Le chiffrer avec la DEK qu'il protège ferait une // boucle : plus moyen de déverrouiller quoi que ce soit. -var encryptedBuckets = []string{bkChat, bkRecall, bkTracker, bkToolRes} +var encryptedBuckets = []string{bkChat, bkRecall, bkTracker, bkToolRes, bkChatSearch} // reencryptChatStores (re)chiffre les buckets de conversation. Exige la DEK. func reencryptChatStores() error { diff --git a/internal/loki/mem_vault.go b/internal/loki/mem_vault.go index b318300..8f0c7c9 100644 --- a/internal/loki/mem_vault.go +++ b/internal/loki/mem_vault.go @@ -79,6 +79,9 @@ func memUnlocked() bool { return memDEK != nil } +// memLocked : mémoire chiffrée MAIS verrouillée (DEK absente). +func memLocked() bool { return memEncActive() && !memUnlocked() } + // setMemDEK charge la DEK en RAM (copie défensive). func setMemDEK(dek []byte) { memKeyMu.Lock() diff --git a/internal/loki/store.go b/internal/loki/store.go index 46bf301..ec014c3 100644 --- a/internal/loki/store.go +++ b/internal/loki/store.go @@ -42,6 +42,11 @@ const ( // bkTracker : les trackers, 3e type de mémoire (tracker.go). Clé // « / » — le cloisonnement par projet est dans la clé. bkTracker = "tracker" + // bkChatSearch : index PLEIN-TEXTE des discussions (chat_search.go) — fiche + // et liste de mots par discussion, liste de discussions par mot. Chiffré + // comme bkChat : un sac de mots en clair à côté de discussions chiffrées + // serait une fuite. + bkChatSearch = "chatsearch" ) // La base n'est PAS gardée ouverte entre deux opérations, et c'est délibéré. diff --git a/internal/loki/web_api.go b/internal/loki/web_api.go index f08b0ac..4c17351 100644 --- a/internal/loki/web_api.go +++ b/internal/loki/web_api.go @@ -951,6 +951,7 @@ func handleMemUnlock(w http.ResponseWriter, r *http.Request) { if memEncActive() && memUnlocked() { _ = reencryptChatStores() } + startHistSearchBackfill() // index plein-texte : il attendait la clé (chat_search.go) sendJSON(w, 200, map[string]any{"ok": true, "kind": kind, "health": memHealth()}) } diff --git a/internal/loki/web_internal.go b/internal/loki/web_internal.go index dc78969..8da6159 100644 --- a/internal/loki/web_internal.go +++ b/internal/loki/web_internal.go @@ -228,6 +228,10 @@ func handleInternalStore(w http.ResponseWriter, r *http.Request) { sendJSON(w, http.StatusServiceUnavailable, map[string]any{"error": err.Error()}) return } + // Une discussion écrite par la façade : l'index plein-texte la suit + // (chat_search.go). Avant la réponse, pour que sa suppression, qui + // n'arrive qu'après, ne soit jamais doublée par cette indexation. + histIndexStoreWrite(bucket, key, val, false) w.WriteHeader(http.StatusNoContent) case http.MethodDelete: err := update(bucket, func(b *bolt.Bucket) error { return b.Delete([]byte(key)) }) @@ -235,6 +239,7 @@ func handleInternalStore(w http.ResponseWriter, r *http.Request) { sendJSON(w, http.StatusServiceUnavailable, map[string]any{"error": err.Error()}) return } + histIndexStoreWrite(bucket, key, nil, true) w.WriteHeader(http.StatusNoContent) default: w.Header().Set("Allow", "GET, PUT, DELETE") diff --git a/internal/loki/web_server.go b/internal/loki/web_server.go index 94aac50..1de625f 100644 --- a/internal/loki/web_server.go +++ b/internal/loki/web_server.go @@ -163,6 +163,9 @@ func newWebMux() *http.ServeMux { // orphelin arrêté, moteur relancé s'il tournait avant (backend_tune_lock.go) // — au démarrage, puis toutes les 30 s (un stat sans verrou). go tuneRecoverWatch(time.NewTicker(tuneRecoverEvery).C) + // Index plein-texte des discussions : rattrape celles d'avant la + // fonctionnalité (ou dont l'indexation a échoué), en tâche de fond. + startHistSearchBackfill() mux := http.NewServeMux() // Pages publiques : le HTML et le JS ne contiennent aucun secret. Toute la // donnée et toutes les actions passent par /api/* qui, lui, exige la clé. @@ -399,6 +402,9 @@ func newWebMux() *http.ServeMux { api("/api/conversations/switch", handleConvSwitch) api("/api/conversations/rename", handleConvRename) api("/api/conversations/delete", handleConvDelete) + // Recherche plein-texte (chat_search.go) : servie ici même quand la façade + // tient la conversation — l'index est tenu par ce process. + api("/api/conversations/search", handleConvSearch) api("/api/chat/mode", handleChatMode) // GET mode+critères / POST bascule Chat|Code api("/api/chat/criteria", handleChatCriteria) // édition manuelle des critères (mode code) api("/api/chat/compact", handleChatCompact) // compaction manuelle du contexte diff --git a/rust/loki-rs/src/chatapi.rs b/rust/loki-rs/src/chatapi.rs index d65a37e..aebed55 100644 --- a/rust/loki-rs/src/chatapi.rs +++ b/rust/loki-rs/src/chatapi.rs @@ -499,4 +499,11 @@ mod tests { ); assert!(is_internal("/internal/facade/v1/delta") && !is_internal("/internal/v1/auth")); } + + /// La recherche plein-texte reste au Go, qui tient l'index (chat_search.go) : + /// la façade la relaie, elle ne doit pas la servir. + #[test] + fn recherche_relayee_au_go() { + assert!(!handles("/api/conversations/search")); + } }