diff --git a/ai/docs/CLS-01-PRUEFPROTOKOLL.md b/ai/docs/CLS-01-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..b6e88864 --- /dev/null +++ b/ai/docs/CLS-01-PRUEFPROTOKOLL.md @@ -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. diff --git a/ai/internal/classify/classify.go b/ai/internal/classify/classify.go new file mode 100644 index 00000000..5256d156 --- /dev/null +++ b/ai/internal/classify/classify.go @@ -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] +} diff --git a/ai/internal/classify/classify_test.go b/ai/internal/classify/classify_test.go new file mode 100644 index 00000000..b081aa3a --- /dev/null +++ b/ai/internal/classify/classify_test.go @@ -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) + } +}