SRC-03: such-api-mit-ranking
Such-API mit Ranking (Relevanz, Datum, Anhangstreffer), mandantengetrennt,
mit Grundoperatoren (Phrase, Ausschluss).
- client.go: Search nutzt jetzt Manticores query_string-Klausel statt
match — unterstützt Phrasensuche ("...") und Ausschluss (-wort) nativ,
Wert bleibt reiner JSON-String ohne dynamischen Feldnamen.
- fieldWeights (statische Konstanten: subject=10, body=3,
attachment_text=1) über die Manticore-Option field_weights — Ranking
berücksichtigt Anhangstreffer, Result.Score macht es nachvollziehbar.
- Bestehenden SRC-01-Injection-Test an die neue query_string-Struktur
angepasst (gleiche Funktion weiterentwickelt).
Prüfungen (alle real durchgeführt, siehe mail/docs/SRC-03-PRUEFPROTOKOLL.md):
1. TestSearch_TenantIsolation (SRC-01, weiterhin gültig).
2. TestSearch_PhraseAndExclusionOperators: Phrase und Ausschluss liefern
real erwartete Teilmengen.
3. TestSearch_PerformanceWithLargeCorpus: Suche über 1000 reale Dokumente
in 775,8µs (Ziel 500ms) gegen echtes Manticore auf 192.168.1.131.
Zusätzlich TestSearch_RankingReflectsFieldWeightAndIsTraceable für
Akzeptanzkriterium 1.
Kein Umbau: dedup/indexworker/storage/crypto/encstorage unverändert.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HhgFcLS8tYMhDJpP74C6AQ
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
c5bdde0cc8
commit
9748307f12
@@ -139,27 +139,47 @@ func (c *Client) Delete(ctx context.Context, id uint64) error {
|
||||
|
||||
// Result ist ein Suchtreffer.
|
||||
type Result struct {
|
||||
MessageID string
|
||||
Subject string
|
||||
MessageID string
|
||||
Subject string
|
||||
Score int64
|
||||
SentAtUnixEpoch int64
|
||||
}
|
||||
|
||||
// fieldWeights gewichtet Betreff höher als Text, Anhangstext am
|
||||
// niedrigsten (SRC-03 Akzeptanzkriterium 1: Ranking berücksichtigt u.a.
|
||||
// Anhangstreffer) — statische Konstanten, keine dynamischen Feldnamen.
|
||||
var fieldWeights = map[string]any{
|
||||
FieldSubject: 10,
|
||||
FieldBody: 3,
|
||||
FieldAttachmentText: 1,
|
||||
}
|
||||
|
||||
// Search sucht queryText innerhalb der Volltextfelder, strikt begrenzt auf
|
||||
// den Mandanten tenantSlug (Akzeptanzkriterium 3: mandantengetrennt
|
||||
// den Mandanten tenantSlug (Akzeptanzkriterium 2: mandantengetrennt
|
||||
// abfragbar) — der Tenant-Filter läuft über ein strukturiertes "equals"-
|
||||
// Match-Feld der JSON-API, niemals über eine interpolierte WHERE-Klausel.
|
||||
// Feld der JSON-API, niemals über eine interpolierte WHERE-Klausel.
|
||||
//
|
||||
// queryText nutzt Manticores erweiterte Abfragesyntax über den
|
||||
// query_string-Klausel-Typ (Akzeptanzkriterium 3: Phrasensuche mit
|
||||
// Anführungszeichen, Ausschluss mit vorangestelltem "-") — der Wert landet
|
||||
// als reiner JSON-String-Wert, es gibt dabei keinerlei dynamischen
|
||||
// Feld-/Tabellennamen, der beeinflusst werden könnte. Ergebnisse kommen
|
||||
// von Manticore bereits nach Relevanz (BM25, gewichtet über fieldWeights)
|
||||
// absteigend sortiert zurück (Akzeptanzkriterium 1).
|
||||
func (c *Client) Search(ctx context.Context, tenantSlug, queryText string) ([]Result, error) {
|
||||
matchFields := strings.Join(searchableTextFields, ",")
|
||||
|
||||
payload := map[string]any{
|
||||
"index": IndexName,
|
||||
"query": map[string]any{
|
||||
"bool": map[string]any{
|
||||
"must": []map[string]any{
|
||||
{"equals": map[string]any{FieldTenantSlug: tenantSlug}},
|
||||
{"match": map[string]any{matchFields: queryText}},
|
||||
{"query_string": queryText},
|
||||
},
|
||||
},
|
||||
},
|
||||
"options": map[string]any{
|
||||
"field_weights": fieldWeights,
|
||||
},
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
@@ -193,8 +213,10 @@ func (c *Client) Search(ctx context.Context, tenantSlug, queryText string) ([]Re
|
||||
results := make([]Result, 0, len(parsed.Hits.Hits))
|
||||
for _, hit := range parsed.Hits.Hits {
|
||||
results = append(results, Result{
|
||||
MessageID: hit.Source.MessageID,
|
||||
Subject: hit.Source.Subject,
|
||||
MessageID: hit.Source.MessageID,
|
||||
Subject: hit.Source.Subject,
|
||||
Score: hit.Score,
|
||||
SentAtUnixEpoch: hit.Source.SentAtUnixEpoch,
|
||||
})
|
||||
}
|
||||
return results, nil
|
||||
@@ -203,9 +225,11 @@ func (c *Client) Search(ctx context.Context, tenantSlug, queryText string) ([]Re
|
||||
type searchResponse struct {
|
||||
Hits struct {
|
||||
Hits []struct {
|
||||
Score int64 `json:"_score"`
|
||||
Source struct {
|
||||
MessageID string `json:"message_id"`
|
||||
Subject string `json:"subject"`
|
||||
MessageID string `json:"message_id"`
|
||||
Subject string `json:"subject"`
|
||||
SentAtUnixEpoch int64 `json:"sent_at"`
|
||||
} `json:"_source"`
|
||||
} `json:"hits"`
|
||||
} `json:"hits"`
|
||||
|
||||
@@ -59,15 +59,14 @@ func TestSearch_MaliciousInputDoesNotAlterFieldNames(t *testing.T) {
|
||||
t.Fatalf("wert wurde verändert: %v", equalsClause[FieldTenantSlug])
|
||||
}
|
||||
|
||||
matchClause, ok := must[1].(map[string]any)["match"].(map[string]any)
|
||||
// query_string hat keinerlei dynamischen Feldnamen — der Klausel-Wert
|
||||
// ist unmittelbar der übergebene String, keine map mit datenabhängigem
|
||||
// Schlüssel möglich.
|
||||
queryStringClause, ok := must[1].(map[string]any)["query_string"]
|
||||
if !ok {
|
||||
t.Fatal("erwartete 'match'-Klausel fehlt")
|
||||
t.Fatal("erwartete 'query_string'-Klausel fehlt")
|
||||
}
|
||||
expectedMatchKey := "subject,body,attachment_text"
|
||||
if _, hasStaticKey := matchClause[expectedMatchKey]; !hasStaticKey {
|
||||
t.Fatalf("erwarteter statischer match-feld-schlüssel %q nicht gefunden, habe: %v", expectedMatchKey, matchClause)
|
||||
}
|
||||
if matchClause[expectedMatchKey] != maliciousQuery {
|
||||
t.Fatalf("suchwert wurde verändert: %v", matchClause[expectedMatchKey])
|
||||
if queryStringClause != maliciousQuery {
|
||||
t.Fatalf("suchwert wurde verändert: %v", queryStringClause)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,10 +26,6 @@ const (
|
||||
FieldSentAt = "sent_at"
|
||||
)
|
||||
|
||||
// searchableTextFields sind die Volltextfelder, über die eine Suchanfrage
|
||||
// läuft (Akzeptanzprüfung 3: Volltextsuche liefert erwartete Treffer).
|
||||
var searchableTextFields = []string{FieldSubject, FieldBody, FieldAttachmentText}
|
||||
|
||||
// DocumentID berechnet deterministisch die Manticore-Dokument-ID aus
|
||||
// Mandant und Message-ID (FNV-1a, 64 Bit). Deterministisch statt einer
|
||||
// separat vergebenen ID, damit Re-Indexierung (Index) und Löschung
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
// Integrationstest (SRC-03): echte Manticore-Instanz, TEST_MANTICORE_URL
|
||||
// (gleiche Konvention wie integration_test.go).
|
||||
package search
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// TestSearch_RankingReflectsFieldWeightAndIsTraceable ist die geforderte
|
||||
// Funktionsprüfung zu Akzeptanzkriterium 1: Treffer sind nach Relevanz
|
||||
// sortiert, und das Ranking ist nachvollziehbar — ein Treffer im höher
|
||||
// gewichteten Betrefffeld liegt vor einem Treffer, der den Suchbegriff
|
||||
// nur im niedriger gewichteten Anhangstext enthält.
|
||||
func TestSearch_RankingReflectsFieldWeightAndIsTraceable(t *testing.T) {
|
||||
client := setupClient(t)
|
||||
ctx := context.Background()
|
||||
tenant := "mandant-src03-ranking"
|
||||
|
||||
if err := client.Index(ctx, Document{
|
||||
ID: DocumentID(tenant, "msg-ranking-subject"),
|
||||
TenantSlug: tenant,
|
||||
MessageID: "msg-ranking-subject",
|
||||
Subject: "Vertragsentwurf",
|
||||
Body: "siehe Anhang",
|
||||
AttachmentText: "",
|
||||
SentAtUnixEpoch: 1000,
|
||||
}); err != nil {
|
||||
t.Fatalf("index: %v", err)
|
||||
}
|
||||
if err := client.Index(ctx, Document{
|
||||
ID: DocumentID(tenant, "msg-ranking-attachment"),
|
||||
TenantSlug: tenant,
|
||||
MessageID: "msg-ranking-attachment",
|
||||
Subject: "Wochenrückblick",
|
||||
Body: "allgemeine Notizen",
|
||||
AttachmentText: "Im Anhang findet sich ein Vertragsentwurf zur Prüfung.",
|
||||
SentAtUnixEpoch: 2000,
|
||||
}); err != nil {
|
||||
t.Fatalf("index: %v", err)
|
||||
}
|
||||
|
||||
results, err := client.Search(ctx, tenant, "Vertragsentwurf")
|
||||
if err != nil {
|
||||
t.Fatalf("search: %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("erwartete 2 treffer, habe %d: %+v", len(results), results)
|
||||
}
|
||||
if results[0].MessageID != "msg-ranking-subject" {
|
||||
t.Fatalf("erwartete höher gewichteten betreff-treffer zuerst, habe: %+v", results)
|
||||
}
|
||||
if results[0].Score <= results[1].Score {
|
||||
t.Fatalf("erwartete nachvollziehbar höheren score für betreff-treffer: %+v", results)
|
||||
}
|
||||
}
|
||||
|
||||
// TestSearch_PhraseAndExclusionOperators ist die geforderte
|
||||
// Pflichtprüfung 2: Phrasensuche und Ausschlussoperator liefern erwartete
|
||||
// Teilmengen.
|
||||
func TestSearch_PhraseAndExclusionOperators(t *testing.T) {
|
||||
client := setupClient(t)
|
||||
ctx := context.Background()
|
||||
tenant := "mandant-src03-operatoren"
|
||||
|
||||
docs := []Document{
|
||||
{MessageID: "msg-op-umsatz-verlust", Subject: "Umsatz Verlust", Body: "Verlust im dritten Quartal, kein Umsatzwachstum"},
|
||||
{MessageID: "msg-op-umsatz-nur", Subject: "Quartalsbericht Umsatz", Body: "hoher Umsatz im dritten Quartal"},
|
||||
{MessageID: "msg-op-anderes-thema", Subject: "Betriebsausflug", Body: "Planung für den nächsten Betriebsausflug"},
|
||||
}
|
||||
for _, d := range docs {
|
||||
d.TenantSlug = tenant
|
||||
d.ID = DocumentID(tenant, d.MessageID)
|
||||
if err := client.Index(ctx, d); err != nil {
|
||||
t.Fatalf("index %s: %v", d.MessageID, err)
|
||||
}
|
||||
}
|
||||
|
||||
phraseResults, err := client.Search(ctx, tenant, `"dritten Quartal"`)
|
||||
if err != nil {
|
||||
t.Fatalf("phrasensuche: %v", err)
|
||||
}
|
||||
phraseIDs := messageIDSet(phraseResults)
|
||||
if !phraseIDs["msg-op-umsatz-verlust"] || !phraseIDs["msg-op-umsatz-nur"] {
|
||||
t.Fatalf("erwartete beide 'dritten Quartal'-treffer, habe: %+v", phraseResults)
|
||||
}
|
||||
if phraseIDs["msg-op-anderes-thema"] {
|
||||
t.Fatalf("unerwarteter treffer ohne die phrase: %+v", phraseResults)
|
||||
}
|
||||
|
||||
exclusionResults, err := client.Search(ctx, tenant, "Umsatz -Verlust")
|
||||
if err != nil {
|
||||
t.Fatalf("ausschlusssuche: %v", err)
|
||||
}
|
||||
exclusionIDs := messageIDSet(exclusionResults)
|
||||
if !exclusionIDs["msg-op-umsatz-nur"] {
|
||||
t.Fatalf("erwarteter treffer ohne 'Verlust' fehlt: %+v", exclusionResults)
|
||||
}
|
||||
if exclusionIDs["msg-op-umsatz-verlust"] {
|
||||
t.Fatalf("mit -Verlust ausgeschlossener treffer erschien dennoch: %+v", exclusionResults)
|
||||
}
|
||||
}
|
||||
|
||||
func messageIDSet(results []Result) map[string]bool {
|
||||
set := make(map[string]bool, len(results))
|
||||
for _, r := range results {
|
||||
set[r.MessageID] = true
|
||||
}
|
||||
return set
|
||||
}
|
||||
|
||||
// TestSearch_PerformanceWithLargeCorpus ist die geforderte Pflichtprüfung
|
||||
// 3: Performance-Test mit großem Testkorpus bleibt innerhalb Zielzeit.
|
||||
// Zielzeit 500ms für eine Suche über 1000 indexierte Dokumente — großzügig
|
||||
// für die "kleinste Lösung", deckt aber real ab, dass die Suche nicht
|
||||
// linear mit der Korpusgröße spürbar einbricht.
|
||||
func TestSearch_PerformanceWithLargeCorpus(t *testing.T) {
|
||||
if os.Getenv("TEST_MANTICORE_URL") == "" {
|
||||
t.Skip("TEST_MANTICORE_URL nicht gesetzt, Integrationstest übersprungen")
|
||||
}
|
||||
client := setupClient(t)
|
||||
ctx := context.Background()
|
||||
tenant := "mandant-src03-performance"
|
||||
|
||||
const corpusSize = 1000
|
||||
for i := 0; i < corpusSize; i++ {
|
||||
messageID := fmt.Sprintf("msg-perf-%d", i)
|
||||
subject := "Alltägliche Nachricht"
|
||||
if i == corpusSize/2 {
|
||||
subject = "Einzigartiges Suchziel Zylotharion"
|
||||
}
|
||||
if err := client.Index(ctx, Document{
|
||||
ID: DocumentID(tenant, messageID),
|
||||
TenantSlug: tenant,
|
||||
MessageID: messageID,
|
||||
Subject: subject,
|
||||
Body: "Routinetext ohne besonderen Inhalt für Testzwecke.",
|
||||
SentAtUnixEpoch: int64(i),
|
||||
}); err != nil {
|
||||
t.Fatalf("index %s: %v", messageID, err)
|
||||
}
|
||||
}
|
||||
|
||||
const targetLatency = 500 * time.Millisecond
|
||||
start := time.Now()
|
||||
results, err := client.Search(ctx, tenant, "Zylotharion")
|
||||
elapsed := time.Since(start)
|
||||
if err != nil {
|
||||
t.Fatalf("search: %v", err)
|
||||
}
|
||||
if elapsed > targetLatency {
|
||||
t.Fatalf("suche über %d dokumente dauerte %s, ziel war %s", corpusSize, elapsed, targetLatency)
|
||||
}
|
||||
found := false
|
||||
for _, r := range results {
|
||||
if r.MessageID == fmt.Sprintf("msg-perf-%d", corpusSize/2) {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("erwartetes einzigartiges dokument im großen korpus nicht gefunden")
|
||||
}
|
||||
t.Logf("Suche über %d Dokumente: %s (Ziel %s)", corpusSize, elapsed, targetLatency)
|
||||
}
|
||||
Reference in New Issue
Block a user