SEM-05: quellenverweise & zitat-nachvollziehbarkeit
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) 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
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

Citations je Chat-Antwort (docchat.Answer.Citations) mit Klammer-
Nummerierung passend zum Prompt, ResolveCitation loest einen Verweis
auf den tatsaechlich im Index gespeicherten Abschnitt auf
(vectorindex.Store.GetChunk). Unbelegte Antworten fuehren keine
Citations.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
patrickandClaude Sonnet 5 committed 2026-09-28 00:30:25 +00:00
1 parent 9a669e5126
commit 5a0c11fc0d
6 files changed
+270 -14

No files matched your search

+39
View File
@@ -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.
+48 -6
View File
@@ -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. " +
+2 -2
View File
@@ -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"}})
+120 -6
View File
@@ -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")
}
}
+24
View File
@@ -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
@@ -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)