mirror of
https://github.com/R0m1k3/Loki.git
synced 2026-10-22 14:38:12 +02:00
Discussions : la recherche trouve aussi les mots du texte, pas seulement les titres (index plein-texte chiffré, repris d'AJEAN 558d3b4d, #98)
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>
This commit is contained in:
1 parent
6a45f19831
commit
e9c0555ab7
12 files changed
+1623
-3
No files matched your search
@@ -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 {
|
||||
|
||||
@@ -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|<id> » → 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|<id> » → []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|<token> » → []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|<terme> »).
|
||||
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:<id>) 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:<id> » 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|<terme> » 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})
|
||||
}
|
||||
@@ -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:<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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -42,6 +42,11 @@ const (
|
||||
// bkTracker : les trackers, 3e type de mémoire (tracker.go). Clé
|
||||
// « <slug-projet>/<slug-tracker> » — 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é.
|
||||
|
||||
@@ -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()})
|
||||
}
|
||||
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user