Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9748307f12 |
@@ -0,0 +1,61 @@
|
|||||||
|
# SRC-03 – Prüfprotokoll: Such-API mit Ranking
|
||||||
|
|
||||||
|
Voraussetzung SRC-01 (Fertig).
|
||||||
|
|
||||||
|
## Umsetzung
|
||||||
|
|
||||||
|
- `mail/internal/search/client.go` — `Search` intern auf Manticores
|
||||||
|
`query_string`-Klausel umgestellt (statt `match`): unterstützt
|
||||||
|
Grundoperatoren nativ (Phrase in Anführungszeichen, Ausschluss mit `-`,
|
||||||
|
Akzeptanzkriterium 3). Der Wert landet unmittelbar als JSON-String,
|
||||||
|
keine dynamischen Feldnamen möglich (sogar strikter als das vorherige
|
||||||
|
`match`-Muster mit kommagetrenntem Feld-Schlüssel).
|
||||||
|
- `fieldWeights` (statische Konstanten: `subject`=10, `body`=3,
|
||||||
|
`attachment_text`=1) über die Manticore-Option `field_weights` — Ranking
|
||||||
|
berücksichtigt Relevanz UND Anhangstreffer (Akzeptanzkriterium 1).
|
||||||
|
Manticore liefert Treffer standardmäßig absteigend nach BM25-Score
|
||||||
|
sortiert zurück; `Result.Score` macht das Ranking nachvollziehbar.
|
||||||
|
- `Result` um `Score` und `SentAtUnixEpoch` erweitert (Datum als weiterer
|
||||||
|
Rankingfaktor gemäß Ticketbeschreibung verfügbar).
|
||||||
|
- Tenant-Trennung (Akzeptanzkriterium 2) unverändert über das strukturierte
|
||||||
|
`equals`-Feld aus SRC-01.
|
||||||
|
- Bestehenden SRC-01-Test `TestSearch_MaliciousInputDoesNotAlterFieldNames`
|
||||||
|
an die neue `query_string`-Struktur angepasst (gleiche Funktion
|
||||||
|
weiterentwickelt, kein Umbau angrenzender Bereiche).
|
||||||
|
- Kein Umbau: `mail/internal/dedup`/`mail/internal/indexworker`/
|
||||||
|
`mail/internal/storage`/`mail/internal/crypto`/`mail/internal/encstorage`
|
||||||
|
unverändert.
|
||||||
|
|
||||||
|
## Prüfungen
|
||||||
|
|
||||||
|
| # | Prüfung | Ergebnis |
|
||||||
|
|---|---|---|
|
||||||
|
| 1 | Test: Suche eines Mandanten liefert keine Treffer eines anderen Mandanten | **bestanden** – `TestSearch_TenantIsolation` (SRC-01, weiterhin gültig gegen die neue Search-Implementierung) |
|
||||||
|
| 2 | Test: Phrasensuche und Ausschlussoperator liefern erwartete Teilmengen | **bestanden** – `TestSearch_PhraseAndExclusionOperators`: `"dritten Quartal"` liefert real genau die beiden Dokumente mit dieser Phrase, `Umsatz -Verlust` schließt real das "Verlust"-Dokument aus |
|
||||||
|
| 3 | Performance-Test mit großem Testkorpus bleibt innerhalb Zielzeit | **bestanden** – `TestSearch_PerformanceWithLargeCorpus`: 1000 reale Dokumente indexiert, Suche nach eindeutigem Begriff in 775,8µs (Ziel 500ms) gegen echtes Manticore auf 192.168.1.131 |
|
||||||
|
|
||||||
|
Zusätzlich (Akzeptanzkriterium 1, Ranking-Nachvollziehbarkeit):
|
||||||
|
`TestSearch_RankingReflectsFieldWeightAndIsTraceable` — ein Treffer im
|
||||||
|
Betreff liegt real vor einem gleichlautenden Treffer nur im Anhangstext,
|
||||||
|
mit real höherem Score.
|
||||||
|
|
||||||
|
## Build/Test-Ergebnis (192.168.1.131)
|
||||||
|
|
||||||
|
```
|
||||||
|
go build ./... -> clean
|
||||||
|
go vet ./... -> clean
|
||||||
|
golangci-lint run ./... -> 0 issues
|
||||||
|
TEST_TENANT_DSN=postgresql://nexarch_test:***@localhost:5432/tenant_acme?sslmode=disable \
|
||||||
|
TEST_MANTICORE_URL=http://127.0.0.1:9308 \
|
||||||
|
go test ./... -v -p 1 -> alle Pakete bestanden, inkl. internal/search (7 Tests,
|
||||||
|
keine Regression in dedup/indexworker/storage/encstorage/example/mimeparse/pflichttestgate)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Gesamtergebnis
|
||||||
|
|
||||||
|
**Bestanden.** Alle drei Akzeptanzkriterien und alle drei Pflichtprüfungen
|
||||||
|
real erfüllt. SRC-03 ist der nächste Schritt in der Suche-Foundation-Kette
|
||||||
|
(Index → Befüllung → abfragbare Such-API mit belastbarem Ranking), nicht
|
||||||
|
nur eine nette Ergänzung — ohne ihn bliebe der Index nur intern befüllt,
|
||||||
|
ohne nutzbare Relevanzsortierung und Suchoperatoren. Entsperrt INT-01,
|
||||||
|
SRC-04, SRC-05, SRC-08.
|
||||||
@@ -139,27 +139,47 @@ func (c *Client) Delete(ctx context.Context, id uint64) error {
|
|||||||
|
|
||||||
// Result ist ein Suchtreffer.
|
// Result ist ein Suchtreffer.
|
||||||
type Result struct {
|
type Result struct {
|
||||||
MessageID string
|
MessageID string
|
||||||
Subject 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
|
// 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"-
|
// 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) {
|
func (c *Client) Search(ctx context.Context, tenantSlug, queryText string) ([]Result, error) {
|
||||||
matchFields := strings.Join(searchableTextFields, ",")
|
|
||||||
|
|
||||||
payload := map[string]any{
|
payload := map[string]any{
|
||||||
"index": IndexName,
|
"index": IndexName,
|
||||||
"query": map[string]any{
|
"query": map[string]any{
|
||||||
"bool": map[string]any{
|
"bool": map[string]any{
|
||||||
"must": []map[string]any{
|
"must": []map[string]any{
|
||||||
{"equals": map[string]any{FieldTenantSlug: tenantSlug}},
|
{"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)
|
body, err := json.Marshal(payload)
|
||||||
if err != nil {
|
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))
|
results := make([]Result, 0, len(parsed.Hits.Hits))
|
||||||
for _, hit := range parsed.Hits.Hits {
|
for _, hit := range parsed.Hits.Hits {
|
||||||
results = append(results, Result{
|
results = append(results, Result{
|
||||||
MessageID: hit.Source.MessageID,
|
MessageID: hit.Source.MessageID,
|
||||||
Subject: hit.Source.Subject,
|
Subject: hit.Source.Subject,
|
||||||
|
Score: hit.Score,
|
||||||
|
SentAtUnixEpoch: hit.Source.SentAtUnixEpoch,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return results, nil
|
return results, nil
|
||||||
@@ -203,9 +225,11 @@ func (c *Client) Search(ctx context.Context, tenantSlug, queryText string) ([]Re
|
|||||||
type searchResponse struct {
|
type searchResponse struct {
|
||||||
Hits struct {
|
Hits struct {
|
||||||
Hits []struct {
|
Hits []struct {
|
||||||
|
Score int64 `json:"_score"`
|
||||||
Source struct {
|
Source struct {
|
||||||
MessageID string `json:"message_id"`
|
MessageID string `json:"message_id"`
|
||||||
Subject string `json:"subject"`
|
Subject string `json:"subject"`
|
||||||
|
SentAtUnixEpoch int64 `json:"sent_at"`
|
||||||
} `json:"_source"`
|
} `json:"_source"`
|
||||||
} `json:"hits"`
|
} `json:"hits"`
|
||||||
} `json:"hits"`
|
} `json:"hits"`
|
||||||
|
|||||||
@@ -59,15 +59,14 @@ func TestSearch_MaliciousInputDoesNotAlterFieldNames(t *testing.T) {
|
|||||||
t.Fatalf("wert wurde verändert: %v", equalsClause[FieldTenantSlug])
|
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 {
|
if !ok {
|
||||||
t.Fatal("erwartete 'match'-Klausel fehlt")
|
t.Fatal("erwartete 'query_string'-Klausel fehlt")
|
||||||
}
|
}
|
||||||
expectedMatchKey := "subject,body,attachment_text"
|
if queryStringClause != maliciousQuery {
|
||||||
if _, hasStaticKey := matchClause[expectedMatchKey]; !hasStaticKey {
|
t.Fatalf("suchwert wurde verändert: %v", queryStringClause)
|
||||||
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])
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -26,10 +26,6 @@ const (
|
|||||||
FieldSentAt = "sent_at"
|
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
|
// DocumentID berechnet deterministisch die Manticore-Dokument-ID aus
|
||||||
// Mandant und Message-ID (FNV-1a, 64 Bit). Deterministisch statt einer
|
// Mandant und Message-ID (FNV-1a, 64 Bit). Deterministisch statt einer
|
||||||
// separat vergebenen ID, damit Re-Indexierung (Index) und Löschung
|
// 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