Merge PR #160: AI CLS-01 Dokumentklassifizierung
This commit is contained in:
commit
5ecec76cae
3 files changed
+366
No files matched your search
@@ -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.
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user