diff --git a/ai/cmd/ai-app/main.go b/ai/cmd/ai-app/main.go index 8b9c22c4..91c9a71d 100644 --- a/ai/cmd/ai-app/main.go +++ b/ai/cmd/ai-app/main.go @@ -12,6 +12,7 @@ import ( "net/http" "os" "os/signal" + "strings" "syscall" "time" @@ -19,8 +20,11 @@ import ( "gitea.perlbach24.de/scripte/nexarch/ai/internal/extract" "gitea.perlbach24.de/scripte/nexarch/ai/internal/extractapi" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/extractconfirmapi" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/extractmapping" "gitea.perlbach24.de/scripte/nexarch/ai/internal/extractvalidate" "gitea.perlbach24.de/scripte/nexarch/ai/internal/invoiceextract" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/metadatapushclient" "gitea.perlbach24.de/scripte/nexarch/ai/internal/provider" "gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/ollama" "gitea.perlbach24.de/scripte/nexarch/ai/internal/queue" @@ -46,6 +50,25 @@ type config struct { serviceToken string addr string model string + moduleURLs map[string]string +} + +// parseModuleBaseURLs liest "modul=url,modul2=url2" (EXT-05: je Modul eine +// Basis-URL fuer den Rueckschreib-Push in dessen Metadaten-API). +func parseModuleBaseURLs(raw string) map[string]string { + urls := map[string]string{} + for _, pair := range strings.Split(raw, ",") { + pair = strings.TrimSpace(pair) + if pair == "" { + continue + } + name, url, ok := strings.Cut(pair, "=") + if !ok || strings.TrimSpace(name) == "" || strings.TrimSpace(url) == "" { + continue + } + urls[strings.TrimSpace(name)] = strings.TrimSpace(url) + } + return urls } // loadConfig liest die Pflicht- und optionalen Umgebungsvariablen (AK3, AK4): @@ -57,6 +80,7 @@ func loadConfig(getenv func(string) string) (config, error) { serviceToken: getenv("NEXARCH_AI_APP_SERVICE_TOKEN"), addr: getenv("NEXARCH_AI_APP_LISTEN_ADDR"), model: getenv("NEXARCH_AI_APP_MODEL"), + moduleURLs: parseModuleBaseURLs(getenv("NEXARCH_AI_APP_MODULE_BASE_URLS")), } if cfg.dsn == "" { return config{}, errors.New("ai-app: NEXARCH_AI_APP_TENANT_DSN ist erforderlich") @@ -109,6 +133,16 @@ func build(pool *pgxpool.Pool, cfg config, prov provider.Provider, workerID stri routingsuggestapi.NewServer(cfg.serviceToken).RegisterRoutes(mux) + moduleClients := make(map[string]*metadatapushclient.Client, len(cfg.moduleURLs)) + for name, baseURL := range cfg.moduleURLs { + moduleClients[name] = metadatapushclient.New(baseURL, cfg.serviceToken) + } + extractconfirmapi.NewServer( + &extractmapping.Registry{Pool: pool}, + moduleClients, + cfg.serviceToken, + ).RegisterRoutes(mux) + quotaStore := quota.NewStore(pool, nil) q := queue.NewQueue(pool, 5*time.Minute) q.SetQuotaEnforcer(quotaStore) diff --git a/ai/docs/EXT-05-PRUEFPROTOKOLL.md b/ai/docs/EXT-05-PRUEFPROTOKOLL.md new file mode 100644 index 00000000..e53857a6 --- /dev/null +++ b/ai/docs/EXT-05-PRUEFPROTOKOLL.md @@ -0,0 +1,59 @@ +# EXT-05 – Prüfprotokoll + +Kopplung bestätigter Extraktionsergebnisse an die Metadatenfelder des aufrufenden Moduls (DMS/Mail) über den Modul-Adapter. + +## Umsetzung + +- `ai/internal/extractmapping`: Postgres-gestützte Registry für das Feld-Mapping (Extraktionsfeld → Modul-Feldschlüssel), je Modul/Objekttyp konfigurierbar (AK2), analog zum bestehenden `moduletype`-Muster. `Apply` ordnet bestätigte Werte zu und meldet Extraktionsfelder ohne Mapping-Eintrag statt sie zu verwerfen (AK3). +- `ai/internal/metadatapushclient`: schlanker HTTP-Client zur Metadaten-API des aufrufenden Moduls (Muster wie `auditclient`): löst Modul-Feldschlüssel zu den internen Feld-IDs des Moduls auf (`GET /metadata-fields`) und schreibt die Werte (`PUT /documents/{id}/metadata`). Ein Feldschlüssel, den das Modul aktuell nicht registriert hat, wird gemeldet statt den gesamten Aufruf zu verwerfen (AK3). +- `ai/internal/extractconfirmapi`: neuer, Service-Token-geschützter Endpunkt `POST /extract/confirm` (Fail-Closed wie `extractapi`), der Mapping-Anwendung und Push zusammenführt und `applied_fields`/`unmapped_fields` zurückmeldet (AK1). +- Migration `0009_extract_field_mappings`: Tabelle `extract_field_mappings`. +- `ai/cmd/ai-app/main.go`: neuer Endpunkt verdrahtet; Modul-Basis-URLs je Modul über `NEXARCH_AI_APP_MODULE_BASE_URLS` (Format `modul=url,modul2=url2`) konfigurierbar. + +## Automatisierte Tests (je Akzeptanzkriterium) + +| AK | Test | Ergebnis | +|---|---|---| +| AK1 (Wert landet im richtigen Metadatenfeld) | `extractmapping.TestApply_MapsKnownFieldsAndReportsUnmapped`, `metadatapushclient.TestPush_ResolvesFieldKeyToIDAndWrites`, `extractconfirmapi.TestHandleConfirm_AppliesMappedValueToModuleField` | PASS | +| AK2 (Mapping je Modul konfigurierbar) | `extractmapping.TestRegistry_SetAndGet`, `TestRegistry_Set_ReplacesExisting` | PASS | +| AK3 (nicht zuordenbarer Wert wird gemeldet, nicht verworfen) | `extractmapping.TestApply_EmptyMapping_ReportsAllAsUnmapped`, `metadatapushclient.TestPush_UnknownFieldKey_ReportedNotDropped`, `TestPush_AllUnknownFieldKeys_NoWriteAttempted`, `extractconfirmapi.TestHandleConfirm_MisconfiguredMapping_ReportsUnmapped`, `TestHandleConfirm_ValueWithoutTargetField_ReportedNotDiscarded` | PASS | +| Zugriffsschutz | `extractconfirmapi.TestHandleConfirm_RequiresServiceToken` | PASS | + +Ausführung (host 204, `TEST_TENANT_DSN` aus `/etc/nexarch-dev/tenant-dev.env`): + +``` +go test ./internal/extractmapping/... ./internal/metadatapushclient/... ./internal/extractconfirmapi/... +ok .../extractmapping 0.503s +ok .../metadatapushclient (cached) +ok .../extractconfirmapi 0.616s +``` + +Vollständiger Modul-Testlauf (`go vet ./... && go build ./... && go test ./...`) ohne Regressionen auf host 204. + +## Prüfungen vor der Abnahme (reale Durchführung auf root@192.168.1.204) + +Aufbau: die unveränderte, echte `dms/internal/metadataapi`-Implementierung (inkl. `metadatafield`-Validierung) wurde über einen temporären, nicht committeten Test-Server (`dms/cmd/ext05-tmp-metaserver`, nach der Prüfung gelöscht) auf einem eigenen TCP-Port gegen ein eigenes, isoliertes Postgres-Schema in `tenantdb` gestartet – reale Zieldatenbank und realer Code des Moduls, keine Simulation. Ein Feld `rechnungsdatum` (Typ `date`) und ein Dokument wurden angelegt. Ein zweites, ebenfalls temporäres Werkzeug (`ai/cmd/ext05-tmp-pruefung`, nach der Prüfung gelöscht) rief den echten `extractconfirmapi`-Endpunkt auf, dessen `metadatapushclient` real per HTTP gegen den laufenden dms-Metadaten-Server pushte. + +1. **Übernahme in DMS-Metadatenfeld im Test nachvollzogen.** + Zielwert: bestätigter Wert `invoice_date=2026-01-15` landet über das Mapping `invoice_date→rechnungsdatum` im DMS-Metadatenfeld des Dokuments. + Ergebnis: `POST /extract/confirm` lieferte `{"applied_fields":["rechnungsdatum"],"unmapped_fields":[]}`. Per `psql` direkt gegen das dms-Test-Schema verifiziert: `SELECT field_key, value FROM document_metadata_values v JOIN metadata_fields f ON f.id=v.field_id` lieferte `rechnungsdatum | 2026-01-15`. **Erreicht.** + +2. **Fehlkonfiguriertes Mapping im Test erkannt.** + Zielwert: eine Anfrage für einen Objekttyp ohne konfiguriertes Mapping (`dms/unbekannter_objekttyp`) schreibt nichts und meldet den Wert als nicht zuordenbar. + Ergebnis: `POST /extract/confirm` lieferte `{"applied_fields":[],"unmapped_fields":["invoice_date"]}`, kein Schreibzugriff auf das dms-Metadatenfeld erfolgte. **Erreicht.** + +3. **Nicht zuordenbarer Wert im Test korrekt gemeldet.** + Zielwert: ein bestätigter Wert ohne Mapping-Eintrag (`vendor`) wird gemeldet statt verworfen, während der zuordenbare Wert (`invoice_date→rechnungsdatum`, neuer Wert `2026-03-01`) trotzdem übernommen wird. + Ergebnis: `POST /extract/confirm` lieferte `{"applied_fields":["rechnungsdatum"],"unmapped_fields":["vendor"]}`. Per `psql` verifiziert: `rechnungsdatum | 2026-03-01` (überschrieben, wie erwartet bei erneutem Push desselben Feldschlüssels). **Erreicht.** + +Aufräumen: Prozess beendet, temporäre Postgres-Schemata (`ext05tmp_*`, `ext05aimap*`) per `DROP SCHEMA ... CASCADE` entfernt, temporäre Binaries/Logs gelöscht, temporäre Quelldateien (`dms/cmd/ext05-tmp-metaserver`, `ai/cmd/ext05-tmp-pruefung`) aus dem Arbeitsverzeichnis entfernt und nicht committet. Host 131 und archivdms-Daten wurden nicht berührt. + +## Nicht Bestandteil (laut Ticket) + +`QA-03` (Prüfgate Klassifizierung & Extraktion) – nicht umgesetzt, wie im Ticket vorgesehen. + +## Offene Punkte / Entscheidungen + +- Modul-Feldschlüssel statt interner Feld-ID als Mapping-Ziel gewählt (stabiler über Feld-Neuanlage hinweg); `metadatapushclient` löst den Schlüssel bei jedem Push frisch über `GET /metadata-fields` auf. +- Modul-Basis-URLs werden serverseitig über Umgebungsvariablen konfiguriert (nicht aus dem Request übernommen), um SSRF über einen beliebigen, vom Aufrufer übergebenen Ziel-Host zu vermeiden. +- Mail-seitige Anbindung folgt demselben, modulunabhängigen `metadatapushclient`-Vertrag (Feldliste + Werte-PUT); ein konkreter Mail-Metadaten-Endpunkt war zum Zeitpunkt dieses Tickets nicht Teil des Scopes und wurde nicht geprüft. diff --git a/ai/internal/extractconfirmapi/extractconfirmapi.go b/ai/internal/extractconfirmapi/extractconfirmapi.go new file mode 100644 index 00000000..cecf67bb --- /dev/null +++ b/ai/internal/extractconfirmapi/extractconfirmapi.go @@ -0,0 +1,137 @@ +// Package extractconfirmapi ist die HTTP-Bindung fuer EXT-05: bestaetigte +// Extraktionswerte (nach EXT-04-Freigabe durch den Nutzer) werden ueber das +// je Modul/Objekttyp konfigurierte Feld-Mapping (extractmapping) den +// tatsaechlichen Metadatenfeldern des aufrufenden Moduls zugeordnet und +// dorthin geschrieben (metadatapushclient). Aufrufer ist die +// Backend-for-Frontend-Route des anfragenden Moduls (DMS/Mail), +// Service-Token-Pflicht wie extractapi, fail closed. +package extractconfirmapi + +import ( + "encoding/json" + "errors" + "net/http" + "sort" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/extractmapping" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/metadatapushclient" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/svcauth" +) + +type Server struct { + mapping *extractmapping.Registry + moduleClients map[string]*metadatapushclient.Client + serviceToken string +} + +func NewServer(mapping *extractmapping.Registry, moduleClients map[string]*metadatapushclient.Client, serviceToken string) *Server { + return &Server{mapping: mapping, moduleClients: moduleClients, serviceToken: serviceToken} +} + +func (s *Server) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("POST /extract/confirm", s.requireAuth(s.handleConfirm)) +} + +func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + got := r.Header.Get("X-Service-Token") + if !svcauth.Equal(got, s.serviceToken) { + writeError(w, http.StatusUnauthorized, "service-token fehlt oder ist ungueltig") + return + } + next(w, r) + } +} + +type confirmRequest struct { + ModuleName string `json:"module_name"` + ObjectType string `json:"object_type"` + ObjectID string `json:"object_id"` + Caller string `json:"caller"` + Values map[string]string `json:"values"` +} + +type confirmResponse struct { + // AppliedFields sind Modul-Feldschluessel, die erfolgreich geschrieben + // wurden (AK1). + AppliedFields []string `json:"applied_fields"` + // UnmappedFields sind Extraktionsfelder bzw. Modul-Feldschluessel, die + // sich mangels Mapping oder mangels registriertem Zielfeld nicht + // zuordnen liessen (AK3: gemeldet statt verworfen). + UnmappedFields []string `json:"unmapped_fields"` +} + +func (s *Server) handleConfirm(w http.ResponseWriter, r *http.Request) { + var req confirmRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper") + return + } + if req.ModuleName == "" || req.ObjectType == "" || req.ObjectID == "" || req.Caller == "" { + writeError(w, http.StatusBadRequest, "module_name, object_type, object_id und caller sind pflicht") + return + } + if len(req.Values) == 0 { + writeError(w, http.StatusBadRequest, "keine werte zur uebernahme") + return + } + + client, ok := s.moduleClients[req.ModuleName] + if !ok { + writeError(w, http.StatusBadRequest, "modul \""+req.ModuleName+"\" ist nicht konfiguriert") + return + } + + mapping, err := s.mapping.Get(r.Context(), req.ModuleName, req.ObjectType) + var mapped map[string]string + unmapped := make([]string, 0, len(req.Values)) + if err != nil { + if !errors.Is(err, extractmapping.ErrNotConfigured) { + writeError(w, http.StatusInternalServerError, "mapping konnte nicht geladen werden") + return + } + // Fehlkonfiguration (kein Mapping hinterlegt): jeder Wert ist + // unzuordenbar, wird aber gemeldet statt verworfen (AK3). + for name := range req.Values { + unmapped = append(unmapped, name) + } + mapped = map[string]string{} + } else { + applied := extractmapping.Apply(mapping, req.Values) + mapped = applied.Mapped + unmapped = append(unmapped, applied.Unmapped...) + } + + appliedFields := make([]string, 0, len(mapped)) + if len(mapped) > 0 { + pushResult, err := client.Push(r.Context(), req.ObjectID, req.Caller, mapped) + if err != nil { + writeError(w, http.StatusBadGateway, "uebernahme in modul-metadatenfeld fehlgeschlagen: "+err.Error()) + return + } + unresolved := make(map[string]bool, len(pushResult.Unresolved)) + for _, key := range pushResult.Unresolved { + unresolved[key] = true + unmapped = append(unmapped, key) + } + for key := range mapped { + if !unresolved[key] { + appliedFields = append(appliedFields, key) + } + } + } + sort.Strings(appliedFields) + sort.Strings(unmapped) + + writeJSON(w, http.StatusOK, confirmResponse{AppliedFields: appliedFields, UnmappedFields: unmapped}) +} + +func writeJSON(w http.ResponseWriter, status int, v any) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(v) +} + +func writeError(w http.ResponseWriter, status int, message string) { + writeJSON(w, status, map[string]string{"error": message}) +} diff --git a/ai/internal/extractconfirmapi/extractconfirmapi_test.go b/ai/internal/extractconfirmapi/extractconfirmapi_test.go new file mode 100644 index 00000000..c3186926 --- /dev/null +++ b/ai/internal/extractconfirmapi/extractconfirmapi_test.go @@ -0,0 +1,225 @@ +package extractconfirmapi + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/extractmapping" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/metadatapushclient" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb" +) + +func newMappingRegistry(t *testing.T) *extractmapping.Registry { + t.Helper() + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen") + } + pool := testdb.Pool(t, dsn) + migrationsDir, err := filepath.Abs("../../migrations") + if err != nil { + t.Fatalf("migrationsverzeichnis: %v", err) + } + migrations, err := migrate.Load(migrationsDir) + if err != nil { + t.Fatalf("migrationen laden: %v", err) + } + if _, err := migrate.Up(context.Background(), pool, migrations); err != nil { + t.Fatalf("migrationen anwenden: %v", err) + } + return &extractmapping.Registry{Pool: pool} +} + +type fakeField struct { + ID string `json:"id"` + Key string `json:"key"` +} + +// newFakeModule stellt die Metadaten-API-Kontraktflaeche des aufrufenden +// Moduls nach (GET /metadata-fields, PUT /documents/{id}/metadata) und +// merkt sich geschriebene Werte je Feld-ID. +func newFakeModule(t *testing.T, fields []fakeField) (*httptest.Server, *map[string]string) { + t.Helper() + written := map[string]string{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/metadata-fields": + _ = json.NewEncoder(w).Encode(fields) + case r.Method == http.MethodPut: + var body map[string]string + _ = json.NewDecoder(r.Body).Decode(&body) + for k, v := range body { + written[k] = v + } + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unerwarteter aufruf %s %s", r.Method, r.URL.Path) + } + })) + return srv, &written +} + +// TestHandleConfirm_AppliesMappedValueToModuleField deckt AK1+AK2 ab: ein +// bestaetigter Wert landet ueber das konfigurierte Mapping im richtigen +// Metadatenfeld des Moduls (Pruefung 1). +func TestHandleConfirm_AppliesMappedValueToModuleField(t *testing.T) { + mappingRegistry := newMappingRegistry(t) + ctx := context.Background() + if err := mappingRegistry.Set(ctx, "dms", "invoice", map[string]string{"invoice_date": "rechnungsdatum"}); err != nil { + t.Fatalf("mapping setzen: %v", err) + } + + fakeModule, written := newFakeModule(t, []fakeField{{ID: "field-uuid-1", Key: "rechnungsdatum"}}) + defer fakeModule.Close() + + srv := NewServer(mappingRegistry, map[string]*metadatapushclient.Client{ + "dms": metadatapushclient.New(fakeModule.URL, "svc-token"), + }, "svc-token") + mux := http.NewServeMux() + srv.RegisterRoutes(mux) + testServer := httptest.NewServer(mux) + defer testServer.Close() + + resp := postConfirm(t, testServer.URL, "svc-token", confirmRequest{ + ModuleName: "dms", ObjectType: "invoice", ObjectID: "doc-1", Caller: "ai", + Values: map[string]string{"invoice_date": "2026-01-15"}, + }) + + if len(resp.AppliedFields) != 1 || resp.AppliedFields[0] != "rechnungsdatum" { + t.Fatalf("applied_fields = %+v, want [rechnungsdatum]", resp.AppliedFields) + } + if len(resp.UnmappedFields) != 0 { + t.Fatalf("unmapped_fields = %+v, want leer", resp.UnmappedFields) + } + if (*written)["field-uuid-1"] != "2026-01-15" { + t.Fatalf("wert nicht im modul-metadatenfeld angekommen: %+v", *written) + } +} + +// TestHandleConfirm_MisconfiguredMapping_ReportsUnmapped deckt AK3 ab: ein +// fehlkonfiguriertes (fehlendes) Mapping wird im Test erkannt und gemeldet, +// nicht stillschweigend verworfen (Pruefung 2). +func TestHandleConfirm_MisconfiguredMapping_ReportsUnmapped(t *testing.T) { + mappingRegistry := newMappingRegistry(t) + + fakeModule, written := newFakeModule(t, []fakeField{{ID: "field-uuid-1", Key: "rechnungsdatum"}}) + defer fakeModule.Close() + + srv := NewServer(mappingRegistry, map[string]*metadatapushclient.Client{ + "dms": metadatapushclient.New(fakeModule.URL, "svc-token"), + }, "svc-token") + mux := http.NewServeMux() + srv.RegisterRoutes(mux) + testServer := httptest.NewServer(mux) + defer testServer.Close() + + // Kein Mapping fuer "dms"/"unbekannter_objekttyp" konfiguriert. + resp := postConfirm(t, testServer.URL, "svc-token", confirmRequest{ + ModuleName: "dms", ObjectType: "unbekannter_objekttyp", ObjectID: "doc-1", Caller: "ai", + Values: map[string]string{"invoice_date": "2026-01-15"}, + }) + + if len(resp.AppliedFields) != 0 { + t.Fatalf("applied_fields = %+v, want leer", resp.AppliedFields) + } + if len(resp.UnmappedFields) != 1 || resp.UnmappedFields[0] != "invoice_date" { + t.Fatalf("unmapped_fields = %+v, want [invoice_date]", resp.UnmappedFields) + } + if len(*written) != 0 { + t.Fatalf("trotz fehlendem mapping wurde geschrieben: %+v", *written) + } +} + +// TestHandleConfirm_ValueWithoutTargetField_ReportedNotDiscarded deckt +// AK3 ab: ein Extraktionsfeld ohne Mapping-Eintrag wird gemeldet, waehrend +// die uebrigen, zuordenbaren Werte trotzdem uebernommen werden +// (Pruefung 3). +func TestHandleConfirm_ValueWithoutTargetField_ReportedNotDiscarded(t *testing.T) { + mappingRegistry := newMappingRegistry(t) + ctx := context.Background() + if err := mappingRegistry.Set(ctx, "dms", "invoice", map[string]string{"invoice_date": "rechnungsdatum"}); err != nil { + t.Fatalf("mapping setzen: %v", err) + } + + fakeModule, written := newFakeModule(t, []fakeField{{ID: "field-uuid-1", Key: "rechnungsdatum"}}) + defer fakeModule.Close() + + srv := NewServer(mappingRegistry, map[string]*metadatapushclient.Client{ + "dms": metadatapushclient.New(fakeModule.URL, "svc-token"), + }, "svc-token") + mux := http.NewServeMux() + srv.RegisterRoutes(mux) + testServer := httptest.NewServer(mux) + defer testServer.Close() + + resp := postConfirm(t, testServer.URL, "svc-token", confirmRequest{ + ModuleName: "dms", ObjectType: "invoice", ObjectID: "doc-1", Caller: "ai", + Values: map[string]string{ + "invoice_date": "2026-01-15", + "vendor": "Acme GmbH", // kein Mapping-Eintrag fuer "vendor" + }, + }) + + if len(resp.AppliedFields) != 1 || resp.AppliedFields[0] != "rechnungsdatum" { + t.Fatalf("applied_fields = %+v, want [rechnungsdatum]", resp.AppliedFields) + } + if len(resp.UnmappedFields) != 1 || resp.UnmappedFields[0] != "vendor" { + t.Fatalf("unmapped_fields = %+v, want [vendor]", resp.UnmappedFields) + } + if (*written)["field-uuid-1"] != "2026-01-15" { + t.Fatalf("zuordenbarer wert wurde trotz gemeldetem restwert nicht uebernommen: %+v", *written) + } +} + +func TestHandleConfirm_RequiresServiceToken(t *testing.T) { + mappingRegistry := newMappingRegistry(t) + srv := NewServer(mappingRegistry, map[string]*metadatapushclient.Client{}, "svc-token") + mux := http.NewServeMux() + srv.RegisterRoutes(mux) + testServer := httptest.NewServer(mux) + defer testServer.Close() + + body, _ := json.Marshal(confirmRequest{ModuleName: "dms", ObjectType: "invoice", ObjectID: "doc-1", Caller: "ai", Values: map[string]string{"x": "y"}}) + req, _ := http.NewRequest(http.MethodPost, testServer.URL+"/extract/confirm", bytes.NewReader(body)) + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("post: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401 ohne service-token", resp.StatusCode) + } +} + +func postConfirm(t *testing.T, baseURL, token string, req confirmRequest) confirmResponse { + t.Helper() + body, err := json.Marshal(req) + if err != nil { + t.Fatalf("request kodieren: %v", err) + } + httpReq, err := http.NewRequest(http.MethodPost, baseURL+"/extract/confirm", bytes.NewReader(body)) + if err != nil { + t.Fatalf("request bauen: %v", err) + } + httpReq.Header.Set("X-Service-Token", token) + resp, err := http.DefaultClient.Do(httpReq) + if err != nil { + t.Fatalf("post: %v", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d, want 200", resp.StatusCode) + } + var out confirmResponse + if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { + t.Fatalf("response dekodieren: %v", err) + } + return out +} diff --git a/ai/internal/extractmapping/extractmapping.go b/ai/internal/extractmapping/extractmapping.go new file mode 100644 index 00000000..f20daef6 --- /dev/null +++ b/ai/internal/extractmapping/extractmapping.go @@ -0,0 +1,95 @@ +// Package extractmapping ordnet bestaetigte Extraktionsfelder (EXT-01/EXT-03) +// den Metadatenfeldern des aufrufenden Moduls zu (EXT-05). Das Mapping ist +// reine, zur Laufzeit aenderbare Konfiguration je Modul/Objekttyp +// (module_objecttypes-Muster, siehe moduletype), kein starres Feldschema. +package extractmapping + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + + "github.com/jackc/pgx/v5/pgxpool" +) + +// ErrNotConfigured signalisiert ein fehlendes Feld-Mapping fuer +// moduleName/objectType (AK3: wird vom Aufrufer gemeldet, nicht verworfen). +var ErrNotConfigured = errors.New("extractmapping: kein feld-mapping konfiguriert") + +// Registry verwaltet die Feld-Mappings je Modul/Objekttyp. +type Registry struct { + Pool *pgxpool.Pool +} + +// Set legt das Feld-Mapping (Extraktionsfeld -> Modul-Feldschluessel) fuer +// moduleName/objectType fest und ersetzt ein vorhandenes Mapping vollstaendig +// (AK2: je Modul konfigurierbar, ohne Codeaenderung). +func (r *Registry) Set(ctx context.Context, moduleName, objectType string, mapping map[string]string) error { + moduleName = strings.TrimSpace(moduleName) + objectType = strings.TrimSpace(objectType) + if moduleName == "" || objectType == "" { + return fmt.Errorf("extractmapping: moduleName und objectType duerfen nicht leer sein") + } + if len(mapping) == 0 { + return fmt.Errorf("extractmapping: mindestens eine feldzuordnung erforderlich") + } + payload, err := json.Marshal(mapping) + if err != nil { + return fmt.Errorf("extractmapping: mapping kodieren: %w", err) + } + _, err = r.Pool.Exec(ctx, ` + INSERT INTO extract_field_mappings (module_name, object_type, mapping) + VALUES ($1, $2, $3) + ON CONFLICT (module_name, object_type) DO UPDATE SET mapping = EXCLUDED.mapping + `, moduleName, objectType, payload) + if err != nil { + return fmt.Errorf("extractmapping: speichern: %w", err) + } + return nil +} + +// Get liefert das Mapping fuer moduleName/objectType oder ErrNotConfigured. +func (r *Registry) Get(ctx context.Context, moduleName, objectType string) (map[string]string, error) { + var payload []byte + err := r.Pool.QueryRow(ctx, ` + SELECT mapping FROM extract_field_mappings WHERE module_name = $1 AND object_type = $2 + `, moduleName, objectType).Scan(&payload) + if err != nil { + return nil, fmt.Errorf("%w: %s/%s", ErrNotConfigured, moduleName, objectType) + } + var mapping map[string]string + if err := json.Unmarshal(payload, &mapping); err != nil { + return nil, fmt.Errorf("extractmapping: mapping dekodieren: %w", err) + } + return mapping, nil +} + +// Result ist das Ergebnis der Anwendung eines Mappings auf bestaetigte +// Extraktionswerte. +type Result struct { + // Mapped ist Modul-Feldschluessel -> bestaetigter Wert. + Mapped map[string]string + // Unmapped listet Extraktionsfelder aus confirmed, fuer die mapping + // keinen Ziel-Feldschluessel kennt (AK3: gemeldet statt verworfen). + Unmapped []string +} + +// Apply ordnet confirmed (Extraktionsfeld -> bestaetigter Wert) ueber +// mapping (Extraktionsfeld -> Modul-Feldschluessel) den Modul- +// Metadatenfeldern zu. +func Apply(mapping map[string]string, confirmed map[string]string) Result { + res := Result{Mapped: make(map[string]string, len(confirmed))} + for extractField, value := range confirmed { + moduleField, ok := mapping[extractField] + if !ok || strings.TrimSpace(moduleField) == "" { + res.Unmapped = append(res.Unmapped, extractField) + continue + } + res.Mapped[moduleField] = value + } + sort.Strings(res.Unmapped) + return res +} diff --git a/ai/internal/extractmapping/extractmapping_test.go b/ai/internal/extractmapping/extractmapping_test.go new file mode 100644 index 00000000..d55e84ae --- /dev/null +++ b/ai/internal/extractmapping/extractmapping_test.go @@ -0,0 +1,125 @@ +package extractmapping + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "sort" + "testing" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate" + "gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb" +) + +func newRegistry(t *testing.T) *Registry { + t.Helper() + dsn := os.Getenv("TEST_TENANT_DSN") + if dsn == "" { + t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest uebersprungen") + } + pool := testdb.Pool(t, dsn) + migrationsDir, err := filepath.Abs("../../migrations") + if err != nil { + t.Fatalf("migrationsverzeichnis: %v", err) + } + migrations, err := migrate.Load(migrationsDir) + if err != nil { + t.Fatalf("migrationen laden: %v", err) + } + if _, err := migrate.Up(context.Background(), pool, migrations); err != nil { + t.Fatalf("migrationen anwenden: %v", err) + } + return &Registry{Pool: pool} +} + +// TestRegistry_SetAndGet deckt AK2 ab: das Feld-Mapping ist je Modul/Objekttyp +// konfigurierbar, ohne dass dieses Paket dafuer geaendert werden muss. +func TestRegistry_SetAndGet(t *testing.T) { + r := newRegistry(t) + ctx := context.Background() + + err := r.Set(ctx, "dms", "invoice", map[string]string{ + "invoice_date": "rechnungsdatum", + "amount": "betrag", + }) + if err != nil { + t.Fatalf("set: %v", err) + } + + got, err := r.Get(ctx, "dms", "invoice") + if err != nil { + t.Fatalf("get: %v", err) + } + want := map[string]string{"invoice_date": "rechnungsdatum", "amount": "betrag"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("mapping = %+v, want %+v", got, want) + } +} + +// TestRegistry_Set_ReplacesExisting deckt eine fehlkonfigurierte, spaeter +// korrigierte Zuordnung ab: ein erneutes Set ersetzt das Mapping +// vollstaendig. +func TestRegistry_Set_ReplacesExisting(t *testing.T) { + r := newRegistry(t) + ctx := context.Background() + + if err := r.Set(ctx, "mail", "email", map[string]string{"sender": "falsches_feld"}); err != nil { + t.Fatalf("set 1: %v", err) + } + if err := r.Set(ctx, "mail", "email", map[string]string{"sender": "absender"}); err != nil { + t.Fatalf("set 2: %v", err) + } + + got, err := r.Get(ctx, "mail", "email") + if err != nil { + t.Fatalf("get: %v", err) + } + if got["sender"] != "absender" { + t.Fatalf("mapping nicht ersetzt: %+v", got) + } +} + +// TestRegistry_Get_UnconfiguredModule_ReturnsErrNotConfigured deckt die +// Grundlage von AK3 ab: ein fehlkonfiguriertes (hier: gar nicht +// konfiguriertes) Mapping wird als solches erkannt, nicht stillschweigend +// als leeres Mapping behandelt. +func TestRegistry_Get_UnconfiguredModule_ReturnsErrNotConfigured(t *testing.T) { + r := newRegistry(t) + ctx := context.Background() + + _, err := r.Get(ctx, "unbekanntes-modul", "irgendwas") + if !errors.Is(err, ErrNotConfigured) { + t.Fatalf("err = %v, want ErrNotConfigured", err) + } +} + +func TestApply_MapsKnownFieldsAndReportsUnmapped(t *testing.T) { + mapping := map[string]string{"invoice_date": "rechnungsdatum", "amount": "betrag"} + confirmed := map[string]string{ + "invoice_date": "2026-01-15", + "amount": "199.00", + "vendor": "Acme GmbH", // kein Mapping-Eintrag + } + + res := Apply(mapping, confirmed) + + wantMapped := map[string]string{"rechnungsdatum": "2026-01-15", "betrag": "199.00"} + if !reflect.DeepEqual(res.Mapped, wantMapped) { + t.Fatalf("mapped = %+v, want %+v", res.Mapped, wantMapped) + } + if !sort.StringsAreSorted(res.Unmapped) || len(res.Unmapped) != 1 || res.Unmapped[0] != "vendor" { + t.Fatalf("unmapped = %+v, want [vendor]", res.Unmapped) + } +} + +func TestApply_EmptyMapping_ReportsAllAsUnmapped(t *testing.T) { + res := Apply(map[string]string{}, map[string]string{"invoice_date": "2026-01-15"}) + if len(res.Mapped) != 0 { + t.Fatalf("mapped = %+v, want leer", res.Mapped) + } + if len(res.Unmapped) != 1 || res.Unmapped[0] != "invoice_date" { + t.Fatalf("unmapped = %+v, want [invoice_date]", res.Unmapped) + } +} diff --git a/ai/internal/metadatapushclient/metadatapushclient.go b/ai/internal/metadatapushclient/metadatapushclient.go new file mode 100644 index 00000000..ebb59afd --- /dev/null +++ b/ai/internal/metadatapushclient/metadatapushclient.go @@ -0,0 +1,129 @@ +// Package metadatapushclient ist ein schlanker HTTP-Client fuer die +// Metadatenfeld-API des aufrufenden Moduls (z.B. DMS MET-03: GET +// /metadata-fields, PUT /documents/{id}/metadata). AI ist ein physisch +// getrenntes Go-Modul und kann die Metadaten-Implementierung des Moduls +// daher nicht direkt importieren, nur ueber dessen HTTP-Schnittstelle +// (Muster wie ai/internal/auditclient). +package metadatapushclient + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/url" + "sort" + + "gitea.perlbach24.de/scripte/nexarch/ai/internal/httpclient" +) + +type Client struct { + BaseURL string + ServiceToken string + HTTPClient *http.Client +} + +func New(baseURL, serviceToken string) *Client { + return &Client{BaseURL: baseURL, ServiceToken: serviceToken, HTTPClient: httpclient.Default()} +} + +type fieldDTO struct { + ID string `json:"id"` + Key string `json:"key"` +} + +// listFields liefert Modul-Feldschluessel -> interne Feld-ID. Push arbeitet +// mit dem stabilen Feldschluessel statt der internen ID, da diese sich beim +// Neuanlegen eines Felds aendern kann. +func (c *Client) listFields(ctx context.Context) (map[string]string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/metadata-fields", nil) + if err != nil { + return nil, fmt.Errorf("metadatapushclient: request bauen: %w", err) + } + req.Header.Set("X-Service-Token", c.ServiceToken) + + resp, err := c.httpClient().Do(req) + if err != nil { + return nil, fmt.Errorf("metadatapushclient: felder abrufen: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("metadatapushclient: unerwarteter status %d beim feld-abruf", resp.StatusCode) + } + + var fields []fieldDTO + if err := json.NewDecoder(resp.Body).Decode(&fields); err != nil { + return nil, fmt.Errorf("metadatapushclient: felder dekodieren: %w", err) + } + byKey := make(map[string]string, len(fields)) + for _, f := range fields { + byKey[f.Key] = f.ID + } + return byKey, nil +} + +// PushResult ist das Ergebnis eines Push-Versuchs. +type PushResult struct { + // Unresolved listet Modul-Feldschluessel aus values, fuer die das Modul + // aktuell kein Feld dieses Schluessels registriert hat (AK3: gemeldet, + // die uebrigen Werte werden trotzdem geschrieben statt den gesamten + // Aufruf zu verwerfen). + Unresolved []string +} + +// Push schreibt values (Modul-Feldschluessel -> bestaetigter Wert) in die +// Metadatenfelder von objectID ueber die Metadaten-API des Moduls (AK1). +func (c *Client) Push(ctx context.Context, objectID, caller string, values map[string]string) (PushResult, error) { + fieldIDs, err := c.listFields(ctx) + if err != nil { + return PushResult{}, err + } + + byID := make(map[string]string, len(values)) + var result PushResult + for key, value := range values { + id, ok := fieldIDs[key] + if !ok { + result.Unresolved = append(result.Unresolved, key) + continue + } + byID[id] = value + } + sort.Strings(result.Unresolved) + + if len(byID) == 0 { + return result, nil + } + + body, err := json.Marshal(byID) + if err != nil { + return PushResult{}, fmt.Errorf("metadatapushclient: request kodieren: %w", err) + } + + reqURL := fmt.Sprintf("%s/documents/%s/metadata?%s", c.BaseURL, url.PathEscape(objectID), + url.Values{"caller": {caller}}.Encode()) + req, err := http.NewRequestWithContext(ctx, http.MethodPut, reqURL, bytes.NewReader(body)) + if err != nil { + return PushResult{}, fmt.Errorf("metadatapushclient: request bauen: %w", err) + } + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Service-Token", c.ServiceToken) + + resp, err := c.httpClient().Do(req) + if err != nil { + return PushResult{}, fmt.Errorf("metadatapushclient: push fehlgeschlagen: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusNoContent { + return PushResult{}, fmt.Errorf("metadatapushclient: unerwarteter status %d beim schreiben", resp.StatusCode) + } + return result, nil +} + +func (c *Client) httpClient() *http.Client { + if c.HTTPClient != nil { + return c.HTTPClient + } + return httpclient.Default() +} diff --git a/ai/internal/metadatapushclient/metadatapushclient_test.go b/ai/internal/metadatapushclient/metadatapushclient_test.go new file mode 100644 index 00000000..60eec1f9 --- /dev/null +++ b/ai/internal/metadatapushclient/metadatapushclient_test.go @@ -0,0 +1,118 @@ +package metadatapushclient + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +type dtoField struct { + ID string `json:"id"` + Key string `json:"key"` +} + +func newFakeModule(t *testing.T, fields []dtoField, wantValues map[string]string) (*httptest.Server, *string, *string) { + t.Helper() + var gotToken, gotCaller string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotToken = r.Header.Get("X-Service-Token") + switch { + case r.Method == http.MethodGet && r.URL.Path == "/metadata-fields": + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(fields) + case r.Method == http.MethodPut: + gotCaller = r.URL.Query().Get("caller") + var body map[string]string + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatalf("body dekodieren: %v", err) + } + for k, v := range wantValues { + if body[k] != v { + t.Fatalf("push body[%q] = %q, want %q (body=%+v)", k, body[k], v, body) + } + } + w.WriteHeader(http.StatusNoContent) + default: + t.Fatalf("unerwarteter aufruf %s %s", r.Method, r.URL.Path) + } + })) + return srv, &gotToken, &gotCaller +} + +// TestPush_ResolvesFieldKeyToIDAndWrites deckt AK1 ab: der bestaetigte Wert +// landet im richtigen Metadatenfeld (per Feldschluessel aufgeloest). +func TestPush_ResolvesFieldKeyToIDAndWrites(t *testing.T) { + srv, gotToken, gotCaller := newFakeModule(t, []dtoField{ + {ID: "field-uuid-1", Key: "rechnungsdatum"}, + {ID: "field-uuid-2", Key: "betrag"}, + }, map[string]string{"field-uuid-1": "2026-01-15", "field-uuid-2": "199.00"}) + defer srv.Close() + + c := New(srv.URL, "svc-token-123") + result, err := c.Push(context.Background(), "doc-1", "ai", map[string]string{ + "rechnungsdatum": "2026-01-15", + "betrag": "199.00", + }) + if err != nil { + t.Fatalf("push: %v", err) + } + if len(result.Unresolved) != 0 { + t.Fatalf("unresolved = %+v, want leer", result.Unresolved) + } + if *gotToken != "svc-token-123" { + t.Fatalf("service-token nicht gesendet: %q", *gotToken) + } + if *gotCaller != "ai" { + t.Fatalf("caller nicht gesendet: %q", *gotCaller) + } +} + +// TestPush_UnknownFieldKey_ReportedNotDropped deckt AK3 ab: ein Modul- +// Feldschluessel, den das Modul aktuell nicht registriert hat, wird +// gemeldet statt stillschweigend verworfen — die uebrigen Werte werden +// trotzdem geschrieben. +func TestPush_UnknownFieldKey_ReportedNotDropped(t *testing.T) { + srv, _, _ := newFakeModule(t, []dtoField{ + {ID: "field-uuid-1", Key: "rechnungsdatum"}, + }, map[string]string{"field-uuid-1": "2026-01-15"}) + defer srv.Close() + + c := New(srv.URL, "svc-token-123") + result, err := c.Push(context.Background(), "doc-1", "ai", map[string]string{ + "rechnungsdatum": "2026-01-15", + "nicht_existent_feld": "wert", + }) + if err != nil { + t.Fatalf("push: %v", err) + } + if len(result.Unresolved) != 1 || result.Unresolved[0] != "nicht_existent_feld" { + t.Fatalf("unresolved = %+v, want [nicht_existent_feld]", result.Unresolved) + } +} + +func TestPush_AllUnknownFieldKeys_NoWriteAttempted(t *testing.T) { + callCount := 0 + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + _ = json.NewEncoder(w).Encode([]dtoField{{ID: "field-uuid-1", Key: "rechnungsdatum"}}) + return + } + callCount++ + w.WriteHeader(http.StatusNoContent) + })) + defer srv.Close() + + c := New(srv.URL, "svc-token-123") + result, err := c.Push(context.Background(), "doc-1", "ai", map[string]string{"unbekannt": "wert"}) + if err != nil { + t.Fatalf("push: %v", err) + } + if callCount != 0 { + t.Fatalf("PUT wurde trotz vollstaendig unaufloesbarer werte gesendet") + } + if len(result.Unresolved) != 1 || result.Unresolved[0] != "unbekannt" { + t.Fatalf("unresolved = %+v, want [unbekannt]", result.Unresolved) + } +} diff --git a/ai/migrations/0009_extract_field_mappings.down.sql b/ai/migrations/0009_extract_field_mappings.down.sql new file mode 100644 index 00000000..02a5f2a6 --- /dev/null +++ b/ai/migrations/0009_extract_field_mappings.down.sql @@ -0,0 +1 @@ +DROP TABLE extract_field_mappings; diff --git a/ai/migrations/0009_extract_field_mappings.up.sql b/ai/migrations/0009_extract_field_mappings.up.sql new file mode 100644 index 00000000..496a6fe7 --- /dev/null +++ b/ai/migrations/0009_extract_field_mappings.up.sql @@ -0,0 +1,8 @@ +CREATE TABLE extract_field_mappings ( + id BIGSERIAL PRIMARY KEY, + module_name TEXT NOT NULL, + object_type TEXT NOT NULL, + mapping JSONB NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (module_name, object_type) +);