CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (push) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Canceled after 0s
CI / Web (dms-browser) (push) Canceled after 0s
CI / Web (pdf-viewer) (push) Canceled after 0s
CI / Web (shl) (push) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (push) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (push) Canceled after 0s
AI-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
CI / Core-Modul (Build/Vet/Test) (pull_request) Canceled after 0s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Canceled after 0s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Canceled after 0s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Canceled after 0s
CI / Web (dms-browser) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (pull_request) Canceled after 0s
CI / Web (shl) (pull_request) Canceled after 0s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Canceled after 0s
Supply-Chain-Scan (Go) / govulncheck (pull_request) Canceled after 0s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Canceled after 0s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Canceled after 0s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Canceled after 0s
Kleinstmoegliche HTTP-Bindung docchatapi fuer den bestehenden docchat-Service ergaenzt (analog summaryapi/semanticsearchapi), da SEM-05 nur die Bibliothek lieferte. Neue Next.js-Oberflaeche web/ai-search-chat mit Suche und Chat als Reiter auf einer Seite, Quellenverweise inkl. Zitat-Aufloesung, Verlauf im Elternzustand ueberdauert Reiterwechsel. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
176 lines
5.7 KiB
Go
176 lines
5.7 KiB
Go
package docchatapi_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"testing"
|
|
|
|
"gitea.perlbach24.de/scripte/nexarch/ai/internal/docchat"
|
|
"gitea.perlbach24.de/scripte/nexarch/ai/internal/docchatapi"
|
|
"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 stubSearcher struct {
|
|
hits []semanticsearch.Hit
|
|
err error
|
|
}
|
|
|
|
func (s stubSearcher) Search(context.Context, string, string, int, []semanticsearch.Ref) ([]semanticsearch.Hit, error) {
|
|
return s.hits, s.err
|
|
}
|
|
|
|
type stubProvider struct{ response provider.ChatResponse }
|
|
|
|
func (p *stubProvider) Chat(context.Context, provider.ChatRequest) (provider.ChatResponse, error) {
|
|
return p.response, nil
|
|
}
|
|
func (p *stubProvider) Complete(context.Context, provider.CompletionRequest) (provider.CompletionResponse, error) {
|
|
return provider.CompletionResponse{}, errors.New("nicht implementiert")
|
|
}
|
|
func (p *stubProvider) Embed(context.Context, provider.EmbeddingRequest) (provider.EmbeddingResponse, error) {
|
|
return provider.EmbeddingResponse{}, errors.New("nicht implementiert")
|
|
}
|
|
|
|
type stubLocator struct {
|
|
chunkText string
|
|
err error
|
|
}
|
|
|
|
func (l *stubLocator) GetChunk(context.Context, string, string, string, int) (string, error) {
|
|
return l.chunkText, l.err
|
|
}
|
|
|
|
func newTestServer(searcher stubSearcher, locator *stubLocator) *docchatapi.Server {
|
|
svc := docchat.New(searcher, &stubProvider{response: provider.ChatResponse{
|
|
Message: provider.Message{Role: provider.RoleAssistant, Content: "Antwort laut Beleg [1]."},
|
|
}}, "test-model", locator)
|
|
return docchatapi.NewServer(svc, "geheimes-token")
|
|
}
|
|
|
|
func TestHandleAsk_RequiresServiceToken(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
newTestServer(stubSearcher{}, &stubLocator{}).RegisterRoutes(mux)
|
|
|
|
body, _ := json.Marshal(map[string]any{"tenant_slug": "acme", "question": "test", "top_k": 5})
|
|
req := httptest.NewRequest(http.MethodPost, "/chat", 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 TestHandleAsk_ValidRequest_ReturnsAnswerWithCitations(t *testing.T) {
|
|
hits := []semanticsearch.Hit{{SourceType: "dms_document", SourceID: "doc-1", ChunkIndex: 0, ChunkText: "beleg"}}
|
|
mux := http.NewServeMux()
|
|
newTestServer(stubSearcher{hits: hits}, &stubLocator{}).RegisterRoutes(mux)
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"tenant_slug": "acme",
|
|
"question": "worum geht es?",
|
|
"top_k": 5,
|
|
"allowed_refs": []map[string]string{
|
|
{"source_type": "dms_document", "source_id": "doc-1"},
|
|
},
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/chat", 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 docchat.Answer
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("antwort dekodieren: %v", err)
|
|
}
|
|
if len(resp.Citations) != 1 || resp.Citations[0].SourceID != "doc-1" {
|
|
t.Fatalf("erwartet eine zitat-quelle, bekommen %+v", resp.Citations)
|
|
}
|
|
}
|
|
|
|
func TestHandleAsk_NoHits_ReturnsNoEvidenceAnswer(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
newTestServer(stubSearcher{hits: nil}, &stubLocator{}).RegisterRoutes(mux)
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"tenant_slug": "acme",
|
|
"question": "worum geht es?",
|
|
"top_k": 5,
|
|
"allowed_refs": []map[string]string{
|
|
{"source_type": "dms_document", "source_id": "doc-1"},
|
|
},
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/chat", bytes.NewReader(body))
|
|
req.Header.Set("X-Service-Token", "geheimes-token")
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
var resp docchat.Answer
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("antwort dekodieren: %v", err)
|
|
}
|
|
if !resp.NoEvidence || resp.Text != docchat.NoEvidenceAnswer {
|
|
t.Fatalf("erwartet no-evidence-antwort, bekommen %+v", resp)
|
|
}
|
|
}
|
|
|
|
func TestHandleResolveCitation_ReturnsChunkText(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
newTestServer(stubSearcher{}, &stubLocator{chunkText: "originaler abschnitt"}).RegisterRoutes(mux)
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"tenant_slug": "acme",
|
|
"index": 1,
|
|
"source_type": "dms_document",
|
|
"source_id": "doc-1",
|
|
"chunk_index": 0,
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/chat/citation", 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 {
|
|
Text string `json:"text"`
|
|
}
|
|
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
|
|
t.Fatalf("antwort dekodieren: %v", err)
|
|
}
|
|
if resp.Text != "originaler abschnitt" {
|
|
t.Fatalf("erwartet originalen abschnitt, bekommen %q", resp.Text)
|
|
}
|
|
}
|
|
|
|
func TestHandleResolveCitation_UnknownChunk_ReturnsNotFound(t *testing.T) {
|
|
mux := http.NewServeMux()
|
|
newTestServer(stubSearcher{}, &stubLocator{err: vectorindex.ErrChunkNotFound}).RegisterRoutes(mux)
|
|
|
|
body, _ := json.Marshal(map[string]any{
|
|
"tenant_slug": "acme",
|
|
"index": 1,
|
|
"source_type": "dms_document",
|
|
"source_id": "doc-1",
|
|
"chunk_index": 9,
|
|
})
|
|
req := httptest.NewRequest(http.MethodPost, "/chat/citation", bytes.NewReader(body))
|
|
req.Header.Set("X-Service-Token", "geheimes-token")
|
|
rec := httptest.NewRecorder()
|
|
mux.ServeHTTP(rec, req)
|
|
|
|
if rec.Code != http.StatusNotFound {
|
|
t.Fatalf("erwartet 404, bekommen %d", rec.Code)
|
|
}
|
|
}
|