diff --git a/ai-compliance-sdk/internal/ucca/authority_router.go b/ai-compliance-sdk/internal/ucca/authority_router.go index 98d006bf..e9737241 100644 --- a/ai-compliance-sdk/internal/ucca/authority_router.go +++ b/ai-compliance-sdk/internal/ucca/authority_router.go @@ -74,6 +74,10 @@ func (c *LegalRAGClient) Retrieve(ctx context.Context, query string, topK int) ( } } + // Embed the query ONCE and stash it in ctx so the concurrent per-collection searches + // below reuse it instead of each re-embedding (was N remote round-trips on dev/OVH). + ctx = c.withQueryEmbedding(ctx, query) + out := make([][]LegalSearchResult, len(collections)) var wg sync.WaitGroup for i, coll := range collections { diff --git a/ai-compliance-sdk/internal/ucca/concept_ontology.go b/ai-compliance-sdk/internal/ucca/concept_ontology.go index f56f8147..7dad8e7d 100644 --- a/ai-compliance-sdk/internal/ucca/concept_ontology.go +++ b/ai-compliance-sdk/internal/ucca/concept_ontology.go @@ -48,12 +48,12 @@ var legalConceptOntology = []conceptNorm{ // ConceptNorms returns the load-bearing norm_ids for the concepts named in the // query (dedup, order-preserving). Empty if no concept is named. func ConceptNorms(query string) []string { - q := strings.ToLower(query) + q := normalizeGerman(query) seen := map[string]bool{} out := []string{} for _, cn := range legalConceptOntology { for _, kw := range cn.keywords { - if strings.Contains(q, kw) { + if strings.Contains(q, normalizeGerman(kw)) { for _, nid := range cn.normIDs { if !seen[nid] { seen[nid] = true diff --git a/ai-compliance-sdk/internal/ucca/embed_cache.go b/ai-compliance-sdk/internal/ucca/embed_cache.go new file mode 100644 index 00000000..40679a56 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/embed_cache.go @@ -0,0 +1,34 @@ +package ucca + +import "context" + +type embCacheKeyT struct{} + +var embCacheKey embCacheKeyT + +type embCacheEntry struct { + query string + vec []float64 +} + +// embedForQuery returns the query embedding, reusing a value precomputed for the SAME +// query and stashed in ctx by withQueryEmbedding. This collapses the Authority Router's +// per-collection fan-out from N embeddings to ONE — decisive when the embedding endpoint +// is remote (dev/OVH), where N round-trips dominated /retrieve latency. Falls back to a +// fresh embedding when nothing is cached (direct Search / SearchCollection callers). +func (c *LegalRAGClient) embedForQuery(ctx context.Context, query string) ([]float64, error) { + if v, ok := ctx.Value(embCacheKey).(*embCacheEntry); ok && v.query == query && len(v.vec) > 0 { + return v.vec, nil + } + return c.generateEmbedding(ctx, query) +} + +// withQueryEmbedding precomputes the query embedding once and stashes it in ctx so the +// concurrent per-collection searches reuse it instead of each re-embedding. Best-effort: +// on embed error the ctx is returned unchanged and callers fall back to per-call embedding. +func (c *LegalRAGClient) withQueryEmbedding(ctx context.Context, query string) context.Context { + if vec, err := c.generateEmbedding(ctx, query); err == nil && len(vec) > 0 { + return context.WithValue(ctx, embCacheKey, &embCacheEntry{query: query, vec: vec}) + } + return ctx +} diff --git a/ai-compliance-sdk/internal/ucca/intent.go b/ai-compliance-sdk/internal/ucca/intent.go index abed232b..be4f27ec 100644 --- a/ai-compliance-sdk/internal/ucca/intent.go +++ b/ai-compliance-sdk/internal/ucca/intent.go @@ -10,10 +10,10 @@ import "strings" // this evidence") instead of guessing the format. Returns "" (neutral) when no // clear task is signalled. First tier of ~20-30 intent types. func DetectIntent(query string) string { - q := " " + strings.ToLower(query) + " " + q := " " + normalizeGerman(query) + " " has := func(subs ...string) bool { for _, s := range subs { - if strings.Contains(q, s) { + if strings.Contains(q, normalizeGerman(s)) { return true } } diff --git a/ai-compliance-sdk/internal/ucca/legal_rag_client.go b/ai-compliance-sdk/internal/ucca/legal_rag_client.go index db4993b7..cf13d2bd 100644 --- a/ai-compliance-sdk/internal/ucca/legal_rag_client.go +++ b/ai-compliance-sdk/internal/ucca/legal_rag_client.go @@ -105,7 +105,7 @@ func (c *LegalRAGClient) searchInternal(ctx context.Context, collection string, } } - embedding, err := c.generateEmbedding(ctx, query) + embedding, err := c.embedForQuery(ctx, query) if err != nil { return nil, fmt.Errorf("failed to generate embedding: %w", err) } diff --git a/ai-compliance-sdk/internal/ucca/text_norm.go b/ai-compliance-sdk/internal/ucca/text_norm.go new file mode 100644 index 00000000..0c746118 --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/text_norm.go @@ -0,0 +1,15 @@ +package ucca + +import "strings" + +// normalizeGerman lowercases and folds German umlauts / ß to their ASCII digraphs +// (ä→ae, ö→oe, ü→ue, ß→ss) so keyword matching is insensitive to whether the user +// typed "Prüfe" or "Pruefe", "Datenschutzerklärung" or "Datenschutzerklaerung". +// Applied to BOTH the query and the keyword lists in the German-text matchers. +func normalizeGerman(s string) string { + return umlautFolder.Replace(strings.ToLower(s)) +} + +var umlautFolder = strings.NewReplacer( + "ä", "ae", "ö", "oe", "ü", "ue", "ß", "ss", +) diff --git a/ai-compliance-sdk/internal/ucca/umlaut_test.go b/ai-compliance-sdk/internal/ucca/umlaut_test.go new file mode 100644 index 00000000..d65b606f --- /dev/null +++ b/ai-compliance-sdk/internal/ucca/umlaut_test.go @@ -0,0 +1,29 @@ +package ucca + +import "testing" + +func TestDetectIntentUmlautFold(t *testing.T) { + cases := map[string]string{ + "Pruefe meine Datenschutzerklaerung.": "review", // ASCII digraph + "Prüfe meine Datenschutzerklärung.": "review", // umlaut + "Ueberpruefe das Impressum": "review", // ASCII "überprüfe" + "Was ist eine TOM?": "definition", // unchanged + } + for q, want := range cases { + if got := DetectIntent(q); got != want { + t.Errorf("DetectIntent(%q)=%q want %q", q, got, want) + } + } +} + +func TestConceptNormsUmlautFold(t *testing.T) { + // ASCII "datenschutzerklaerung" must resolve to the same core norms as the umlaut form. + ascii := ConceptNorms("Was gehoert in eine Datenschutzerklaerung?") + umlaut := ConceptNorms("Was gehört in eine Datenschutzerklärung?") + if len(ascii) == 0 { + t.Errorf("ConceptNorms(ASCII datenschutzerklaerung) returned none") + } + if len(ascii) != len(umlaut) { + t.Errorf("ASCII vs umlaut concept norms differ: %v vs %v", ascii, umlaut) + } +}