From 7818c5464fbfa34223c4a9f3f8bd49169b3a608e Mon Sep 17 00:00:00 2001 From: patrick Date: Fri, 6 Nov 2026 20:15:59 +0100 Subject: [PATCH 1/2] ai: Titel- und Korrespondent-Vorschlag (titlesuggest, correspondentsuggest) Vorschlagen mit Konfidenz, keine automatische Uebernahme. Korrespondent wird auf bestehende Schreibweise normalisiert. Co-Authored-By: Claude Sonnet 5.5 --- .../correspondentsuggest.go | 90 +++++++++++++++++ .../correspondentsuggest_test.go | 52 ++++++++++ ai/internal/titlesuggest/titlesuggest.go | 97 +++++++++++++++++++ ai/internal/titlesuggest/titlesuggest_test.go | 61 ++++++++++++ 4 files changed, 300 insertions(+) create mode 100644 ai/internal/correspondentsuggest/correspondentsuggest.go create mode 100644 ai/internal/correspondentsuggest/correspondentsuggest_test.go create mode 100644 ai/internal/titlesuggest/titlesuggest.go create mode 100644 ai/internal/titlesuggest/titlesuggest_test.go diff --git a/ai/internal/correspondentsuggest/correspondentsuggest.go b/ai/internal/correspondentsuggest/correspondentsuggest.go new file mode 100644 index 00000000..8b3a8843 --- /dev/null +++ b/ai/internal/correspondentsuggest/correspondentsuggest.go @@ -0,0 +1,90 @@ +package correspondentsuggest + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/provider" +) + +type Suggestion struct { + Name string `json:"name"` + Confidence float64 `json:"confidence"` + Existing bool `json:"existing"` +} + +type Suggester struct { + Provider provider.Provider + Model string +} + +func (s *Suggester) Suggest(ctx context.Context, existing []string, moduleContext, documentText string) (Suggestion, bool, error) { + if s.Provider == nil { + return Suggestion{}, false, errors.New("correspondentsuggest: kein provider konfiguriert") + } + resp, err := s.Provider.Chat(ctx, provider.ChatRequest{ + Model: s.Model, + Messages: []provider.Message{ + {Role: provider.RoleSystem, Content: "Du bist ein Assistent zur Absender-Erkennung. Antworte ausschließlich mit einem JSON-Objekt, ohne Erklärtext."}, + {Role: provider.RoleUser, Content: buildPrompt(existing, moduleContext, documentText)}, + }, + Temperature: 0, + }) + if err != nil { + return Suggestion{}, false, fmt.Errorf("correspondentsuggest: modellaufruf: %w", err) + } + sug, ok := parse(existing, resp.Message.Content) + return sug, ok, nil +} + +func buildPrompt(existing []string, moduleContext, documentText string) string { + var b strings.Builder + b.WriteString("Nenne den Korrespondenten (Absender bzw. Gegenpartei) des folgenden Dokuments.\n") + if moduleContext != "" { + fmt.Fprintf(&b, "Kontext: das Dokument stammt aus dem Modul %q.\n", moduleContext) + } + if len(existing) > 0 { + b.WriteString("Bekannte Korrespondenten (bevorzugt exakt einen davon wählen): ") + b.WriteString(strings.Join(existing, ", ")) + b.WriteString("\n") + } + b.WriteString(`Antworte NUR mit einem JSON-Objekt: {"name": string, "confidence": number zwischen 0 und 1}. Ist kein Korrespondent erkennbar, setze name auf "".` + "\n\nDokument:\n") + b.WriteString(documentText) + return b.String() +} + +func parse(existing []string, raw string) (Suggestion, bool) { + start, end := strings.IndexByte(raw, '{'), strings.LastIndexByte(raw, '}') + if start < 0 || end < start { + return Suggestion{}, false + } + var in Suggestion + if err := json.Unmarshal([]byte(raw[start:end+1]), &in); err != nil { + return Suggestion{}, false + } + name := strings.Join(strings.Fields(in.Name), " ") + if name == "" { + return Suggestion{}, false + } + out := Suggestion{Name: name, Confidence: clamp(in.Confidence)} + for _, e := range existing { + if strings.EqualFold(strings.Join(strings.Fields(e), " "), name) { + out.Name, out.Existing = e, true + break + } + } + return out, true +} + +func clamp(v float64) float64 { + if v < 0 { + return 0 + } + if v > 1 { + return 1 + } + return v +} diff --git a/ai/internal/correspondentsuggest/correspondentsuggest_test.go b/ai/internal/correspondentsuggest/correspondentsuggest_test.go new file mode 100644 index 00000000..466c75e6 --- /dev/null +++ b/ai/internal/correspondentsuggest/correspondentsuggest_test.go @@ -0,0 +1,52 @@ +package correspondentsuggest + +import ( + "context" + "errors" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/provider" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/mock" +) + +func newSuggester(resp string) *Suggester { + return &Suggester{ + Provider: &mock.Provider{ChatResponse: provider.ChatResponse{Message: provider.Message{Role: provider.RoleAssistant, Content: resp}}}, + } +} + +func TestSuggest_NormalizesToExisting(t *testing.T) { + got, ok, err := newSuggester(`{"name":" muster gmbh ","confidence":0.8}`). + Suggest(context.Background(), []string{"Beispiel AG", "Muster GmbH"}, "dms", "Brief") + if err != nil || !ok { + t.Fatalf("ok=%v err=%v", ok, err) + } + if got.Name != "Muster GmbH" || !got.Existing || got.Confidence != 0.8 { + t.Fatalf("unerwartet: %+v", got) + } +} + +func TestSuggest_NewCorrespondentNotMarkedExisting(t *testing.T) { + got, ok, _ := newSuggester(`{"name":"Neu KG","confidence":7}`).Suggest(context.Background(), []string{"Alt AG"}, "", "x") + if !ok || got.Existing || got.Confidence != 1 { + t.Fatalf("unerwartet: %+v ok=%v", got, ok) + } +} + +func TestSuggest_NoneOrGarbage(t *testing.T) { + for _, raw := range []string{`{"name":"","confidence":0.9}`, "nix", `{"name":`} { + if _, ok, err := newSuggester(raw).Suggest(context.Background(), nil, "", "x"); ok || err != nil { + t.Fatalf("raw=%q ok=%v err=%v", raw, ok, err) + } + } +} + +func TestSuggest_Errors(t *testing.T) { + s := &Suggester{Provider: &mock.Provider{Err: errors.New("down")}} + if _, _, err := s.Suggest(context.Background(), nil, "", "x"); err == nil { + t.Fatal("fehler erwartet") + } + if _, _, err := (&Suggester{}).Suggest(context.Background(), nil, "", "x"); err == nil { + t.Fatal("fehler erwartet") + } +} diff --git a/ai/internal/titlesuggest/titlesuggest.go b/ai/internal/titlesuggest/titlesuggest.go new file mode 100644 index 00000000..5cc84c53 --- /dev/null +++ b/ai/internal/titlesuggest/titlesuggest.go @@ -0,0 +1,97 @@ +package titlesuggest + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/provider" +) + +const ( + maxTitleRunes = 120 + maxSuggestions = 3 +) + +type Suggestion struct { + Title string `json:"title"` + Confidence float64 `json:"confidence"` +} + +type Suggester struct { + Provider provider.Provider + Model string +} + +func (s *Suggester) Suggest(ctx context.Context, moduleContext, language, documentText string) ([]Suggestion, error) { + if s.Provider == nil { + return nil, errors.New("titlesuggest: kein provider konfiguriert") + } + resp, err := s.Provider.Chat(ctx, provider.ChatRequest{ + Model: s.Model, + Messages: []provider.Message{ + {Role: provider.RoleSystem, Content: "Du bist ein Assistent für Dokumenttitel. Antworte ausschließlich mit einem JSON-Array von Objekten, ohne Erklärtext."}, + {Role: provider.RoleUser, Content: buildPrompt(moduleContext, language, documentText)}, + }, + Temperature: 0, + }) + if err != nil { + return nil, fmt.Errorf("titlesuggest: modellaufruf: %w", err) + } + return parse(resp.Message.Content), nil +} + +func buildPrompt(moduleContext, language, documentText string) string { + var b strings.Builder + b.WriteString("Schlage prägnante Titel für das folgende Dokument vor.\n") + if moduleContext != "" { + fmt.Fprintf(&b, "Kontext: das Dokument stammt aus dem Modul %q.\n", moduleContext) + } + if language != "" { + fmt.Fprintf(&b, "Schreibe die Titel in der Sprache %q.\n", language) + } + fmt.Fprintf(&b, "Antworte NUR mit einem JSON-Array: [{\"title\": string, \"confidence\": number zwischen 0 und 1}, ...]. Höchstens %d Titel, je höchstens %d Zeichen.\n\nDokument:\n", maxSuggestions, maxTitleRunes) + b.WriteString(documentText) + return b.String() +} + +func parse(raw string) []Suggestion { + start, end := strings.IndexByte(raw, '['), strings.LastIndexByte(raw, ']') + if start < 0 || end < start { + return nil + } + var in []Suggestion + if err := json.Unmarshal([]byte(raw[start:end+1]), &in); err != nil { + return nil + } + seen := make(map[string]bool) + var out []Suggestion + for _, s := range in { + title := strings.Join(strings.Fields(s.Title), " ") + if r := []rune(title); len(r) > maxTitleRunes { + title = strings.TrimSpace(string(r[:maxTitleRunes])) + } + key := strings.ToLower(title) + if title == "" || seen[key] { + continue + } + seen[key] = true + out = append(out, Suggestion{Title: title, Confidence: clamp(s.Confidence)}) + if len(out) == maxSuggestions { + break + } + } + return out +} + +func clamp(v float64) float64 { + if v < 0 { + return 0 + } + if v > 1 { + return 1 + } + return v +} diff --git a/ai/internal/titlesuggest/titlesuggest_test.go b/ai/internal/titlesuggest/titlesuggest_test.go new file mode 100644 index 00000000..8472a8a3 --- /dev/null +++ b/ai/internal/titlesuggest/titlesuggest_test.go @@ -0,0 +1,61 @@ +package titlesuggest + +import ( + "context" + "errors" + "strings" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/provider" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/mock" +) + +func newSuggester(resp string) *Suggester { + return &Suggester{ + Provider: &mock.Provider{ChatResponse: provider.ChatResponse{Message: provider.Message{Role: provider.RoleAssistant, Content: resp}}}, + Model: "test", + } +} + +func TestSuggest_ReturnsConfidencePerTitle(t *testing.T) { + got, err := newSuggester(`[{"title":"Rechnung 2026-001","confidence":0.9},{"title":"Rechnung Muster GmbH","confidence":2}]`). + Suggest(context.Background(), "dms", "de", "Rechnung") + if err != nil { + t.Fatal(err) + } + if len(got) != 2 || got[0].Confidence != 0.9 || got[1].Confidence != 1 { + t.Fatalf("unerwartet: %+v", got) + } +} + +func TestSuggest_DedupTrimAndLimit(t *testing.T) { + long := strings.Repeat("a", 300) + got, err := newSuggester(`[{"title":" Vertrag A ","confidence":0.5},{"title":"vertrag a","confidence":0.4},{"title":"","confidence":1},{"title":"`+long+`","confidence":0.3},{"title":"B","confidence":0.2},{"title":"C","confidence":0.1}]`). + Suggest(context.Background(), "", "", "x") + if err != nil { + t.Fatal(err) + } + if len(got) != 3 || got[0].Title != "Vertrag A" { + t.Fatalf("unerwartet: %+v", got) + } + if n := len([]rune(got[1].Title)); n != maxTitleRunes { + t.Fatalf("titel nicht gekuerzt: %d", n) + } +} + +func TestSuggest_GarbageYieldsEmpty(t *testing.T) { + got, err := newSuggester("kein json").Suggest(context.Background(), "", "", "x") + if err != nil || len(got) != 0 { + t.Fatalf("got=%v err=%v", got, err) + } +} + +func TestSuggest_ProviderErrorAndNil(t *testing.T) { + s := &Suggester{Provider: &mock.Provider{Err: errors.New("down")}} + if _, err := s.Suggest(context.Background(), "", "", "x"); err == nil { + t.Fatal("fehler erwartet") + } + if _, err := (&Suggester{}).Suggest(context.Background(), "", "", "x"); err == nil { + t.Fatal("fehler erwartet") + } +} -- 2.47.3 From eebc87c4c181a6620d637c4a30fc69cd1602c3e8 Mon Sep 17 00:00:00 2001 From: patrick Date: Fri, 6 Nov 2026 22:57:59 +0100 Subject: [PATCH 2/2] ai: Realtests und Pruefprotokolle CLS-06 und CLS-07 Prompt fuer unbekannte Korrespondenten praezisiert (Realtest 3/4 auf 4/4). Co-Authored-By: Claude Sonnet 5.5 --- ai/docs/CLS-06-PRUEFPROTOKOLL.md | 30 ++++++++++ ai/docs/CLS-07-PRUEFPROTOKOLL.md | 33 +++++++++++ .../correspondentsuggest.go | 2 +- .../correspondentsuggest_real_test.go | 47 +++++++++++++++ .../titlesuggest/titlesuggest_real_test.go | 59 +++++++++++++++++++ 5 files changed, 170 insertions(+), 1 deletion(-) create mode 100644 ai/docs/CLS-06-PRUEFPROTOKOLL.md create mode 100644 ai/docs/CLS-07-PRUEFPROTOKOLL.md create mode 100644 ai/internal/correspondentsuggest/correspondentsuggest_real_test.go create mode 100644 ai/internal/titlesuggest/titlesuggest_real_test.go diff --git a/ai/docs/CLS-06-PRUEFPROTOKOLL.md b/ai/docs/CLS-06-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..adde9f4a --- /dev/null +++ b/ai/docs/CLS-06-PRUEFPROTOKOLL.md @@ -0,0 +1,30 @@ +# CLS-06 – Automatischer Titelvorschlag – Prüfprotokoll + +Datum: 2026-11-06. Host: Entwicklungshost 192.168.1.204. Externer Ollama-Server `office.sysops.de:11435` (Text), Modell `gemma4:26b` (`gpt-oss:20b` aus CLS-03 ist dort nicht mehr verfügbar). Ausschließlich synthetische Dokumente. + +## Umsetzung + +Neues Paket `ai/internal/titlesuggest`: `Suggester.Suggest(ctx, moduleContext, language, documentText)` liefert bis zu 3 `Suggestion{Title, Confidence}`. Baut auf `provider.Provider` (ENG-01) auf, ohne Schreibpfad. Titel werden von Leerraum bereinigt, auf 120 Zeichen gekürzt und case-unabhängig dedupliziert. Kaputte Modellantwort ergibt eine leere Liste. + +## Akzeptanzkriterien + +| # | Kriterium | Ergebnis | +|---|---|---| +| 1 | Bis zu drei Titelvorschläge mit Konfidenz | **Erfüllt.** `TestSuggest_ReturnsConfidencePerTitle`, Konfidenz auf [0,1] geklammert. | +| 2 | Titel bereinigt, gekürzt, ohne Dopplung | **Erfüllt.** `TestSuggest_DedupTrimAndLimit`. | +| 3 | Vorschlag ohne automatische Übernahme | **Erfüllt.** Strukturell: kein Pool, kein Client außer `provider.Provider`. | +| 4 | Fehlerhafte Modellantwort ergibt leere Liste statt Fehler | **Erfüllt.** `TestSuggest_GarbageYieldsEmpty`; Providerfehler und fehlender Provider werden als Fehler gemeldet (`TestSuggest_ProviderErrorAndNil`). | + +## Prüfungen vor der Abnahme + +| # | Prüfung | Zielwert | Ergebnis | +|---|---|---|---| +| 1 | Akzeptanzkriterien mit Mock-Provider belegt | alle 4 | **Bestanden.** 4/4 Unit-Tests grün. | +| 2 | Trefferquote an synthetischem Testset gegen echtes Modell | dokumentiert | **Bestanden, 5/5.** `TestSuggest_RealInference_Accuracy`: Rechnung, Vertrag, Lieferschein, Zahlungserinnerung, englische Rechnung; jeweils enthielt mindestens ein Titel den erwarteten Begriff. Laufzeit 54 s. | +| 3 | Keine automatische Übernahme geprüft | strukturell | **Bestanden.** Siehe AK3. | + +## Einschränkungen + +- Testset mit 5 Fällen; "Treffer" heißt: ein erwarteter Begriff kommt im Titel vor, nicht, dass der Titel sprachlich optimal ist. +- Keine HTTP-API und keine Verdrahtung in `cmd/ai-app` (nicht Teil der Kriterien). +- Der Parameter `language` ist ein reiner Prompt-Hinweis; die Spracherkennung liefert CLS-08. diff --git a/ai/docs/CLS-07-PRUEFPROTOKOLL.md b/ai/docs/CLS-07-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..e959b5b7 --- /dev/null +++ b/ai/docs/CLS-07-PRUEFPROTOKOLL.md @@ -0,0 +1,33 @@ +# CLS-07 – Automatischer Korrespondenten-Vorschlag – Prüfprotokoll + +Datum: 2026-11-06. Host: Entwicklungshost 192.168.1.204. Externer Ollama-Server `office.sysops.de:11435` (Text), Modell `gemma4:26b`. Ausschließlich synthetische Dokumente, keine echten Kunden- oder Mandantendaten. + +## Umsetzung + +Neues Paket `ai/internal/correspondentsuggest`: `Suggester.Suggest(ctx, existing, moduleContext, documentText)` liefert `(Suggestion{Name, Confidence, Existing}, ok, error)`. Ein Treffer auf einen bekannten Korrespondenten (case- und leerraumunabhängig) übernimmt dessen exakte Schreibweise und setzt `Existing=true`. Ohne erkennbaren Korrespondenten ist `ok=false`. Kein Schreibpfad. + +## Akzeptanzkriterien + +| # | Kriterium | Ergebnis | +|---|---|---| +| 1 | Korrespondent mit Konfidenz | **Erfüllt.** `TestSuggest_NormalizesToExisting`, Konfidenz geklammert (`TestSuggest_NewCorrespondentNotMarkedExisting`). | +| 2 | Bekannter Korrespondent übernimmt exakte Schreibweise, als bestehend markiert | **Erfüllt.** `TestSuggest_NormalizesToExisting`. | +| 3 | Kein erkennbarer Korrespondent ergibt keinen Vorschlag | **Erfüllt.** `TestSuggest_NoneOrGarbage` (leerer Name, Müll, abgeschnittenes JSON). | +| 4 | Vorschlag ohne automatische Übernahme | **Erfüllt.** Strukturell, kein Schreibpfad. | + +## Prüfungen vor der Abnahme + +| # | Prüfung | Zielwert | Ergebnis | +|---|---|---|---| +| 1 | Akzeptanzkriterien mit Mock-Provider belegt | alle 4 | **Bestanden.** 4/4 Unit-Tests grün. | +| 2 | Trefferquote an synthetischem Testset gegen echtes Modell | dokumentiert | **Bestanden, 4/4** im zweiten Lauf (Fälle: 3 bekannte Korrespondenten, 1 neuer). Laufzeit 66 s. | +| 3 | Keine echten Kunden-/Mandantendaten im Testset | keine | **Bestanden.** Nur Muster-/Beispiel-Namen. | + +## Befund und Korrektur + +Erster Realtest-Lauf: 3/4. Bei unbekanntem Absender ("Neuer Absender KG") lieferte das Modell keinen Namen, weil der Prompt "bevorzugt exakt einen davon wählen" zu stark auf die Liste festlegte. Prompt geändert auf "wähle exakt einen davon, wenn er der Absender ist; ist der Absender keiner davon, nenne ihn so, wie er im Dokument steht". Zweiter Lauf: 4/4. Unit-Tests blieben grün. + +## Einschränkungen + +- Testset mit 4 Fällen, ein Fall für den Pfad "neuer Korrespondent". +- Anlage neuer Korrespondenten im DMS, API und Verdrahtung in `cmd/ai-app` sind nicht Teil der Kachel. diff --git a/ai/internal/correspondentsuggest/correspondentsuggest.go b/ai/internal/correspondentsuggest/correspondentsuggest.go index 8b3a8843..c882ed13 100644 --- a/ai/internal/correspondentsuggest/correspondentsuggest.go +++ b/ai/internal/correspondentsuggest/correspondentsuggest.go @@ -47,7 +47,7 @@ func buildPrompt(existing []string, moduleContext, documentText string) string { fmt.Fprintf(&b, "Kontext: das Dokument stammt aus dem Modul %q.\n", moduleContext) } if len(existing) > 0 { - b.WriteString("Bekannte Korrespondenten (bevorzugt exakt einen davon wählen): ") + b.WriteString("Bekannte Korrespondenten (wähle exakt einen davon, wenn er der Absender ist; ist der Absender keiner davon, nenne ihn so, wie er im Dokument steht): ") b.WriteString(strings.Join(existing, ", ")) b.WriteString("\n") } diff --git a/ai/internal/correspondentsuggest/correspondentsuggest_real_test.go b/ai/internal/correspondentsuggest/correspondentsuggest_real_test.go new file mode 100644 index 00000000..6bfa27e9 --- /dev/null +++ b/ai/internal/correspondentsuggest/correspondentsuggest_real_test.go @@ -0,0 +1,47 @@ +package correspondentsuggest + +import ( + "context" + "os" + "testing" + "time" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/ollama" +) + +func TestSuggest_RealInference_Accuracy(t *testing.T) { + base, model := os.Getenv("TEST_OLLAMA_BASE_URL"), os.Getenv("TEST_OLLAMA_MODEL") + if base == "" || model == "" { + t.Skip("TEST_OLLAMA_BASE_URL/TEST_OLLAMA_MODEL nicht gesetzt, Realtest uebersprungen") + } + s := &Suggester{Provider: ollama.New(base), Model: model} + existing := []string{"Musterfirma GmbH", "Beispiel AG", "Stadtwerke Beispielstadt"} + + cases := []struct { + name, text, want string + wantExisting bool + }{ + {"rechnung", "Musterfirma GmbH\nMusterstrasse 1, 12345 Musterstadt\n\nRechnung Nr. 2024-0451 an Beispiel Consulting\nBetrag 1.297,00 EUR", "Musterfirma GmbH", true}, + {"vertrag", "Kuendigung des Dienstleistungsvertrags\nBeispiel AG, Vorstand\nBeispielweg 5, 54321 Beispieldorf\n\nSehr geehrte Damen und Herren, hiermit kuendigen wir den Vertrag zum Quartalsende.", "Beispiel AG", true}, + {"neu", "Neuer Absender KG\nTeststrasse 9\n\nAngebot Nr. 17 fuer Buerobedarf, gueltig bis 31.12.2026.", "Neuer Absender KG", false}, + {"strom", "Stadtwerke Beispielstadt\nJahresabrechnung Strom 2026\nVerbrauch 2.431 kWh, Nachzahlung 84,12 EUR", "Stadtwerke Beispielstadt", true}, + } + + hits := 0 + for _, c := range cases { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + got, ok, err := s.Suggest(ctx, existing, "dms", c.text) + cancel() + if err != nil { + t.Fatalf("%s: %v", c.name, err) + } + t.Logf("%s: ok=%v %+v", c.name, ok, got) + if ok && got.Name == c.want && got.Existing == c.wantExisting && got.Confidence >= 0 && got.Confidence <= 1 { + hits++ + } + } + t.Logf("Trefferquote: %d/%d", hits, len(cases)) + if hits < 3 { + t.Errorf("trefferquote %d/%d unter 3/4", hits, len(cases)) + } +} diff --git a/ai/internal/titlesuggest/titlesuggest_real_test.go b/ai/internal/titlesuggest/titlesuggest_real_test.go new file mode 100644 index 00000000..0fc99d78 --- /dev/null +++ b/ai/internal/titlesuggest/titlesuggest_real_test.go @@ -0,0 +1,59 @@ +package titlesuggest + +import ( + "context" + "os" + "strings" + "testing" + "time" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/ollama" +) + +func TestSuggest_RealInference_Accuracy(t *testing.T) { + base, model := os.Getenv("TEST_OLLAMA_BASE_URL"), os.Getenv("TEST_OLLAMA_MODEL") + if base == "" || model == "" { + t.Skip("TEST_OLLAMA_BASE_URL/TEST_OLLAMA_MODEL nicht gesetzt, Realtest uebersprungen") + } + s := &Suggester{Provider: ollama.New(base), Model: model} + + cases := []struct { + name, text string + want []string + }{ + {"rechnung", "Rechnung Nr. 2024-0451\nMusterfirma GmbH\nBetrag: 1.297,00 EUR zzgl. 19% USt\nLeistung: Beratung September 2026", []string{"rechnung", "2024-0451"}}, + {"vertrag", "Dienstleistungsvertrag zwischen Musterfirma GmbH und Beispiel AG. Laufzeit 24 Monate, Kuendigungsfrist 3 Monate zum Quartalsende.", []string{"vertrag"}}, + {"lieferschein", "Lieferschein Nr. 88213\n20x Aktenordner, 5x Tonerkartuschen\nLieferung am 03.10.2026 an Musterstrasse 1", []string{"lieferschein", "88213"}}, + {"mahnung", "Zahlungserinnerung: Die Rechnung 2024-0451 vom 15.11.2026 ist noch offen. Bitte zahlen Sie bis zum 30.11.2026.", []string{"mahnung", "erinnerung", "zahlung"}}, + {"invoice-en", "Invoice No. 7731, Sample Corp. Amount due: 540.00 EUR. Payment terms: 14 days net.", []string{"invoice", "7731", "rechnung"}}, + } + + hits := 0 + for _, c := range cases { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + got, err := s.Suggest(ctx, "dms", "Deutsch", c.text) + cancel() + if err != nil { + t.Fatalf("%s: %v", c.name, err) + } + hit := false + for _, g := range got { + if g.Confidence < 0 || g.Confidence > 1 { + t.Errorf("%s: konfidenz ausserhalb [0,1]: %v", c.name, g.Confidence) + } + for _, w := range c.want { + if strings.Contains(strings.ToLower(g.Title), w) { + hit = true + } + } + } + t.Logf("%s: hit=%v vorschlaege=%+v", c.name, hit, got) + if hit { + hits++ + } + } + t.Logf("Trefferquote: %d/%d", hits, len(cases)) + if hits < 3 { + t.Errorf("trefferquote %d/%d unter 3/5", hits, len(cases)) + } +} -- 2.47.3