diff --git a/ai/docs/SEM-05-PRUEFPROTOKOLL.md b/ai/docs/SEM-05-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..bba32f32 --- /dev/null +++ b/ai/docs/SEM-05-PRUEFPROTOKOLL.md @@ -0,0 +1,39 @@ +# SEM-05 – Prüfprotokoll + +Datum: 2026-09-28. Host: 192.168.1.204 (frischer Klon in eigenem Wegwerfverzeichnis, eigene Postgres-Rolle/DB `sem05_test`/`sem05_test_db`, nach Testlauf zurückgebaut). Reale Modellprüfungen gegen `office.sysops.de:11435`, Modell `gpt-oss:20b`. + +## Umsetzung + +Baut auf SEM-04 (`ai/internal/docchat`) auf, ohne dessen Retrieval-vor-Generation-Logik zu ändern: + +- `docchat.Citation`: ein Quellenverweis je zitierbarem Treffer, mit `Index` passend zur im Prompt verwendeten Klammer-Nummerierung (`[1]`, `[2]`, ...) sowie `SourceType`/`SourceID`/`ChunkIndex`. +- `Answer.Citations`: wird in `Ask` aus den abgerufenen Treffern befüllt — nur wenn mindestens ein Treffer vorliegt. Bei `NoEvidence` (kein Treffer) bleibt `Citations` leer, der feste `NoEvidenceAnswer`-Text bleibt unverändert (AK3). +- `Service.ResolveCitation(ctx, tenantSlug, Citation)`: löst einen Zitat-Verweis über einen neuen `chunkLocator` auf den tatsächlich im Index gespeicherten Abschnittstext auf (AK2) — unabhängig von der zum Antwortzeitpunkt mitgeführten Kopie in `Sources`. +- `vectorindex.Store.GetChunk(ctx, tenantSlug, sourceType, sourceID, chunkIndex)`: neue Methode, die genau den adressierten Abschnitt aus `content_embeddings` liefert (mandantengetrennt) oder `ErrChunkNotFound`, wenn der Verweis nicht mehr auf einen tatsächlichen Abschnitt zeigt (gelöschtes Dokument, falscher Index, anderer Mandant). + +`docchat.New` bekommt dafür einen vierten Parameter (`chunkLocator`, hier `*vectorindex.Store`); bestehende Aufrufer gab es im Code nicht (nur die eigenen Tests), daher keine Anpassung außerhalb des Pakets nötig. + +## Akzeptanzkriterien / Prüfungen + +| # | Kriterium | Ergebnis | +|---|---|---| +| 1 | Jede Antwort führt mindestens einen Quellenverweis | **Bestanden.** `TestAsk_AntwortFuehrtQuellenverweise`: zwei Treffer ergeben zwei `Citations` mit `Index` 1/2 passend zur Klammer-Nummerierung im Prompt und korrektem `SourceType`/`SourceID`/`ChunkIndex`. Stichprobe an Testfrage "Wie lang ist die Kündigungsfrist?" nachvollzogen: Antwort zitiert `[1]`, `Citations[0]` verweist auf exakt den Treffer, aus dem die Antwort stammt. | +| 2 | Verweis führt zum tatsächlichen Abschnitt im Dokument | **Bestanden.** `TestGetChunk_ReturnsStoredChunkText` (echte Postgres-DB): eingefügter Abschnitt wird über `GetChunk` mit denselben Koordinaten (`tenant_slug`, `source_type`, `source_id`, `chunk_index`) byte-genau zurückgeliefert. `TestResolveCitation_LiefertAbschnittAusIndex` prüft den Sprung über `docchat.Service.ResolveCitation` mit denselben Koordinaten wie in der `Citation`. `TestGetChunk_UnknownReference_ReturnsErrChunkNotFound` deckt den Fall ab, dass ein Verweis (falscher Index, anderer Mandant) nicht mehr auflösbar ist und das als solches gemeldet wird statt einen falschen/leeren Abschnitt vorzutäuschen. | +| 3 | Antwort ohne Beleg wird nicht als gesichert dargestellt | **Bestanden.** `TestAsk_UnbelegteAntwort_KeineCitations`: ohne Treffer bleibt `Citations` leer, `NoEvidence` ist `true`, `Text` ist der feste, unmissverständliche Hinweistext `NoEvidenceAnswer` statt einer wie eine Tatsachenbehauptung wirkenden Modellantwort — es findet dafür ohnehin kein Modellaufruf statt (bereits SEM-04-Verhalten, hier zusätzlich gegen die neuen `Citations` abgesichert). | + +## Prüfungen vor der Abnahme (laut Ticket) + +1. **Quellenverweis an Testfrage stichprobenartig nachvollzogen:** Testfrage "Wie lang ist die Kündigungsfrist?" gegen einen Abschnitt mit "Die Kuendigungsfrist betraegt drei Monate zum Quartalsende." — reale Antwort von `gpt-oss:20b` enthält "drei Monate" und referenziert `[1]`; `Answer.Citations[0]` zeigt exakt auf `document`/`vertrag-42`/Abschnitt 0, den Treffer, aus dem die Antwort stammt (`TestAsk_Real_BelegAntwortZuordnungUndAntwortzeit`, ergänzt um Citation-Prüfung in `TestAsk_AntwortFuehrtQuellenverweise`). +2. **Sprung zum Originalabschnitt im Test funktionsfähig:** `TestResolveCitation_LiefertAbschnittAusIndex` und `TestGetChunk_ReturnsStoredChunkText` (echte Postgres-DB auf Host 204) zeigen, dass ein `Citation`-Verweis über `ResolveCitation`/`GetChunk` denselben Abschnittstext liefert, der ursprünglich im Index gespeichert wurde — der Verweis führt reproduzierbar zurück zum Original, nicht zu einer Kopie im Antworttext. +3. **Unbelegte Antwort im Test korrekt gekennzeichnet:** `TestAsk_UnbelegteAntwort_KeineCitations` und die bereits aus SEM-04 bestehenden `TestAsk_KeinTrefferKeinModellaufruf`/`TestAsk_KeineFreigegebenenReferenzen` zeigen: ohne Treffer weder `Citations` noch ein aus dem Modell erzeugter Text, sondern ausschließlich der feste `NoEvidenceAnswer`-Hinweis mit `NoEvidence = true`. + +## Tests + +- `internal/docchat/docchat_test.go`: 5 neue Unit-Tests (`TestAsk_AntwortFuehrtQuellenverweise`, `TestAsk_UnbelegteAntwort_KeineCitations`, `TestResolveCitation_LiefertAbschnittAusIndex`, `TestResolveCitation_OhneLocator_ReturnsError`, `TestResolveCitation_LocatorFehler_WirdDurchgereicht`) zusätzlich zu den 6 bestehenden — alle grün. +- `internal/docchat/docchat_real_test.go`: unverändert in der Logik, nur an die neue `New`-Signatur angepasst; beide echten Tests weiterhin grün gegen `office.sysops.de:11435`/`gpt-oss:20b` (Antwortzeit 6,21s, unter dem 10s-Richtwert). +- `internal/vectorindex/vectorindex_test.go`: 2 neue Integrationstests (`TestGetChunk_ReturnsStoredChunkText`, `TestGetChunk_UnknownReference_ReturnsErrChunkNotFound`) gegen echte Postgres-DB, zusätzlich zu den 5 bestehenden — alle grün. +- Volle Regression `go build ./...`, `go vet ./...` im gesamten `ai`-Modul: sauber, keine Fehler. + +## Bewusst nicht Teil dieser Kachel + +Keine Chat-Oberfläche (SEM-06) und kein HTTP-Endpunkt für `docchat` — beides existierte bereits vor diesem Ticket nicht und wird hier nicht vorweggenommen. Kein Umbau von `semanticsearch`/`vectorindex.Search` selbst, nur eine neue, punktuelle Lesemethode `GetChunk` daneben. diff --git a/ai/internal/docchat/docchat.go b/ai/internal/docchat/docchat.go index 8d54e7f7..d4f325ec 100644 --- a/ai/internal/docchat/docchat.go +++ b/ai/internal/docchat/docchat.go @@ -1,8 +1,9 @@ // Package docchat baut auf semanticsearch (SEM-03) eine Retrieval-vor-Generation- // Pipeline: erst Abschnitte suchen, dann nur mit diesen Abschnitten als Kontext // eine Antwort erzeugen. Ohne Treffer gibt es keine Modellanfrage und keine -// erfundene Antwort (AK2) — Quellenverweise (SEM-05) und eine Chat-Oberfläche -// (SEM-06) bauen auf dem hier zurückgegebenen Answer-Typ auf. +// erfundene Antwort (AK2). Jede Antwort führt ihre Quellenverweise als +// Citations mit, auflösbar auf den tatsächlichen Abschnitt im Index (SEM-05); +// eine Chat-Oberfläche (SEM-06) baut auf dem hier zurückgegebenen Answer-Typ auf. package docchat import ( @@ -24,11 +25,30 @@ type searcher interface { Search(ctx context.Context, tenantSlug, queryText string, topK int, allowedRefs []semanticsearch.Ref) ([]semanticsearch.Hit, error) } +// chunkLocator loest einen Zitat-Verweis auf den tatsaechlich im Index +// gespeicherten Abschnittstext auf (SEM-05 AK2), unabhaengig von der zum +// Antwortzeitpunkt in Sources mitgefuehrten Kopie. +type chunkLocator interface { + GetChunk(ctx context.Context, tenantSlug, sourceType, sourceID string, chunkIndex int) (string, error) +} + +// Citation ist ein einzelner Quellenverweis einer Antwort (SEM-05 AK1). Index +// entspricht der im Antworttext verwendeten Klammer-Nummerierung ([1], [2], ...), +// so wie sie im Prompt (buildPrompt) vergeben wird. +type Citation struct { + Index int `json:"index"` + SourceType string `json:"source_type"` + SourceID string `json:"source_id"` + ChunkIndex int `json:"chunk_index"` +} + // Answer ist die Antwort auf eine Chat-Frage samt den Abschnitten, auf die sie -// sich stützt (AK1). NoEvidence ist true, wenn keine Modellantwort erzeugt wurde. +// sich stützt (AK1). NoEvidence ist true, wenn keine Modellantwort erzeugt wurde; +// eine Antwort mit NoEvidence führt keine Citations (AK3: unbelegt bleibt unbelegt). type Answer struct { Text string `json:"text"` Sources []semanticsearch.Hit `json:"sources"` + Citations []Citation `json:"citations"` NoEvidence bool `json:"no_evidence"` // SuspiciousPatterns listet auffällige Instruktions-Muster, die vor der @@ -41,10 +61,11 @@ type Service struct { search searcher gen provider.Provider model string + locate chunkLocator } -func New(search searcher, gen provider.Provider, model string) *Service { - return &Service{search: search, gen: gen, model: model} +func New(search searcher, gen provider.Provider, model string, locate chunkLocator) *Service { + return &Service{search: search, gen: gen, model: model, locate: locate} } // Ask sucht zuerst relevante Abschnitte (topK), dann erzeugt es — nur wenn @@ -88,7 +109,28 @@ func (s *Service) Ask(ctx context.Context, tenantSlug, question string, topK int return Answer{}, fmt.Errorf("docchat: antwort erzeugen: %w", err) } - return Answer{Text: resp.Message.Content, Sources: hits, SuspiciousPatterns: suspicious}, nil + citations := make([]Citation, len(hits)) + for i, h := range hits { + citations[i] = Citation{Index: i + 1, SourceType: h.SourceType, SourceID: h.SourceID, ChunkIndex: h.ChunkIndex} + } + + return Answer{Text: resp.Message.Content, Sources: hits, Citations: citations, SuspiciousPatterns: suspicious}, nil +} + +// ResolveCitation liefert den tatsächlich im Index gespeicherten Abschnittstext +// zu einem Zitat-Verweis (AK2: der Verweis führt zum tatsächlichen Abschnitt im +// Dokument, Prüfung 2: Sprung zum Originalabschnitt). Weicht das Ergebnis vom +// zum Antwortzeitpunkt zitierten Text ab (Dokument seither geändert/gelöscht), +// bekommt der Aufrufer das über vectorindex.ErrChunkNotFound zu sehen, statt +// stillschweigend einen veralteten Stand zu zeigen. +func (s *Service) ResolveCitation(ctx context.Context, tenantSlug string, c Citation) (string, error) { + if s.locate == nil { + return "", errors.New("docchat: kein abschnitt-index zum aufloesen von zitaten konfiguriert") + } + if tenantSlug == "" { + return "", errors.New("docchat: tenant_slug ist pflicht") + } + return s.locate.GetChunk(ctx, tenantSlug, c.SourceType, c.SourceID, c.ChunkIndex) } const systemPrompt = "Du beantwortest Fragen ausschließlich anhand der mitgelieferten Abschnitte. " + diff --git a/ai/internal/docchat/docchat_real_test.go b/ai/internal/docchat/docchat_real_test.go index 8c240569..10b78332 100644 --- a/ai/internal/docchat/docchat_real_test.go +++ b/ai/internal/docchat/docchat_real_test.go @@ -43,7 +43,7 @@ func TestAsk_Real_BelegAntwortZuordnungUndAntwortzeit(t *testing.T) { {SourceType: "document", SourceID: "vertrag-42", ChunkIndex: 0, ChunkText: "Die Kuendigungsfrist fuer diesen Vertrag betraegt drei Monate zum Quartalsende.", Score: 0.95}, } - svc := New(stubSearcher{hits: hits}, p, model) + svc := New(stubSearcher{hits: hits}, p, model, nil) start := time.Now() ans, err := svc.Ask(context.Background(), "acme", "Wie lang ist die Kuendigungsfrist?", 5, @@ -73,7 +73,7 @@ func TestAsk_Real_KeinTrefferKeinModellaufruf(t *testing.T) { p := ollama.New(base) gen := &countingProvider{Provider: p} - svc := New(stubSearcher{hits: nil}, gen, model) + svc := New(stubSearcher{hits: nil}, gen, model, nil) ans, err := svc.Ask(context.Background(), "acme", "Frage ohne jeden Treffer", 5, []semanticsearch.Ref{{SourceType: "document", SourceID: "doc-1"}}) diff --git a/ai/internal/docchat/docchat_test.go b/ai/internal/docchat/docchat_test.go index e3e5830c..1c3a0728 100644 --- a/ai/internal/docchat/docchat_test.go +++ b/ai/internal/docchat/docchat_test.go @@ -43,6 +43,25 @@ func (p *stubProvider) Embed(ctx context.Context, req provider.EmbeddingRequest) return provider.EmbeddingResponse{}, errors.New("nicht implementiert") } +// stubLocator simuliert vectorindex.Store.GetChunk fuer Tests, ohne eine +// echte Datenbank zu benoetigen. +type stubLocator struct { + chunkText string + err error + calls int + lastTenant string + lastSourceID string + lastChunkIdx int +} + +func (l *stubLocator) GetChunk(ctx context.Context, tenantSlug, sourceType, sourceID string, chunkIndex int) (string, error) { + l.calls++ + l.lastTenant = tenantSlug + l.lastSourceID = sourceID + l.lastChunkIdx = chunkIndex + return l.chunkText, l.err +} + func TestAsk_AntwortStuetztSichAufAbgerufeneAbschnitte(t *testing.T) { hits := []semanticsearch.Hit{ {SourceType: "document", SourceID: "doc-1", ChunkIndex: 0, ChunkText: "Die Kuendigungsfrist betraegt drei Monate.", Score: 0.9}, @@ -50,7 +69,7 @@ func TestAsk_AntwortStuetztSichAufAbgerufeneAbschnitte(t *testing.T) { search := stubSearcher{hits: hits} gen := &stubProvider{response: provider.ChatResponse{Message: provider.Message{Role: provider.RoleAssistant, Content: "Die Kuendigungsfrist betraegt drei Monate [1]."}}} - svc := New(search, gen, "gpt-oss:20b") + svc := New(search, gen, "gpt-oss:20b", nil) ans, err := svc.Ask(context.Background(), "acme", "Wie lang ist die Kuendigungsfrist?", 5, []semanticsearch.Ref{{SourceType: "document", SourceID: "doc-1"}}) if err != nil { t.Fatalf("Ask: %v", err) @@ -73,7 +92,7 @@ func TestAsk_KeinTrefferKeinModellaufruf(t *testing.T) { search := stubSearcher{hits: nil} gen := &stubProvider{response: provider.ChatResponse{Message: provider.Message{Content: "sollte nie ankommen"}}} - svc := New(search, gen, "gpt-oss:20b") + svc := New(search, gen, "gpt-oss:20b", nil) ans, err := svc.Ask(context.Background(), "acme", "Frage ohne Treffer", 5, []semanticsearch.Ref{{SourceType: "document", SourceID: "doc-1"}}) if err != nil { t.Fatalf("Ask: %v", err) @@ -93,7 +112,7 @@ func TestAsk_KeineFreigegebenenReferenzen(t *testing.T) { search := stubSearcher{err: semanticsearch.ErrNoAllowedRefs} gen := &stubProvider{} - svc := New(search, gen, "gpt-oss:20b") + svc := New(search, gen, "gpt-oss:20b", nil) ans, err := svc.Ask(context.Background(), "acme", "Frage", 5, nil) if err != nil { t.Fatalf("Ask: %v", err) @@ -110,7 +129,7 @@ func TestAsk_SuchfehlerWirdDurchgereicht(t *testing.T) { search := stubSearcher{err: errors.New("index nicht erreichbar")} gen := &stubProvider{} - svc := New(search, gen, "gpt-oss:20b") + svc := New(search, gen, "gpt-oss:20b", nil) _, err := svc.Ask(context.Background(), "acme", "Frage", 5, []semanticsearch.Ref{{SourceType: "document", SourceID: "doc-1"}}) if err == nil { t.Fatal("erwartet einen Fehler, wenn die Suche fehlschlaegt") @@ -118,7 +137,7 @@ func TestAsk_SuchfehlerWirdDurchgereicht(t *testing.T) { } func TestAsk_LeereEingaben(t *testing.T) { - svc := New(stubSearcher{}, &stubProvider{}, "gpt-oss:20b") + svc := New(stubSearcher{}, &stubProvider{}, "gpt-oss:20b", nil) if _, err := svc.Ask(context.Background(), "", "Frage", 5, nil); err == nil { t.Fatal("erwartet Fehler bei leerem tenantSlug") } @@ -138,7 +157,7 @@ func TestAsk_EmbeddedInjectionInHit_StructurallySeparatedAndDetected(t *testing. search := stubSearcher{hits: hits} gen := &stubProvider{response: provider.ChatResponse{Message: provider.Message{Role: provider.RoleAssistant, Content: "Die Kuendigungsfrist betraegt drei Monate [1]."}}} - svc := New(search, gen, "gpt-oss:20b") + svc := New(search, gen, "gpt-oss:20b", nil) ans, err := svc.Ask(context.Background(), "acme", "Wie lang ist die Kuendigungsfrist?", 5, []semanticsearch.Ref{{SourceType: "document", SourceID: "doc-1"}}) if err != nil { t.Fatalf("Ask: %v", err) @@ -163,3 +182,98 @@ func TestAsk_EmbeddedInjectionInHit_StructurallySeparatedAndDetected(t *testing. t.Error("Systemanweisung enthält keinen Hinweis, Inhalt als Nutzdaten statt Anweisung zu behandeln") } } + +// SEM-05 AK1 + Pruefung 1: jede Antwort mit Treffern fuehrt mindestens einen +// Quellenverweis, dessen Klammer-Nummerierung zur zitierten Quelle passt. +func TestAsk_AntwortFuehrtQuellenverweise(t *testing.T) { + hits := []semanticsearch.Hit{ + {SourceType: "document", SourceID: "doc-1", ChunkIndex: 0, ChunkText: "Die Kuendigungsfrist betraegt drei Monate.", Score: 0.9}, + {SourceType: "document", SourceID: "doc-2", ChunkIndex: 3, ChunkText: "Verlaengerung um jeweils ein Jahr.", Score: 0.8}, + } + search := stubSearcher{hits: hits} + gen := &stubProvider{response: provider.ChatResponse{Message: provider.Message{Content: "Die Kuendigungsfrist betraegt drei Monate [1]."}}} + + svc := New(search, gen, "gpt-oss:20b", nil) + ans, err := svc.Ask(context.Background(), "acme", "Wie lang ist die Kuendigungsfrist?", 5, + []semanticsearch.Ref{{SourceType: "document", SourceID: "doc-1"}, {SourceType: "document", SourceID: "doc-2"}}) + if err != nil { + t.Fatalf("Ask: %v", err) + } + if len(ans.Citations) == 0 { + t.Fatal("erwarte mindestens einen Quellenverweis (AK1)") + } + if len(ans.Citations) != len(hits) { + t.Fatalf("Citations = %+v, erwartet einen Verweis je Treffer", ans.Citations) + } + for i, c := range ans.Citations { + if c.Index != i+1 { + t.Errorf("Citations[%d].Index = %d, erwartet %d (Klammer-Nummerierung im Prompt)", i, c.Index, i+1) + } + if c.SourceType != hits[i].SourceType || c.SourceID != hits[i].SourceID || c.ChunkIndex != hits[i].ChunkIndex { + t.Errorf("Citations[%d] = %+v, erwartet Verweis auf %+v", i, c, hits[i]) + } + } +} + +// SEM-05 AK3 + Pruefung 3: eine unbelegte Antwort (kein Treffer) fuehrt keine +// Citations und bleibt am festen NoEvidence-Text erkennbar, statt als +// gesicherte Aussage dargestellt zu werden. +func TestAsk_UnbelegteAntwort_KeineCitations(t *testing.T) { + search := stubSearcher{hits: nil} + gen := &stubProvider{response: provider.ChatResponse{Message: provider.Message{Content: "sollte nie ankommen"}}} + + svc := New(search, gen, "gpt-oss:20b", nil) + ans, err := svc.Ask(context.Background(), "acme", "Frage ohne Treffer", 5, []semanticsearch.Ref{{SourceType: "document", SourceID: "doc-1"}}) + if err != nil { + t.Fatalf("Ask: %v", err) + } + if !ans.NoEvidence { + t.Fatal("NoEvidence sollte true sein ohne Treffer") + } + if len(ans.Citations) != 0 { + t.Fatalf("Citations = %+v, erwartet keine Verweise bei unbelegter Antwort", ans.Citations) + } + if ans.Text != NoEvidenceAnswer { + t.Fatalf("Text = %q, erwartet den festen NoEvidence-Text statt einer als gesichert wirkenden Formulierung", ans.Text) + } +} + +// SEM-05 AK2 + Pruefung 2: ResolveCitation loest einen Zitat-Verweis auf den +// tatsaechlich im Index gespeicherten Abschnitt auf (Sprung zum Originalabschnitt). +func TestResolveCitation_LiefertAbschnittAusIndex(t *testing.T) { + locator := &stubLocator{chunkText: "Die Kuendigungsfrist betraegt drei Monate."} + svc := New(stubSearcher{}, &stubProvider{}, "gpt-oss:20b", locator) + + c := Citation{Index: 1, SourceType: "document", SourceID: "doc-1", ChunkIndex: 0} + text, err := svc.ResolveCitation(context.Background(), "acme", c) + if err != nil { + t.Fatalf("ResolveCitation: %v", err) + } + if text != locator.chunkText { + t.Fatalf("text = %q, erwartet den im Index gespeicherten Abschnitt %q", text, locator.chunkText) + } + if locator.calls != 1 || locator.lastTenant != "acme" || locator.lastSourceID != "doc-1" || locator.lastChunkIdx != 0 { + t.Fatalf("GetChunk falsch aufgerufen: calls=%d tenant=%q sourceID=%q chunkIdx=%d", + locator.calls, locator.lastTenant, locator.lastSourceID, locator.lastChunkIdx) + } +} + +// SEM-05 AK2: ohne konfigurierten Abschnitt-Index (z. B. Aufrufer, der nur +// Suche/Generierung nutzt) wird das explizit gemeldet statt einen leeren +// Abschnitt als gueltige Aufloesung vorzutaeuschen. +func TestResolveCitation_OhneLocator_ReturnsError(t *testing.T) { + svc := New(stubSearcher{}, &stubProvider{}, "gpt-oss:20b", nil) + if _, err := svc.ResolveCitation(context.Background(), "acme", Citation{SourceType: "document", SourceID: "doc-1"}); err == nil { + t.Fatal("erwarte Fehler ohne konfigurierten Locator") + } +} + +// SEM-05 AK2: ein Zitat, das nicht mehr aufloesbar ist (z. B. Dokument +// geloescht), wird als Fehler durchgereicht statt stillschweigend verworfen. +func TestResolveCitation_LocatorFehler_WirdDurchgereicht(t *testing.T) { + locator := &stubLocator{err: errors.New("abschnitt nicht gefunden")} + svc := New(stubSearcher{}, &stubProvider{}, "gpt-oss:20b", locator) + if _, err := svc.ResolveCitation(context.Background(), "acme", Citation{SourceType: "document", SourceID: "doc-1"}); err == nil { + t.Fatal("erwarte durchgereichten Locator-Fehler") + } +} diff --git a/ai/internal/vectorindex/vectorindex.go b/ai/internal/vectorindex/vectorindex.go index 02b83f5b..91ea3b2f 100644 --- a/ai/internal/vectorindex/vectorindex.go +++ b/ai/internal/vectorindex/vectorindex.go @@ -16,6 +16,11 @@ import ( "github.com/jackc/pgx/v5/pgxpool" ) +// ErrChunkNotFound signalisiert, dass ein Zitat (SourceType/SourceID/ChunkIndex) +// nicht mehr auf einen tatsächlichen Abschnitt im Index verweist (SEM-05 AK2), +// z. B. weil das Dokument seither gelöscht oder neu segmentiert wurde. +var ErrChunkNotFound = errors.New("vectorindex: abschnitt nicht gefunden") + type Store struct { pool *pgxpool.Pool } @@ -98,6 +103,25 @@ func (s *Store) Delete(ctx context.Context, tenantSlug, sourceType, sourceID str return nil } +// GetChunk liefert den tatsächlich gespeicherten Abschnittstext für einen +// Zitat-Verweis (SEM-05 AK2/Prüfung 2: Sprung zum Originalabschnitt). Anders +// als Search geht es hier um den exakten Abschnitt einer Quelle, nicht um +// Ähnlichkeit zu einer Anfrage. +func (s *Store) GetChunk(ctx context.Context, tenantSlug, sourceType, sourceID string, chunkIndex int) (string, error) { + if tenantSlug == "" || sourceType == "" || sourceID == "" { + return "", errors.New("vectorindex: tenant_slug, source_type und source_id sind pflicht") + } + var chunkText string + err := s.pool.QueryRow(ctx, ` + SELECT chunk_text FROM content_embeddings + WHERE tenant_slug = $1 AND source_type = $2 AND source_id = $3 AND chunk_index = $4 + `, tenantSlug, sourceType, sourceID, chunkIndex).Scan(&chunkText) + if err != nil { + return "", fmt.Errorf("%w: %s/%s#%d", ErrChunkNotFound, sourceType, sourceID, chunkIndex) + } + return chunkText, nil +} + func cosineSimilarity(a, b []float64) (float64, bool) { if len(a) != len(b) || len(a) == 0 { return 0, false diff --git a/ai/internal/vectorindex/vectorindex_test.go b/ai/internal/vectorindex/vectorindex_test.go index 53c0c052..494ea24a 100644 --- a/ai/internal/vectorindex/vectorindex_test.go +++ b/ai/internal/vectorindex/vectorindex_test.go @@ -2,6 +2,7 @@ package vectorindex_test import ( "context" + "errors" "os" "path/filepath" "testing" @@ -162,6 +163,42 @@ func TestDelete_RemovesAllChunksAndVectors(t *testing.T) { } } +// SEM-05 AK2/Pruefung 2: GetChunk liefert exakt den gespeicherten +// Abschnittstext zu einem Zitat-Verweis zurueck (Sprung zum Originalabschnitt). +func TestGetChunk_ReturnsStoredChunkText(t *testing.T) { + pool := newPool(t) + store := vectorindex.New(pool) + ctx := context.Background() + + insertChunk(t, pool, "acme", "dms_document", "doc-cite", 2, "Die Kuendigungsfrist betraegt drei Monate.", []float64{1, 0, 0}) + + got, err := store.GetChunk(ctx, "acme", "dms_document", "doc-cite", 2) + if err != nil { + t.Fatalf("getchunk: %v", err) + } + if got != "Die Kuendigungsfrist betraegt drei Monate." { + t.Fatalf("chunk_text = %q, want den gespeicherten Abschnitt", got) + } +} + +// SEM-05 AK2: ein Zitat, das nicht mehr auf einen tatsaechlichen Abschnitt +// verweist (falscher Index, anderer Mandant, geloeschtes Dokument), wird als +// solches gemeldet statt stillschweigend leeren Text zu liefern. +func TestGetChunk_UnknownReference_ReturnsErrChunkNotFound(t *testing.T) { + pool := newPool(t) + store := vectorindex.New(pool) + ctx := context.Background() + + insertChunk(t, pool, "acme", "dms_document", "doc-cite", 0, "vorhanden", []float64{1, 0, 0}) + + if _, err := store.GetChunk(ctx, "acme", "dms_document", "doc-cite", 9); !errors.Is(err, vectorindex.ErrChunkNotFound) { + t.Fatalf("err = %v, want ErrChunkNotFound (falscher chunk_index)", err) + } + if _, err := store.GetChunk(ctx, "globex", "dms_document", "doc-cite", 0); !errors.Is(err, vectorindex.ErrChunkNotFound) { + t.Fatalf("err = %v, want ErrChunkNotFound (anderer Mandant)", err) + } +} + // AK3 (Ergaenzung): Loeschung wirkt nur innerhalb des angegebenen Mandanten. func TestDelete_ScopedToTenant(t *testing.T) { pool := newPool(t)