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
@@ -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