Files
nexarch/ai/internal/classify/classify.go
T
patrickandClaude Sonnet 5 e6c7baf915
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 / 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
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Canceled after 0s
CLS-01: dokumentklassifizierung (generisches modell)
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 02:56:18 +02:00

144 lines
4.9 KiB
Go

// 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]
}