SRC-08: gespeicherte-suchanfragen
Gespeicherte Suchanfragen: Suchkriterien inklusive aktiver Filter benannt speichern und live wiederausführen. - savedsearch/store.go: Postgres-Store, Save (Upsert über tenant_slug/ user_id/name), List/Get streng auf Mandant+Benutzer beschränkt, Delete entfernt genau eine Zeile. Execute führt jede Ausführung LIVE gegen search.Client aus, kein eingefrorener Snapshot. - search/facets.go: kleinste nötige Erweiterung — Client.SearchWithFilters (gemeinsame buildFilteredMust-Hilfsfunktion mit Facets extrahiert) liefert tatsächlich gefilterte Treffer statt nur Zählungen, sonst gäbe es keinen echten Weg, gespeicherte Filter beim Wiederausführen anzuwenden. Prüfungen (alle real durchgeführt, siehe mail/docs/SRC-08-PRUEFPROTOKOLL.md): 1. TestExecute_SavedSearchWithMultipleFiltersReproducesCorrectly: 2 kombinierte Filter liefern real genau das eine passende Dokument. 2. TestList_UserSeesNoOtherTenantsSavedSearches: Mandant B sieht real keine gespeicherten Suchen von Mandant A. 3. TestDelete_RemovesOnlyThatSavedSearch: Löschen entfernt real nur die eine gespeicherte Suche, die andere bleibt unverändert. Kein Umbau: Search/Index/Delete-Verhalten sonst unverändert, 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
6b5cefc20f
commit
ff4d716b94
@@ -79,12 +79,11 @@ func isFacetField(field string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// Facets berechnet für jede Filterdimension (Akzeptanzkriterium 1) die
|
||||
// Trefferzahl je Wert, mandantengetrennt (Akzeptanzkriterium 3) und unter
|
||||
// Berücksichtigung bereits gewählter Filter (Akzeptanzkriterium 2: mehrere
|
||||
// Filter kombinieren sich als UND-Verknüpfung in derselben bool.must-Liste
|
||||
// wie der Tenant-Filter).
|
||||
func (c *Client) Facets(ctx context.Context, tenantSlug, queryText string, filters []FacetFilter) (FacetResult, error) {
|
||||
// buildFilteredMust baut die gemeinsame bool.must-Liste für Facets und
|
||||
// SearchWithFilters: Tenant-Filter zwingend, optionaler Suchtext, dann je
|
||||
// Filter eine zusätzliche equals-Klausel (UND-Verknüpfung) — einzige
|
||||
// Stelle, an der Filter-Feldnamen gegen FacetFields geprüft werden.
|
||||
func buildFilteredMust(tenantSlug, queryText string, filters []FacetFilter) ([]map[string]any, error) {
|
||||
must := []map[string]any{
|
||||
{"equals": map[string]any{FieldTenantSlug: tenantSlug}},
|
||||
}
|
||||
@@ -93,10 +92,71 @@ func (c *Client) Facets(ctx context.Context, tenantSlug, queryText string, filte
|
||||
}
|
||||
for _, f := range filters {
|
||||
if !isFacetField(f.Field) {
|
||||
return FacetResult{}, fmt.Errorf("search: unbekanntes facettenfeld %q", f.Field)
|
||||
return nil, fmt.Errorf("search: unbekanntes facettenfeld %q", f.Field)
|
||||
}
|
||||
must = append(must, map[string]any{"equals": map[string]any{f.Field: f.Value}})
|
||||
}
|
||||
return must, nil
|
||||
}
|
||||
|
||||
// SearchWithFilters ist Search, zusätzlich beschränkt auf Dokumente, die
|
||||
// ALLE angegebenen Filter erfüllen (UND-Verknüpfung, dieselbe Semantik wie
|
||||
// Facets) — Grundlage für SRC-08s Wiederausführung gespeicherter Suchen
|
||||
// mit Filtern.
|
||||
func (c *Client) SearchWithFilters(ctx context.Context, tenantSlug, queryText string, filters []FacetFilter) ([]Result, error) {
|
||||
if len(filters) == 0 {
|
||||
return c.Search(ctx, tenantSlug, queryText)
|
||||
}
|
||||
must, err := buildFilteredMust(tenantSlug, queryText, filters)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
payload := map[string]any{
|
||||
"index": IndexName,
|
||||
"query": map[string]any{"bool": map[string]any{"must": must}},
|
||||
"options": map[string]any{
|
||||
"field_weights": fieldWeights,
|
||||
},
|
||||
"limit": searchResultLimit,
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("search: gefilterte suchanfrage serialisieren: %w", err)
|
||||
}
|
||||
|
||||
respBody, err := c.doSearchWithSwapRetry(ctx, body)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var parsed searchResponse
|
||||
if err := json.Unmarshal(respBody, &parsed); err != nil {
|
||||
return nil, fmt.Errorf("search: antwort parsen: %w", err)
|
||||
}
|
||||
|
||||
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,
|
||||
Score: hit.Score,
|
||||
SentAtUnixEpoch: hit.Source.SentAtUnixEpoch,
|
||||
})
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// Facets berechnet für jede Filterdimension (Akzeptanzkriterium 1) die
|
||||
// Trefferzahl je Wert, mandantengetrennt (Akzeptanzkriterium 3) und unter
|
||||
// Berücksichtigung bereits gewählter Filter (Akzeptanzkriterium 2: mehrere
|
||||
// Filter kombinieren sich als UND-Verknüpfung in derselben bool.must-Liste
|
||||
// wie der Tenant-Filter).
|
||||
func (c *Client) Facets(ctx context.Context, tenantSlug, queryText string, filters []FacetFilter) (FacetResult, error) {
|
||||
must, err := buildFilteredMust(tenantSlug, queryText, filters)
|
||||
if err != nil {
|
||||
return FacetResult{}, err
|
||||
}
|
||||
|
||||
aggs := map[string]any{}
|
||||
for _, field := range FacetFields {
|
||||
|
||||
Reference in New Issue
Block a user