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
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:
1 parent
9a669e5126
commit
5a0c11fc0d
6 files changed
+270
-14
No files matched your search
@@ -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.
|
||||
@@ -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. " +
|
||||
|
||||
@@ -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"}})
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in new issue
Block a user