Merge PR #160: AI CLS-01 Dokumentklassifizierung

This commit is contained in:
patrick committed 2026-09-23 03:00:02 +02:00
commit 5ecec76cae
3 files changed
+366

No files matched your search

+36
View File
@@ -0,0 +1,36 @@
# CLS-01 – Prüfprotokoll: Dokumentklassifizierung (generisches Modell)
## Umsetzung
Neues Paket `internal/classify`. `Classifier.ClassifyWithSchema` nutzt den bestehenden `provider.Provider` aus ENG-01 direkt (synchron, kein Queueing über ENG-05 nötig — dieses Ticket verlangt eine unmittelbare Kategorie-mit-Konfidenz-Antwort, keine Stapelverarbeitung). Temperatur 0 für reproduzierbare Klassifizierung.
- **AK1 (Kategorie mit Konfidenz):** `Result{Category, Confidence, Reasoning}`.
- **AK2 (festes Ausgabeschema):** `Result` wird in jedem Fall zurückgegeben — auch bei kaputtem oder umformuliertem Modell-Output (`extractJSON` schneidet das JSON-Objekt aus umgebendem Text, `parseAndValidate` fängt Parse-Fehler ab).
- **AK3 (unbekannte Dokumentart):** Jede Kategorie außerhalb des konfigurierten `Schema` sowie jede vom Modell selbst als `"unbekannt"` gemeldete Zuordnung wird auf `UnknownCategory` mit Konfidenz 0 normalisiert.
## Prüfungen
| # | Prüfung | Ziel | Ergebnis | Bewertung |
|---|---|---|---|---|
| 1 | Genauigkeit an gelabeltem Testset | ≥ 50 % (Mindestwert für "generisches Modell", kein Fine-Tuning) | **6/6 = 100 %**, real gegen `office.sysops.de:11434`, Modell `gpt-oss:20b`, 3 Kategorien (rechnung/vertrag/kuendigung), Dauer 26,04 s | bestanden |
| 2 | Verhalten bei unbekannter Dokumentart | Kennzeichnung statt Rateergebnis | Kategorie außerhalb Schema → `unbekannt`/Konfidenz 0 (Test); Modell meldet selbst unbekannt → ebenfalls `unbekannt` (Test) | bestanden |
| 3 | Ausgabeschema in allen Testfällen eingehalten | `Result` immer wohlgeformt | Valides JSON, JSON mit Text drumherum, kaputtes JSON, Konfidenz außerhalb [0,1] (geklemmt), Providerfehler (durchgereicht, kein invalides `Result`) — je eigener Test | bestanden |
## Läufe auf 204
- `go vet ./...`, `go build ./...`: sauber.
- `go test -count=1 ./internal/classify/...` (ohne echten Ollama-Zugriff): 8 Unit-Tests grün.
- `go test -run TestClassifyWithSchema_RealOllama ./internal/classify/...` (mit `TEST_AI_OLLAMA_BASE_URL`/`TEST_AI_OLLAMA_MODEL`): 1 Integrationstest grün, 100 % Genauigkeit.
- `gofmt -l .`: leer.
- Vollständiger Modul-Lauf `go test ./...`: alle Pakete `ok`, keine Regression an `provider`, `queue`, `modelconfig`, `prompttemplate`.
## QA und Entscheidungen
- **Synchron statt über ENG-05-Queue:** Das Ticket verlangt eine Kategorie-mit-Konfidenz-Antwort für die Klassifizierung selbst, keine asynchrone Stapelverarbeitung. Die Queue bleibt für Aufrufer verfügbar, die Klassifizierung im Hintergrund fahren wollen (z. B. bei Massenimport) — das ist kein Gegensatz, `Classifier` ist ein einfacher Baustein, den ein Queue-Worker ebenso aufrufen kann.
- **Temperatur 0:** Reproduzierbarkeit für Klassifizierung ist wichtiger als Kreativität.
- **JSON-Extraktion statt strenges Format erzwingen:** Modelle liefern gelegentlich erklärenden Text um das JSON. Statt das als Fehler zu werten, wird das erste vollständige `{...}`-Objekt herausgeschnitten — das ist robuster und ändert nichts an AK2, weil am Ende immer noch das feste `Result`-Schema zurückkommt.
- **0 Iterationen nötig**, Design und Tests liefen beim ersten Durchlauf grün.
## Nicht Teil dieser Kachel
CLS-02 (Dokumenttyp-Erkennung), CLS-03 (Verschlagwortung), CLS-04 (wichtige Inhalte), CLS-05 (Schwelle & Feedback-Loop), WFS-01 (Vorschlagsmodell) — alle Folgetickets, `Classifier`/`Schema` sind bewusst generisch gehalten, damit sie dort wiederverwendbar sind.
+143
View File
@@ -0,0 +1,143 @@
// Package classify liefert eine generische, konfigurierbare Dokumentklassifizierung
// über ein Sprachmodell: Kategorie mit Konfidenz statt binärer Zuordnung (CLS-01 AK1),
// einem festen Ausgabeschema (AK2), und einer expliziten Kennzeichnung, wenn keine der
// konfigurierten Kategorien passt (AK3).
package classify
import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
)
// UnknownCategory kennzeichnet ein Dokument, das keiner der konfigurierten
// Kategorien zugeordnet werden konnte (AK3).
const UnknownCategory = "unbekannt"
// Result ist das feste Ausgabeschema einer Klassifizierung (AK2).
type Result struct {
Category string `json:"category"`
Confidence float64 `json:"confidence"`
Reasoning string `json:"reasoning,omitempty"`
}
// Schema ist das konfigurierbare Klassifizierungsschema: die Menge zulässiger
// Kategorien. Eine leere Liste ist ungültig.
type Schema struct {
Categories []string
}
func (s Schema) validate() error {
if len(s.Categories) == 0 {
return errors.New("classify: schema ohne kategorien")
}
seen := make(map[string]bool, len(s.Categories))
for _, c := range s.Categories {
if strings.TrimSpace(c) == "" {
return errors.New("classify: leere kategorie im schema")
}
if seen[c] {
return fmt.Errorf("classify: doppelte kategorie %q im schema", c)
}
seen[c] = true
}
return nil
}
// Classifier klassifiziert Dokumenttext anhand eines Schemas über einen
// Provider (ENG-01). Model ist der zu verwendende Modellname.
type Classifier struct {
Provider provider.Provider
Model string
}
// modelOutput ist das vom Sprachmodell erwartete rohe JSON, bevor es gegen
// das Schema geprüft wird.
type modelOutput struct {
Category string `json:"category"`
Confidence float64 `json:"confidence"`
Reasoning string `json:"reasoning"`
}
// ClassifyWithSchema ordnet den gegebenen Dokumenttext einer Kategorie aus
// schema zu, mit Konfidenz (AK1). Passt keine Kategorie oder liefert das
// Modell eine unbekannte/leere Kategorie, ist das Ergebnis UnknownCategory
// mit Konfidenz 0 (AK3). Das Ergebnis folgt in jedem Fall dem festen
// Ausgabeschema (AK2).
func (c *Classifier) ClassifyWithSchema(ctx context.Context, schema Schema, documentText string) (Result, error) {
if c.Provider == nil {
return Result{}, errors.New("classify: kein provider konfiguriert")
}
if err := schema.validate(); err != nil {
return Result{}, err
}
prompt := buildPrompt(schema, documentText)
resp, err := c.Provider.Chat(ctx, provider.ChatRequest{
Model: c.Model,
Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "Du bist ein Klassifizierungsassistent. Antworte ausschließlich mit einem einzelnen JSON-Objekt, ohne Erklärtext davor oder danach."},
{Role: provider.RoleUser, Content: prompt},
},
Temperature: 0,
})
if err != nil {
return Result{}, fmt.Errorf("classify: modellaufruf: %w", err)
}
return parseAndValidate(schema, resp.Message.Content), nil
}
func buildPrompt(schema Schema, documentText string) string {
var b strings.Builder
b.WriteString("Ordne das folgende Dokument genau einer dieser Kategorien zu: ")
b.WriteString(strings.Join(schema.Categories, ", "))
b.WriteString(fmt.Sprintf(". Passt keine Kategorie eindeutig, antworte mit der Kategorie %q und einer Konfidenz von 0.\n\n", UnknownCategory))
b.WriteString("Antworte NUR mit JSON in diesem Format: {\"category\": string, \"confidence\": number zwischen 0 und 1, \"reasoning\": string}.\n\n")
b.WriteString("Dokument:\n")
b.WriteString(documentText)
return b.String()
}
// parseAndValidate parst die Modellantwort und erzwingt das Ausgabeschema
// (AK2) sowie die Unbekannt-Kennzeichnung (AK3) für jeden Fall, in dem die
// Antwort nicht eindeutig einer konfigurierten Kategorie entspricht: leeres
// JSON, kaputtes JSON, Kategorie außerhalb des Schemas, oder eine vom Modell
// selbst als unbekannt gemeldete Zuordnung.
func parseAndValidate(schema Schema, raw string) Result {
jsonPart := extractJSON(raw)
var out modelOutput
if jsonPart == "" || json.Unmarshal([]byte(jsonPart), &out) != nil {
return Result{Category: UnknownCategory, Confidence: 0, Reasoning: "antwort nicht als json auswertbar"}
}
if out.Confidence < 0 {
out.Confidence = 0
}
if out.Confidence > 1 {
out.Confidence = 1
}
for _, cat := range schema.Categories {
if cat == out.Category {
return Result{Category: out.Category, Confidence: out.Confidence, Reasoning: out.Reasoning}
}
}
return Result{Category: UnknownCategory, Confidence: 0, Reasoning: out.Reasoning}
}
// extractJSON schneidet das erste vollständige {...}-Objekt aus raw heraus,
// falls das Modell zusätzlichen Text um das JSON herum liefert.
func extractJSON(raw string) string {
start := strings.IndexByte(raw, '{')
end := strings.LastIndexByte(raw, '}')
if start < 0 || end < start {
return ""
}
return raw[start : end+1]
}
+187
View File
@@ -0,0 +1,187 @@
package classify
import (
"context"
"os"
"testing"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/mock"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/ollama"
)
var testSchema = Schema{Categories: []string{"rechnung", "vertrag", "kuendigung"}}
// AK1 + Prüfung 3: Klassifizierung liefert Kategorie mit Konfidenz, im festen Schema.
func TestClassifyWithSchema_ValidResponse_ReturnsCategoryWithConfidence(t *testing.T) {
p := &mock.Provider{ChatResponse: provider.ChatResponse{
Message: provider.Message{Content: `{"category":"rechnung","confidence":0.92,"reasoning":"enthält Rechnungsnummer und Betrag"}`},
}}
c := &Classifier{Provider: p, Model: "test-model"}
got, err := c.ClassifyWithSchema(context.Background(), testSchema, "Rechnung Nr. 123, Betrag 50,00 EUR")
if err != nil {
t.Fatalf("ClassifyWithSchema: %v", err)
}
if got.Category != "rechnung" {
t.Errorf("Category = %q, want rechnung", got.Category)
}
if got.Confidence != 0.92 {
t.Errorf("Confidence = %v, want 0.92", got.Confidence)
}
}
// AK2 + Prüfung 3: Ausgabeschema wird auch bei Text rund um das JSON eingehalten.
func TestClassifyWithSchema_ExtraTextAroundJSON_StillParsesSchema(t *testing.T) {
p := &mock.Provider{ChatResponse: provider.ChatResponse{
Message: provider.Message{Content: "Hier ist meine Einschätzung:\n{\"category\":\"vertrag\",\"confidence\":0.7,\"reasoning\":\"Vertragsklauseln erkannt\"}\nEnde."},
}}
c := &Classifier{Provider: p, Model: "test-model"}
got, err := c.ClassifyWithSchema(context.Background(), testSchema, "irrelevant")
if err != nil {
t.Fatalf("ClassifyWithSchema: %v", err)
}
if got.Category != "vertrag" || got.Confidence != 0.7 {
t.Errorf("got %+v, want category=vertrag confidence=0.7", got)
}
}
// AK3 + Prüfung 2: unbekannte Dokumentart (Modell antwortet außerhalb des Schemas) wird gekennzeichnet.
func TestClassifyWithSchema_CategoryOutsideSchema_MarkedUnknown(t *testing.T) {
p := &mock.Provider{ChatResponse: provider.ChatResponse{
Message: provider.Message{Content: `{"category":"lieferschein","confidence":0.6,"reasoning":"passt nicht ins Schema"}`},
}}
c := &Classifier{Provider: p, Model: "test-model"}
got, err := c.ClassifyWithSchema(context.Background(), testSchema, "irrelevant")
if err != nil {
t.Fatalf("ClassifyWithSchema: %v", err)
}
if got.Category != UnknownCategory {
t.Errorf("Category = %q, want %q", got.Category, UnknownCategory)
}
if got.Confidence != 0 {
t.Errorf("Confidence = %v, want 0 bei unbekannter Kategorie", got.Confidence)
}
}
// AK3 + Prüfung 2: Modell meldet selbst die Unbekannt-Kategorie.
func TestClassifyWithSchema_ModelReportsUnknown_KeptUnknown(t *testing.T) {
p := &mock.Provider{ChatResponse: provider.ChatResponse{
Message: provider.Message{Content: `{"category":"unbekannt","confidence":0,"reasoning":"keine Kategorie passt"}`},
}}
c := &Classifier{Provider: p, Model: "test-model"}
got, err := c.ClassifyWithSchema(context.Background(), testSchema, "voellig fremdartiger inhalt")
if err != nil {
t.Fatalf("ClassifyWithSchema: %v", err)
}
if got.Category != UnknownCategory {
t.Errorf("Category = %q, want %q", got.Category, UnknownCategory)
}
}
// AK2 + Prüfung 3: kaputtes JSON wird nicht durchgereicht, sondern als unbekannt mit festem Schema markiert.
func TestClassifyWithSchema_MalformedJSON_ReturnsUnknownNotError(t *testing.T) {
p := &mock.Provider{ChatResponse: provider.ChatResponse{
Message: provider.Message{Content: "Ich bin mir nicht sicher, was das für ein Dokument ist."},
}}
c := &Classifier{Provider: p, Model: "test-model"}
got, err := c.ClassifyWithSchema(context.Background(), testSchema, "irrelevant")
if err != nil {
t.Fatalf("ClassifyWithSchema soll bei kaputtem Modell-Output keinen Fehler liefern, sondern Unknown: %v", err)
}
if got.Category != UnknownCategory {
t.Errorf("Category = %q, want %q", got.Category, UnknownCategory)
}
}
// Konfidenz wird auf [0,1] begrenzt, auch wenn das Modell einen Ausreißer liefert.
func TestClassifyWithSchema_ConfidenceOutOfRange_Clamped(t *testing.T) {
p := &mock.Provider{ChatResponse: provider.ChatResponse{
Message: provider.Message{Content: `{"category":"rechnung","confidence":1.5,"reasoning":"x"}`},
}}
c := &Classifier{Provider: p, Model: "test-model"}
got, err := c.ClassifyWithSchema(context.Background(), testSchema, "irrelevant")
if err != nil {
t.Fatalf("ClassifyWithSchema: %v", err)
}
if got.Confidence != 1 {
t.Errorf("Confidence = %v, want geklemmt auf 1", got.Confidence)
}
}
// Leeres Schema ist ungültig und wird ohne Modellaufruf abgewiesen.
func TestClassifyWithSchema_EmptySchema_Rejected(t *testing.T) {
p := &mock.Provider{}
c := &Classifier{Provider: p, Model: "test-model"}
_, err := c.ClassifyWithSchema(context.Background(), Schema{}, "irrelevant")
if err == nil {
t.Fatal("erwarte Fehler bei leerem Schema")
}
}
// Providerfehler wird durchgereicht (AK3 der Provider-Schnittstelle aus ENG-01: einheitliches Fehlerformat).
func TestClassifyWithSchema_ProviderError_Propagated(t *testing.T) {
p := &mock.Provider{Err: &provider.Error{Provider: "mock", Code: provider.CodeUnavailable, Message: "down"}}
c := &Classifier{Provider: p, Model: "test-model"}
_, err := c.ClassifyWithSchema(context.Background(), testSchema, "irrelevant")
if err == nil {
t.Fatal("erwarte Fehler bei nicht erreichbarem Provider")
}
if !provider.IsCode(err, provider.CodeUnavailable) {
t.Errorf("erwarte durchgereichten CodeUnavailable, got %v", err)
}
}
// Prüfung 1: Genauigkeit an einem gelabelten Testset, real gegen den externen Ollama-Server.
func TestClassifyWithSchema_RealOllama_AccuracyOnLabelledSet(t *testing.T) {
base := os.Getenv("TEST_AI_OLLAMA_BASE_URL")
model := os.Getenv("TEST_AI_OLLAMA_MODEL")
if base == "" || model == "" {
t.Skip("TEST_AI_OLLAMA_BASE_URL/TEST_AI_OLLAMA_MODEL nicht gesetzt, Integrationstest übersprungen")
}
p := ollama.New(base)
if err := p.Health(context.Background()); err != nil {
t.Skipf("Ollama nicht erreichbar: %v", err)
}
c := &Classifier{Provider: p, Model: model}
set := []struct {
text string
want string
}{
{"Rechnung Nr. 2026-451, Rechnungsdatum 01.03.2026, Gesamtbetrag 1.234,50 EUR, zahlbar bis 15.03.2026, Umsatzsteuer 19%.", "rechnung"},
{"Mietvertrag zwischen Vermieter und Mieter über die Wohnung Musterstraße 5, Laufzeit ab 01.04.2026, monatliche Miete 850 EUR.", "vertrag"},
{"Hiermit kündige ich fristgerecht zum 30.06.2026 meinen Vertrag mit der Kundennummer 998877.", "kuendigung"},
{"Sehr geehrte Damen und Herren, anbei erhalten Sie unsere Rechnung über die im letzten Quartal erbrachten Leistungen.", "rechnung"},
{"Der vorliegende Vertrag regelt die Zusammenarbeit beider Parteien für die Dauer von 24 Monaten.", "vertrag"},
{"Wir kündigen den bestehenden Wartungsvertrag mit sofortiger Wirkung wegen wiederholter Vertragsverletzung.", "kuendigung"},
}
correct := 0
for i, tc := range set {
got, err := c.ClassifyWithSchema(context.Background(), testSchema, tc.text)
if err != nil {
t.Fatalf("Fall %d: ClassifyWithSchema: %v", i, err)
}
if got.Category != testSchema.Categories[0] && got.Category != testSchema.Categories[1] && got.Category != testSchema.Categories[2] && got.Category != UnknownCategory {
t.Errorf("Fall %d: Category %q verletzt das Schema", i, got.Category)
}
if got.Category == tc.want {
correct++
} else {
t.Logf("Fall %d: erwartet %q, erhalten %q (Konfidenz %.2f)", i, tc.want, got.Category, got.Confidence)
}
}
accuracy := float64(correct) / float64(len(set))
t.Logf("Genauigkeit am gelabelten Testset: %d/%d = %.0f%%", correct, len(set), accuracy*100)
if accuracy < 0.5 {
t.Errorf("Genauigkeit %.0f%% unter dem Mindestwert von 50%%", accuracy*100)
}
}