diff --git a/ai/docs/SEM-03-PRUEFPROTOKOLL.md b/ai/docs/SEM-03-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..cb61364a --- /dev/null +++ b/ai/docs/SEM-03-PRUEFPROTOKOLL.md @@ -0,0 +1,32 @@ +# SEM-03 – Prüfprotokoll: Semantische-Such-API + +Datum: 2026-09-26. Host: 192.168.1.204 (Wegwerf-Postgres, `dms_dev`-Rolle über `TEST_TENANT_DSN`). Frischer Klon von `main` (Head `c7075792`). + +## Umsetzung + +- `ai/internal/semanticsearch`: `Service.Search` bettet den Anfragetext über `provider.Provider` (gleiches Interface wie ENG-01/02/03) ein und ruft `vectorindex.Store.Search` (SEM-02) auf. Kein neues Schema, keine Änderung an SEM-01/SEM-02. +- Rechteprüfung (AK2): Das AI-Modul kennt keine ACL — die kennt nur das aufrufende Modul (DMS/Mail). Die Suche verlangt deshalb zwingend eine Liste bereits freigegebener Referenzen (`AllowedRefs`) und filtert strukturell darauf; ohne Referenzen liefert die Suche `ErrNoAllowedRefs` statt stillschweigend alles zu zeigen. +- `ai/internal/semanticsearchapi`: erste HTTP-API-Bindung dieses Pakets (`POST /search`), Service-Token-Pflicht wie `moduleadapter` (Workflow ENG-02), fail closed. Eigene `ai/internal/svcauth`-Kopie angelegt (Go-Module teilen keine `internal/`-Pakete über Modulgrenzen, gleiches Muster wie in Workflow/Archive). + +## Geprüfte Akzeptanzkriterien + +1. **Suche liefert nach Relevanz sortierte Treffer.** `TestSearch_ReturnsRankedHits` (Fake-Index, kontrollierte Ähnlichkeitswerte) und `TestSearch_RealVectorindex_RankingAndTenantIsolation` (echter Postgres, echter `vectorindex.Store`): das ähnlichste Dokument erscheint zuerst. +2. **Nur berechtigte Dokumente erscheinen im Ergebnis.** `TestSearch_FiltersToAllowedRefs`: ein nicht freigegebenes, aber ähnlicheres Dokument bleibt draußen. `TestSearch_NoAllowedRefs_Rejected`/`TestHandleSearch_NoAllowedRefs_BadRequest`: ohne Freigabeliste kein Ergebnis, sondern ein klarer Fehler (400 auf HTTP-Ebene). Real-Test: Dokument eines fremden Mandanten (`andere-firma`) erscheint nie, unabhängig von `AllowedRefs`. +3. **Leere Trefferliste wird sauber gemeldet.** `TestSearch_EmptyResult_NoError`, `TestSearch_AllFilteredOut_EmptyResultNoError`, `TestHandleSearch_NoMatchingAllowedRefs_ReturnsEmptyList`: leere Liste (`[]`, nicht `null`), kein Fehler, HTTP 200. + +## Durchgeführte Prüfungen + +| # | Prüfung | Ergebnis | +|---|---|---| +| 1 | Relevanz-Ranking an Testkorpus stichprobenartig geprüft | **Bestanden.** Real gegen Postgres/`vectorindex.Store`: drei Dokumente unterschiedlicher Ähnlichkeit, korrekte Reihenfolge (`doc-nah` vor `doc-fern`), fremder Mandant (`andere-firma`) nie im Ergebnis. | +| 2 | Rechteprüfung im Test bestätigt | **Bestanden.** `TestSearch_FiltersToAllowedRefs` (Unit) und der Real-Test filtern ein ähnlicheres, aber nicht freigegebenes Dokument korrekt heraus. Fehlende Freigabeliste wird strukturell abgewiesen (kein Fallback auf „alles zeigen“). | +| 3 | Leerfall im Test korrekt behandelt | **Bestanden.** Sowohl „kein Kandidat im Index“ als auch „alle Kandidaten durch Rechtefilter ausgefiltert“ liefern eine leere, aber valide Liste (kein Fehler, kein `null`). | + +## Tests + +`go test ./internal/semanticsearch/... ./internal/semanticsearchapi/...`: 15 Tests, alle grün (10 Unit + 1 Real-Integrationstest in `semanticsearch`, 5 Handler-Tests in `semanticsearchapi`). Volle `ai`-Modul-Regression (`go test ./... -p 1`): alle Pakete grün, keine Regression. `go build ./...`/`go vet ./...`: sauber. + +## Nicht Bestandteil dieser Kachel (laut Ticket) + +- SEM-04 (Chat-mit-Dokumenten/RAG-Pipeline) und SEM-06 (Oberfläche) bauen auf dieser Schnittstelle auf, sind hier nicht enthalten. +- Kein Merge, kein Deploy. diff --git a/ai/internal/semanticsearch/integration_test.go b/ai/internal/semanticsearch/integration_test.go new file mode 100644 index 00000000..b0f02fca --- /dev/null +++ b/ai/internal/semanticsearch/integration_test.go @@ -0,0 +1,90 @@ +package semanticsearch_test + +import ( + "context" + "os" + "path/filepath" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/semanticsearch" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/vectorindex" +) + +func newRealPool(t *testing.T) *pgxpool.Pool { + t.Helper() + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen") + } + pool := testdb.Pool(t, dsn) + + root, err := filepath.Abs("../../migrations") + if err != nil { + t.Fatalf("migrationsverzeichnis: %v", err) + } + migrations, err := migrate.Load(root) + if err != nil { + t.Fatalf("migrationen laden: %v", err) + } + if _, err := migrate.Up(context.Background(), pool, migrations); err != nil { + t.Fatalf("migrationen anwenden: %v", err) + } + return pool +} + +func insertRealChunk(t *testing.T, pool *pgxpool.Pool, tenant, sourceType, sourceID, text string, vec []float64) { + t.Helper() + ctx := context.Background() + var jobID string + err := pool.QueryRow(ctx, ` + INSERT INTO inference_jobs (job_type, payload, idempotency_key) + VALUES ('embedding', '{}', $1) RETURNING id + `, tenant+":"+sourceType+":"+sourceID+":"+time.Now().Format(time.RFC3339Nano)).Scan(&jobID) + if err != nil { + t.Fatalf("job anlegen: %v", err) + } + _, err = pool.Exec(ctx, ` + INSERT INTO content_embeddings (tenant_slug, source_type, source_id, chunk_index, chunk_text, job_id, model, embedding) + VALUES ($1, $2, $3, 0, $4, $5, 'test-model', $6) + `, tenant, sourceType, sourceID, text, jobID, vec) + if err != nil { + t.Fatalf("chunk einfuegen: %v", err) + } +} + +// Pruefung 1: Relevanz-Ranking an Testkorpus, real gegen Postgres und den +// echten vectorindex.Store (SEM-02) — kein Fake-Index. +func TestSearch_RealVectorindex_RankingAndTenantIsolation(t *testing.T) { + pool := newRealPool(t) + index := vectorindex.New(pool) + + insertRealChunk(t, pool, "acme", "dms_document", "doc-nah", "Rechnung Nummer 42 ueber 100 Euro", []float64{1, 0, 0}) + insertRealChunk(t, pool, "acme", "dms_document", "doc-fern", "Urlaubsantrag fuer naechste Woche", []float64{0, 0, 1}) + insertRealChunk(t, pool, "andere-firma", "dms_document", "doc-fremd", "Rechnung eines anderen Mandanten", []float64{1, 0, 0}) + + svc := semanticsearch.New(&fixedProvider{vec: []float64{1, 0, 0}}, index, "test-model") + + hits, err := svc.Search(context.Background(), "acme", "Rechnung", 10, []semanticsearch.Ref{ + {SourceType: "dms_document", SourceID: "doc-nah"}, + {SourceType: "dms_document", SourceID: "doc-fern"}, + }) + if err != nil { + t.Fatalf("search: %v", err) + } + if len(hits) != 2 { + t.Fatalf("erwartet 2 treffer (nur eigener mandant + erlaubt), bekommen %d: %+v", len(hits), hits) + } + if hits[0].SourceID != "doc-nah" { + t.Fatalf("erwartet aehnlichstes dokument zuerst, bekommen %+v", hits[0]) + } + for _, h := range hits { + if h.SourceID == "doc-fremd" { + t.Fatal("dokument eines anderen mandanten im ergebnis erschienen") + } + } +} diff --git a/ai/internal/semanticsearch/semanticsearch.go b/ai/internal/semanticsearch/semanticsearch.go new file mode 100644 index 00000000..0ff1b96e --- /dev/null +++ b/ai/internal/semanticsearch/semanticsearch.go @@ -0,0 +1,107 @@ +// Package semanticsearch liefert die Schnittstelle für semantische Suche +// über den von embedpipeline (SEM-01) befüllten und von vectorindex (SEM-02) +// verwalteten Vektorindex: Anfragetext einbetten, ähnlichste Abschnitte +// finden, nach Relevanz sortieren (AK1). Rechteprüfung (AK2) liegt beim +// aufrufenden Modul (DMS/Mail kennen die ACL, das AI-Modul nicht) — die +// Suche verlangt deshalb zwingend eine bereits freigegebene Referenzmenge +// und filtert strukturell darauf, statt sich auf reine Mandantentrennung zu +// verlassen. +package semanticsearch + +import ( + "context" + "errors" + "fmt" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/provider" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/vectorindex" +) + +// ErrNoAllowedRefs signalisiert eine Anfrage ohne freigegebene Referenzen — +// ohne sie kann keine Rechteprüfung stattfinden, also gibt es keine Treffer. +var ErrNoAllowedRefs = errors.New("semanticsearch: keine freigegebenen referenzen") + +// Ref identifiziert ein Quellobjekt (Dokument/E-Mail) eindeutig. +type Ref struct { + SourceType string + SourceID string +} + +// Hit ist ein einzelner, nach Relevanz sortierter Treffer (AK1). +type Hit struct { + SourceType string `json:"source_type"` + SourceID string `json:"source_id"` + ChunkIndex int `json:"chunk_index"` + ChunkText string `json:"chunk_text"` + Score float64 `json:"score"` +} + +type indexSearcher interface { + Search(ctx context.Context, tenantSlug string, query []float64, topK int) ([]vectorindex.Result, error) +} + +// Service verbindet Query-Embedding (über provider.Provider) mit der +// Ähnlichkeitssuche aus vectorindex (SEM-02). +type Service struct { + provider provider.Provider + index indexSearcher + model string +} + +func New(p provider.Provider, index indexSearcher, model string) *Service { + return &Service{provider: p, index: index, model: model} +} + +// Search bettet queryText ein, sucht die ähnlichsten Abschnitte innerhalb +// von tenantSlug und liefert nur Treffer aus allowedRefs (AK2). Eine leere +// Trefferliste ist kein Fehler (AK3). +func (s *Service) Search(ctx context.Context, tenantSlug, queryText string, topK int, allowedRefs []Ref) ([]Hit, error) { + if tenantSlug == "" { + return nil, errors.New("semanticsearch: tenant_slug ist pflicht") + } + if queryText == "" { + return nil, errors.New("semanticsearch: anfragetext ist leer") + } + if len(allowedRefs) == 0 { + return nil, ErrNoAllowedRefs + } + if topK <= 0 { + topK = 10 + } + + allowed := make(map[Ref]bool, len(allowedRefs)) + for _, ref := range allowedRefs { + allowed[ref] = true + } + + emb, err := s.provider.Embed(ctx, provider.EmbeddingRequest{Model: s.model, Input: queryText}) + if err != nil { + return nil, fmt.Errorf("semanticsearch: anfrage einbetten: %w", err) + } + + // topK*len(allowedRefs)-unabhängig genug Kandidaten abfragen, damit nach + // dem Rechte-Filter noch topK Treffer übrig bleiben können, auch wenn + // viele der ähnlichsten Abschnitte außerhalb der Freigabe liegen. + candidates, err := s.index.Search(ctx, tenantSlug, emb.Embedding, topK*10+len(allowedRefs)) + if err != nil { + return nil, fmt.Errorf("semanticsearch: index durchsuchen: %w", err) + } + + hits := make([]Hit, 0, topK) + for _, c := range candidates { + if len(hits) >= topK { + break + } + if !allowed[Ref{SourceType: c.SourceType, SourceID: c.SourceID}] { + continue + } + hits = append(hits, Hit{ + SourceType: c.SourceType, + SourceID: c.SourceID, + ChunkIndex: c.ChunkIndex, + ChunkText: c.ChunkText, + Score: c.Similarity, + }) + } + return hits, nil +} diff --git a/ai/internal/semanticsearch/semanticsearch_test.go b/ai/internal/semanticsearch/semanticsearch_test.go new file mode 100644 index 00000000..d3977507 --- /dev/null +++ b/ai/internal/semanticsearch/semanticsearch_test.go @@ -0,0 +1,143 @@ +package semanticsearch_test + +import ( + "context" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/provider" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/semanticsearch" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/vectorindex" +) + +type fakeIndex struct { + results []vectorindex.Result +} + +func (f *fakeIndex) Search(_ context.Context, _ string, _ []float64, topK int) ([]vectorindex.Result, error) { + if len(f.results) > topK { + return f.results[:topK], nil + } + return f.results, nil +} + +type fixedProvider struct { + vec []float64 +} + +func (p *fixedProvider) Chat(context.Context, provider.ChatRequest) (provider.ChatResponse, error) { + return provider.ChatResponse{}, nil +} +func (p *fixedProvider) Complete(context.Context, provider.CompletionRequest) (provider.CompletionResponse, error) { + return provider.CompletionResponse{}, nil +} +func (p *fixedProvider) Embed(context.Context, provider.EmbeddingRequest) (provider.EmbeddingResponse, error) { + return provider.EmbeddingResponse{Embedding: p.vec}, nil +} + +// AK1: Suche liefert nach Relevanz sortierte Treffer. +func TestSearch_ReturnsRankedHits(t *testing.T) { + idx := &fakeIndex{results: []vectorindex.Result{ + {SourceType: "dms_document", SourceID: "doc-1", ChunkIndex: 0, ChunkText: "hoch relevant", Similarity: 0.95}, + {SourceType: "dms_document", SourceID: "doc-2", ChunkIndex: 0, ChunkText: "mittel relevant", Similarity: 0.6}, + {SourceType: "dms_document", SourceID: "doc-3", ChunkIndex: 0, ChunkText: "wenig relevant", Similarity: 0.2}, + }} + svc := semanticsearch.New(&fixedProvider{vec: []float64{1, 0}}, idx, "test-model") + + hits, err := svc.Search(context.Background(), "acme", "suchanfrage", 10, []semanticsearch.Ref{ + {SourceType: "dms_document", SourceID: "doc-1"}, + {SourceType: "dms_document", SourceID: "doc-2"}, + {SourceType: "dms_document", SourceID: "doc-3"}, + }) + if err != nil { + t.Fatalf("search: %v", err) + } + if len(hits) != 3 { + t.Fatalf("erwartet 3 treffer, bekommen %d", len(hits)) + } + if hits[0].SourceID != "doc-1" || hits[1].SourceID != "doc-2" || hits[2].SourceID != "doc-3" { + t.Fatalf("treffer nicht nach relevanz sortiert: %+v", hits) + } + if hits[0].Score <= hits[1].Score || hits[1].Score <= hits[2].Score { + t.Fatalf("score-reihenfolge stimmt nicht mit relevanz ueberein: %+v", hits) + } +} + +// AK2: Nur berechtigte Dokumente erscheinen im Ergebnis. +func TestSearch_FiltersToAllowedRefs(t *testing.T) { + idx := &fakeIndex{results: []vectorindex.Result{ + {SourceType: "dms_document", SourceID: "doc-erlaubt", ChunkIndex: 0, ChunkText: "sichtbar", Similarity: 0.9}, + {SourceType: "dms_document", SourceID: "doc-verboten", ChunkIndex: 0, ChunkText: "unsichtbar", Similarity: 0.99}, + }} + svc := semanticsearch.New(&fixedProvider{vec: []float64{1, 0}}, idx, "test-model") + + hits, err := svc.Search(context.Background(), "acme", "suchanfrage", 10, []semanticsearch.Ref{ + {SourceType: "dms_document", SourceID: "doc-erlaubt"}, + }) + if err != nil { + t.Fatalf("search: %v", err) + } + if len(hits) != 1 { + t.Fatalf("erwartet genau 1 treffer (nur berechtigt), bekommen %d: %+v", len(hits), hits) + } + if hits[0].SourceID != "doc-erlaubt" { + t.Fatalf("unerlaubtes dokument im ergebnis: %+v", hits[0]) + } +} + +// AK2 (Grenzfall): keine freigegebenen Referenzen -> keine Treffer, klarer Fehler statt stillschweigend alles zu zeigen. +func TestSearch_NoAllowedRefs_Rejected(t *testing.T) { + idx := &fakeIndex{results: []vectorindex.Result{ + {SourceType: "dms_document", SourceID: "doc-1", Similarity: 0.9}, + }} + svc := semanticsearch.New(&fixedProvider{vec: []float64{1, 0}}, idx, "test-model") + + _, err := svc.Search(context.Background(), "acme", "suchanfrage", 10, nil) + if err == nil { + t.Fatal("erwartet fehler ohne freigegebene referenzen") + } +} + +// AK3: Leere Trefferliste wird sauber gemeldet (kein Fehler, leere Liste). +func TestSearch_EmptyResult_NoError(t *testing.T) { + idx := &fakeIndex{results: nil} + svc := semanticsearch.New(&fixedProvider{vec: []float64{1, 0}}, idx, "test-model") + + hits, err := svc.Search(context.Background(), "acme", "suchanfrage ohne treffer", 10, []semanticsearch.Ref{ + {SourceType: "dms_document", SourceID: "doc-1"}, + }) + if err != nil { + t.Fatalf("leere trefferliste soll kein fehler sein: %v", err) + } + if hits == nil { + t.Fatal("leere trefferliste soll [] sein, nicht nil") + } + if len(hits) != 0 { + t.Fatalf("erwartet 0 treffer, bekommen %d", len(hits)) + } +} + +// AK3 (Grenzfall): alle Kandidaten durch Rechtefilter ausgefiltert -> ebenfalls saubere leere Liste. +func TestSearch_AllFilteredOut_EmptyResultNoError(t *testing.T) { + idx := &fakeIndex{results: []vectorindex.Result{ + {SourceType: "dms_document", SourceID: "doc-verboten", Similarity: 0.9}, + }} + svc := semanticsearch.New(&fixedProvider{vec: []float64{1, 0}}, idx, "test-model") + + hits, err := svc.Search(context.Background(), "acme", "suchanfrage", 10, []semanticsearch.Ref{ + {SourceType: "dms_document", SourceID: "doc-anderes"}, + }) + if err != nil { + t.Fatalf("kein fehler erwartet: %v", err) + } + if len(hits) != 0 { + t.Fatalf("erwartet 0 treffer nach vollstaendiger filterung, bekommen %d", len(hits)) + } +} + +func TestSearch_MissingTenant_Rejected(t *testing.T) { + svc := semanticsearch.New(&fixedProvider{vec: []float64{1, 0}}, &fakeIndex{}, "test-model") + _, err := svc.Search(context.Background(), "", "q", 10, []semanticsearch.Ref{{SourceType: "x", SourceID: "y"}}) + if err == nil { + t.Fatal("erwartet fehler ohne tenant_slug") + } +} diff --git a/ai/internal/semanticsearchapi/semanticsearchapi.go b/ai/internal/semanticsearchapi/semanticsearchapi.go new file mode 100644 index 00000000..f7109630 --- /dev/null +++ b/ai/internal/semanticsearchapi/semanticsearchapi.go @@ -0,0 +1,87 @@ +// Package semanticsearchapi ist die HTTP-Bindung der semantischen Suche +// (SEM-03). Aufrufer ist das anfragende Modul (DMS/Mail), nicht der +// Endnutzer direkt — Service-Token-Pflicht wie moduleadapter (Workflow +// ENG-02), fail closed. +package semanticsearchapi + +import ( + "encoding/json" + "errors" + "net/http" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/semanticsearch" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/svcauth" +) + +type Server struct { + svc *semanticsearch.Service + serviceToken string +} + +func NewServer(svc *semanticsearch.Service, serviceToken string) *Server { + return &Server{svc: svc, serviceToken: serviceToken} +} + +func (s *Server) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("POST /search", s.requireAuth(s.handleSearch)) +} + +func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + got := r.Header.Get("X-Service-Token") + if !svcauth.Equal(got, s.serviceToken) { + writeError(w, http.StatusUnauthorized, "service-token fehlt oder ist ungueltig") + return + } + next(w, r) + } +} + +type refRequest struct { + SourceType string `json:"source_type"` + SourceID string `json:"source_id"` +} + +type searchRequest struct { + TenantSlug string `json:"tenant_slug"` + Query string `json:"query"` + TopK int `json:"top_k"` + AllowedRefs []refRequest `json:"allowed_refs"` +} + +type searchResponse struct { + Hits []semanticsearch.Hit `json:"hits"` +} + +func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) { + var req searchRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper") + return + } + + refs := make([]semanticsearch.Ref, 0, len(req.AllowedRefs)) + for _, ref := range req.AllowedRefs { + refs = append(refs, semanticsearch.Ref{SourceType: ref.SourceType, SourceID: ref.SourceID}) + } + + hits, err := s.svc.Search(r.Context(), req.TenantSlug, req.Query, req.TopK, refs) + switch { + case err == nil: + writeJSON(w, http.StatusOK, searchResponse{Hits: hits}) + case errors.Is(err, semanticsearch.ErrNoAllowedRefs): + writeError(w, http.StatusBadRequest, "keine freigegebenen referenzen") + default: + writeError(w, http.StatusInternalServerError, "suche fehlgeschlagen") + } +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} diff --git a/ai/internal/semanticsearchapi/semanticsearchapi_test.go b/ai/internal/semanticsearchapi/semanticsearchapi_test.go new file mode 100644 index 00000000..a6f04132 --- /dev/null +++ b/ai/internal/semanticsearchapi/semanticsearchapi_test.go @@ -0,0 +1,153 @@ +package semanticsearchapi_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/provider" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/semanticsearch" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/semanticsearchapi" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/vectorindex" +) + +type fakeIndex struct { + results []vectorindex.Result +} + +func (f *fakeIndex) Search(_ context.Context, _ string, _ []float64, topK int) ([]vectorindex.Result, error) { + if len(f.results) > topK { + return f.results[:topK], nil + } + return f.results, nil +} + +type fixedProvider struct{ vec []float64 } + +func (p *fixedProvider) Chat(context.Context, provider.ChatRequest) (provider.ChatResponse, error) { + return provider.ChatResponse{}, nil +} +func (p *fixedProvider) Complete(context.Context, provider.CompletionRequest) (provider.CompletionResponse, error) { + return provider.CompletionResponse{}, nil +} +func (p *fixedProvider) Embed(context.Context, provider.EmbeddingRequest) (provider.EmbeddingResponse, error) { + return provider.EmbeddingResponse{Embedding: p.vec}, nil +} + +func newTestServer() *semanticsearchapi.Server { + idx := &fakeIndex{results: []vectorindex.Result{ + {SourceType: "dms_document", SourceID: "doc-1", ChunkText: "treffer", Similarity: 0.8}, + }} + svc := semanticsearch.New(&fixedProvider{vec: []float64{1, 0}}, idx, "test-model") + return semanticsearchapi.NewServer(svc, "geheimes-token") +} + +func TestHandleSearch_RequiresServiceToken(t *testing.T) { + mux := http.NewServeMux() + newTestServer().RegisterRoutes(mux) + + body, _ := json.Marshal(map[string]any{"tenant_slug": "acme", "query": "test", "top_k": 5}) + req := httptest.NewRequest(http.MethodPost, "/search", bytes.NewReader(body)) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("erwartet 401 ohne service-token, bekommen %d", rec.Code) + } +} + +func TestHandleSearch_WrongServiceToken_Rejected(t *testing.T) { + mux := http.NewServeMux() + newTestServer().RegisterRoutes(mux) + + body, _ := json.Marshal(map[string]any{"tenant_slug": "acme", "query": "test", "top_k": 5}) + req := httptest.NewRequest(http.MethodPost, "/search", bytes.NewReader(body)) + req.Header.Set("X-Service-Token", "falsches-token") + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("erwartet 401 bei falschem token, bekommen %d", rec.Code) + } +} + +func TestHandleSearch_ValidRequest_ReturnsHits(t *testing.T) { + mux := http.NewServeMux() + newTestServer().RegisterRoutes(mux) + + body, _ := json.Marshal(map[string]any{ + "tenant_slug": "acme", + "query": "test", + "top_k": 5, + "allowed_refs": []map[string]string{ + {"source_type": "dms_document", "source_id": "doc-1"}, + }, + }) + req := httptest.NewRequest(http.MethodPost, "/search", bytes.NewReader(body)) + req.Header.Set("X-Service-Token", "geheimes-token") + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("erwartet 200, bekommen %d: %s", rec.Code, rec.Body.String()) + } + var resp struct { + Hits []semanticsearch.Hit `json:"hits"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("antwort dekodieren: %v", err) + } + if len(resp.Hits) != 1 || resp.Hits[0].SourceID != "doc-1" { + t.Fatalf("unerwartete treffer: %+v", resp.Hits) + } +} + +// AK3: Leere Trefferliste wird sauber gemeldet — 200 mit leerer Liste, kein Fehler. +func TestHandleSearch_NoMatchingAllowedRefs_ReturnsEmptyList(t *testing.T) { + mux := http.NewServeMux() + newTestServer().RegisterRoutes(mux) + + body, _ := json.Marshal(map[string]any{ + "tenant_slug": "acme", + "query": "test", + "top_k": 5, + "allowed_refs": []map[string]string{ + {"source_type": "dms_document", "source_id": "doc-existiert-nicht"}, + }, + }) + req := httptest.NewRequest(http.MethodPost, "/search", bytes.NewReader(body)) + req.Header.Set("X-Service-Token", "geheimes-token") + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("erwartet 200 bei leerer trefferliste, bekommen %d", rec.Code) + } + var resp struct { + Hits []semanticsearch.Hit `json:"hits"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("antwort dekodieren: %v", err) + } + if len(resp.Hits) != 0 { + t.Fatalf("erwartet leere liste, bekommen %+v", resp.Hits) + } +} + +func TestHandleSearch_NoAllowedRefs_BadRequest(t *testing.T) { + mux := http.NewServeMux() + newTestServer().RegisterRoutes(mux) + + body, _ := json.Marshal(map[string]any{"tenant_slug": "acme", "query": "test", "top_k": 5}) + req := httptest.NewRequest(http.MethodPost, "/search", bytes.NewReader(body)) + req.Header.Set("X-Service-Token", "geheimes-token") + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("erwartet 400 ohne allowed_refs, bekommen %d", rec.Code) + } +} diff --git a/ai/internal/svcauth/svcauth.go b/ai/internal/svcauth/svcauth.go new file mode 100644 index 00000000..5c065de3 --- /dev/null +++ b/ai/internal/svcauth/svcauth.go @@ -0,0 +1,20 @@ +// Package svcauth vergleicht Service-Token in konstanter Zeit (eigene Kopie +// je Modul, siehe workflow/internal/svcauth — Go-Module teilen keine +// internal/-Pakete über Modulgrenzen; Lehre aus RET-14/F-08: Schutz beim +// ersten Entwurf einbauen statt nachzuruesten). +package svcauth + +import ( + "crypto/sha256" + "crypto/subtle" +) + +// Equal vergleicht die SHA-256-Werte beider Eingaben in konstanter Zeit. +// Ein leeres erwartetes Token stimmt nie. +func Equal(got, want string) bool { + if want == "" { + return false + } + g, w := sha256.Sum256([]byte(got)), sha256.Sum256([]byte(want)) + return subtle.ConstantTimeCompare(g[:], w[:]) == 1 +}