Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7515b8db4d | ||
|
|
cd5ca8da53 |
No files matched your search
@@ -1,2 +0,0 @@
|
||||
# common/ ist modulübergreifend: Änderungen brauchen Review (Auswirkungsliste im PR)
|
||||
/common/ @patrick
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "ai/**"
|
||||
- "common/**"
|
||||
|
||||
jobs:
|
||||
pflichttest-gate:
|
||||
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "archive/**"
|
||||
- "common/**"
|
||||
|
||||
jobs:
|
||||
pflichttest-gate:
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
name: common Pflichttest-Gate
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "common/**"
|
||||
|
||||
jobs:
|
||||
pflichttest-gate:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
- name: Gate bauen
|
||||
working-directory: common
|
||||
run: go build -o /tmp/pflichttestgate ./cmd/pflichttestgate
|
||||
- name: Geänderte Dateien gegen Pflichttest-Regel pruefen
|
||||
run: |
|
||||
git diff --name-only "origin/${{ github.base_ref }}...HEAD" -- common/ | /tmp/pflichttestgate
|
||||
@@ -1,33 +0,0 @@
|
||||
name: common Versions-Tag
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
paths:
|
||||
- "common/**"
|
||||
|
||||
jobs:
|
||||
tag:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
- name: Build, Vet, Test
|
||||
working-directory: common
|
||||
run: |
|
||||
go build ./...
|
||||
go vet ./...
|
||||
go test ./... -count=1
|
||||
- name: Naechsten Tag setzen
|
||||
run: |
|
||||
minor=$(tr -d '[:space:]' < common/MINOR)
|
||||
last=$(git tag -l "common/v0.${minor}.*" --sort=-v:refname | head -n1)
|
||||
if [ -z "$last" ]; then next="common/v0.${minor}.0"; else
|
||||
patch=${last##*.}; base=${last%.*}; next="${base}.$((patch + 1))"
|
||||
fi
|
||||
git tag "$next"
|
||||
git push origin "$next"
|
||||
@@ -1,37 +0,0 @@
|
||||
name: DB-Integrationstests
|
||||
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "common/**"
|
||||
- "archive/**"
|
||||
- "ai/**"
|
||||
- "connect/**"
|
||||
- "scripts/ci-pg-cluster.sh"
|
||||
- ".gitea/workflows/db-tests.yml"
|
||||
|
||||
jobs:
|
||||
db-tests:
|
||||
runs-on: nexarch-test
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: "1.25"
|
||||
- name: Wegwerf-Postgres starten
|
||||
run: scripts/ci-pg-cluster.sh start
|
||||
- name: common
|
||||
working-directory: common
|
||||
run: go test ./... -count=1 -p 1
|
||||
- name: archive
|
||||
working-directory: archive
|
||||
run: go test ./... -count=1 -p 1
|
||||
- name: ai
|
||||
working-directory: ai
|
||||
run: go test ./... -count=1 -p 1
|
||||
- name: connect
|
||||
working-directory: connect
|
||||
run: go test ./... -count=1 -p 1
|
||||
- name: Wegwerf-Postgres stoppen
|
||||
if: always()
|
||||
run: scripts/ci-pg-cluster.sh stop
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "mail/**"
|
||||
- "common/**"
|
||||
|
||||
jobs:
|
||||
pflichttest-gate:
|
||||
|
||||
@@ -4,6 +4,7 @@ on:
|
||||
pull_request:
|
||||
paths:
|
||||
- "workflow/**"
|
||||
- "common/**"
|
||||
|
||||
jobs:
|
||||
pflichttest-gate:
|
||||
|
||||
@@ -604,567 +604,3 @@ Keine Commits in dieser Session.
|
||||
- scripts/qa02-foundation-gate.sh | 2 +-
|
||||
|
||||
---
|
||||
## 2026-10-19 12:07 – 12:07 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/audit-log/app/page.tsx | 6 +++---
|
||||
- web/dms-browser/app/layout.tsx | 6 +++---
|
||||
- web/lic-admin/app/page.tsx | 18 +++++++++---------
|
||||
- web/portal/app/layout.tsx | 12 ++----------
|
||||
- web/portal/app/page.tsx | 4 ++--
|
||||
- web/shl/components/ModuleNavShell.tsx | 18 +++++++++++++++---
|
||||
- web/shl/components/Shell.tsx | 2 ++
|
||||
- web/shl/index.ts | 2 +-
|
||||
- web/shl/tokens/tokens.ts | 24 ++++++++++++++++++++++++
|
||||
- web/status-page/app/page.tsx | 12 ++++++------
|
||||
- web/tenant-admin/app/page.tsx | 8 ++++----
|
||||
|
||||
---
|
||||
## 2026-10-19 12:08 – 12:08 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/audit-log/app/page.tsx | 6 +++---
|
||||
- web/dms-browser/app/layout.tsx | 6 +++---
|
||||
- web/lic-admin/app/page.tsx | 18 +++++++++---------
|
||||
- web/portal/app/layout.tsx | 12 ++----------
|
||||
- web/portal/app/page.tsx | 4 ++--
|
||||
- web/shl/components/ModuleNavShell.tsx | 18 +++++++++++++++---
|
||||
- web/shl/components/Shell.tsx | 2 ++
|
||||
- web/shl/index.ts | 2 +-
|
||||
- web/shl/tokens/tokens.ts | 24 ++++++++++++++++++++++++
|
||||
- web/status-page/app/page.tsx | 12 ++++++------
|
||||
- web/tenant-admin/app/page.tsx | 8 ++++----
|
||||
|
||||
---
|
||||
## 2026-10-19 12:14 – 12:15 (1m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/audit-log/app/page.tsx | 6 +++---
|
||||
- web/dms-browser/app/layout.tsx | 6 +++---
|
||||
- web/lic-admin/app/page.tsx | 18 +++++++++---------
|
||||
- web/portal/app/layout.tsx | 12 ++----------
|
||||
- web/portal/app/page.tsx | 4 ++--
|
||||
- web/shl/components/ModuleNavShell.tsx | 18 +++++++++++++++---
|
||||
- web/shl/components/Shell.tsx | 2 ++
|
||||
- web/shl/index.ts | 2 +-
|
||||
- web/shl/tokens/tokens.ts | 24 ++++++++++++++++++++++++
|
||||
- web/status-page/app/page.tsx | 12 ++++++------
|
||||
- web/tenant-admin/app/page.tsx | 8 ++++----
|
||||
|
||||
---
|
||||
## 2026-10-19 12:29 – 12:30 (1m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- dms/internal/upload/service.go | 38 ++++++++++++++++++++++++++++++---
|
||||
- dms/internal/upload/service_test.go | 95 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
---
|
||||
## 2026-10-19 12:31 – 12:31 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- dms/internal/upload/service.go | 25 ++++++++++++++++++++++++-
|
||||
- dms/internal/upload/service_test.go | 44 ++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
---
|
||||
## 2026-10-19 12:44 – 12:53 (8m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** banking
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- dms/internal/upload/service.go | 25 ++++++++++++++++++++++++-
|
||||
- dms/internal/upload/service_test.go | 44 ++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
---
|
||||
## 2026-10-19 12:54 – 12:54 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- dms/internal/upload/service.go | 25 ++++++++++++++++++++++++-
|
||||
- dms/internal/upload/service_test.go | 44 ++++++++++++++++++++++++++++++++++++++++++++
|
||||
|
||||
---
|
||||
## 2026-10-19 13:47 – 13:50 (2m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** banking
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/mail-postfach/app/layout.tsx | 8 +++++---
|
||||
- web/shl/components/ModuleNavShell.tsx | 4 ++++
|
||||
|
||||
---
|
||||
## 2026-10-19 14:49 – 14:50 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** archivmail
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- MIGRATIONSKONZEPT.md | 2 +-
|
||||
- PRODUKTARCHITEKTUR.md | 2 +-
|
||||
- SICHERHEITSKONZEPT.md | 16 ++++++++--------
|
||||
- SKALIERUNGSKONZEPT.md | 2 +-
|
||||
- STORAGE-KONZEPT.md | 4 ++--
|
||||
- dms/internal/searchsync/searchsync_test.go | 4 ++--
|
||||
- web/portal/app/ModuleGrid.test.tsx | 3 ++-
|
||||
- web/portal/app/ModuleGrid.tsx | 17 +----------------
|
||||
- web/portal/lib/modules.test.ts | 4 +++-
|
||||
- web/shl/components/ModuleNavShell.tsx | 27 +++++++++++++++++++--------
|
||||
- web/shl/index.ts | 2 +-
|
||||
|
||||
---
|
||||
## 2026-10-19 14:58 – 23:11 (8h 12m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
|
||||
- web/shl/index.ts | 3 +++
|
||||
|
||||
---
|
||||
## 2026-10-20 04:03 – 04:03 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
|
||||
- web/shl/index.ts | 3 +++
|
||||
|
||||
---
|
||||
## 2026-10-20 04:03 – 04:03 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
|
||||
- web/shl/index.ts | 3 +++
|
||||
|
||||
---
|
||||
## 2026-10-20 04:03 – 04:03 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
|
||||
- web/shl/index.ts | 3 +++
|
||||
|
||||
---
|
||||
## 2026-10-20 04:03 – 04:03 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
|
||||
- web/shl/index.ts | 3 +++
|
||||
|
||||
---
|
||||
## 2026-10-20 04:03 – 04:03 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
|
||||
- web/shl/index.ts | 3 +++
|
||||
|
||||
---
|
||||
## 2026-10-27 11:18 – 11:18 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
|
||||
- web/shl/index.ts | 3 +++
|
||||
|
||||
---
|
||||
## 2026-10-27 11:20 – 11:20 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
|
||||
- web/shl/index.ts | 3 +++
|
||||
|
||||
---
|
||||
## 2026-10-27 11:54 – 11:54 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
|
||||
- web/shl/index.ts | 3 +++
|
||||
|
||||
---
|
||||
## 2026-10-27 11:55 – 11:55 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
|
||||
- web/shl/index.ts | 3 +++
|
||||
|
||||
---
|
||||
## 2026-10-27 11:57 – 11:57 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
|
||||
- web/shl/index.ts | 3 +++
|
||||
|
||||
---
|
||||
## 2026-10-27 11:58 – 11:58 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
|
||||
- web/shl/index.ts | 3 +++
|
||||
|
||||
---
|
||||
## 2026-10-27 12:37 – 12:38 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
|
||||
- web/shl/index.ts | 3 +++
|
||||
|
||||
---
|
||||
## 2026-10-27 12:38 – 12:38 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
|
||||
- web/shl/index.ts | 3 +++
|
||||
|
||||
---
|
||||
## 2026-10-27 12:40 – 12:40 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
|
||||
- web/shl/index.ts | 3 +++
|
||||
|
||||
---
|
||||
## 2026-10-27 12:42 – 12:42 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
|
||||
- web/shl/index.ts | 3 +++
|
||||
|
||||
---
|
||||
## 2026-10-27 12:42 – 12:44 (1m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
|
||||
- web/shl/index.ts | 3 +++
|
||||
|
||||
---
|
||||
## 2026-10-27 12:47 – 12:52 (4m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
|
||||
- web/shl/index.ts | 3 +++
|
||||
|
||||
---
|
||||
## 2026-10-27 12:53 – 12:54 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
|
||||
- web/shl/index.ts | 3 +++
|
||||
|
||||
---
|
||||
## 2026-10-27 12:55 – 12:56 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
|
||||
- web/shl/index.ts | 3 +++
|
||||
|
||||
---
|
||||
## 2026-10-27 13:00 – 13:01 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** Trad-Bot
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
|
||||
- web/shl/index.ts | 3 +++
|
||||
|
||||
---
|
||||
## 2026-10-27 13:35 – 13:39 (4m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
|
||||
- web/shl/index.ts | 3 +++
|
||||
|
||||
---
|
||||
## 2026-10-27 13:42 – 13:52 (10m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
|
||||
- web/shl/index.ts | 3 +++
|
||||
|
||||
---
|
||||
## 2026-10-27 13:53 – 13:53 (0m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
|
||||
- web/shl/index.ts | 3 +++
|
||||
|
||||
---
|
||||
## 2026-10-27 13:54 – 14:06 (12m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
|
||||
- web/shl/index.ts | 3 +++
|
||||
|
||||
---
|
||||
## 2026-10-27 14:08 – 14:41 (32m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
|
||||
- web/shl/index.ts | 3 +++
|
||||
|
||||
---
|
||||
## 2026-10-27 15:03 – 15:25 (22m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
|
||||
- web/shl/index.ts | 3 +++
|
||||
|
||||
---
|
||||
## 2026-10-27 15:27 – 15:43 (15m)
|
||||
**Beschreibung:** Claude Code Session
|
||||
**Projekt:** nexarch
|
||||
|
||||
### Commits
|
||||
Keine Commits in dieser Session.
|
||||
|
||||
### Geänderte Dateien
|
||||
- web/dms-browser/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/dms-upload/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/pdf-viewer/components/AccountBar.tsx | 14 +++++++-------
|
||||
- web/shl/components/AccountBar.tsx | 22 ++++++++++++++++++++++
|
||||
- web/shl/index.ts | 3 +++
|
||||
|
||||
---
|
||||
+1
-12
@@ -6,17 +6,6 @@ Stand: 2026-08-26. Zusammenführung aller bisherigen Entscheidungen aus `nexarch
|
||||
|
||||
NEXARCH ist die Nachfolgeplattform von **archivmail** (E-Mail-Archivierung) und **archivdms** (Dokumentenmanagement) — nicht als Umbenennung, sondern als modulare Neuentwicklung unter der Positionierung **"Next Generation Information Management"**: E-Mails, Dokumente, digitale Archivierung, OCR, Volltextsuche, Workflows, Compliance, Integrationen, KI — als ein zusammenhängendes System, nicht als zwei getrennte Werkzeuge nebeneinander.
|
||||
|
||||
## 1a. Vorbildsysteme
|
||||
|
||||
NEXARCH vereint die Stärken bestehender Systeme und vermeidet deren bekannte Schwächen (Details: `dms-kanban/known-issues-archivdms.md`, `dms-kanban/known-issues-archivmail.md`).
|
||||
|
||||
| Bereich | Vorbildsysteme |
|
||||
|---|---|
|
||||
| Mail | **archivmail** (eigenes Produkt, Vorgänger), **MailPiler** |
|
||||
| DMS | **paperless-ngx**, **Alfresco**, **Docspell**, **ecoDMS**, **archivdms** (eigenes Produkt, Vorgänger) |
|
||||
|
||||
Weitere Vergleichsprojekte (z. B. OpenKM, Nuxeo, Mayan EDMS, Seafile) sind noch zu prüfen. Für das DMS-Modul siehe auch `dms/README.md`.
|
||||
|
||||
## 2. Architekturprinzip
|
||||
|
||||
**Ein modularer Monolith aus sieben eigenständigen, aber koordinierten Diensten** — kein Microservice-Zwang, aber klare Dienstgrenzen mit eigenem Datenmodell, eigenem Go-Binary, eigenem Kanban-Board:
|
||||
@@ -75,7 +64,7 @@ Kein Modul kopiert die Daten eines anderen — stattdessen ein wiederkehrendes *
|
||||
|
||||
Gemeinsamer Nenner aller drei: **Referenz statt Kopie, Adapter statt Direktkopplung.**
|
||||
|
||||
Go-`internal/`-Pakete sind zwischen den Modulen nicht sichtbar; identische Querschnittspakete liegen deshalb im versionierten Modul `common/` (Git-Tags `common/vX.Y.Z`, siehe `common/README.md`). So bleiben Module einzeln baubar (Docker, eigenes Repo, Partner-Build). Fachlich verschiedene Pakete (`audittrail`, `cutoverstage`, `crypto`) bleiben modul-lokal.
|
||||
Go-`internal/`-Pakete sind zwischen den Modulen nicht sichtbar; identische Querschnittspakete werden deshalb im geteilten Modul `common/` gehalten (per `replace` in den `go.mod` eingebunden, keine eigenen Abhängigkeiten außer der Standardbibliothek). Pilot: `common/httpclient`, `common/apierror`. Fachlich verschiedene Pakete (`audittrail`, `cutoverstage`, `crypto`) bleiben modul-lokal.
|
||||
|
||||
Dasselbe Prinzip gilt auch innerhalb eines Moduls, auf Code-Ebene: Hauptdateien/Entry-Points bleiben klein, Fachlogik wandert in eigene Module. Vor einer neuen Funktions-/Modul-Datei wird geprüft, ob dieselbe Funktionalität nicht schon woanders existiert (auch modulübergreifend, z. B. in Core) — keine doppelten Funktionsdateien. Das eigene Kanban-Planungswerkzeug wurde nach genau diesem Muster korrigiert (sieben `kanban.html` enthielten identisches JS/CSS als Volltext-Duplikat, damals in `_shared/kanban.js`/`kanban.css` ausgelagert) — die Boards selbst sind inzwischen komplett aus dem Repo entfernt (Ticket-Historie liegt in der Gitea-PR-Historie), das Prinzip "keine doppelten Funktionsdateien" gilt unverändert für den eigentlichen NEXARCH-Code.
|
||||
|
||||
|
||||
+15
-110
@@ -18,29 +18,17 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/classify"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/confidence"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/correction"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/decisionaudit"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/docchat"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/docchatapi"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/doclang"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/doctype"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/embedpipeline"
|
||||
"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/featureapi"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/highlight"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/invoiceextract"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/metadatapushclient"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/modelconfig"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/moduletype"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/moduletypeapi"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/originmark"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/prompttemplate"
|
||||
"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"
|
||||
@@ -51,14 +39,10 @@ import (
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/routingsuggestapi"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/semanticsearch"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/semanticsearchapi"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/suggestapi"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/summarize"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/summaryapi"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/tenantfeature"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/threadsummarize"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/vectorindex"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/auditclient"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/policyclient"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/tenantregistryclient"
|
||||
)
|
||||
|
||||
@@ -68,25 +52,13 @@ const defaultListenAddr = "127.0.0.1:9600"
|
||||
const defaultModel = "default"
|
||||
|
||||
type config struct {
|
||||
dsn string
|
||||
serviceToken string
|
||||
addr string
|
||||
model string
|
||||
moduleURLs map[string]string
|
||||
coreBaseURL string
|
||||
coreServiceToken string
|
||||
policyURL string
|
||||
policyServiceToken string
|
||||
policyDisabled bool
|
||||
languages *doclang.Registry
|
||||
}
|
||||
|
||||
// policyClient liefert nil nur mit NEXARCH_AI_APP_POLICY_DISABLED=1.
|
||||
func (c config) policyClient() *policyclient.Client {
|
||||
if c.policyURL == "" {
|
||||
return nil
|
||||
}
|
||||
return policyclient.New(c.policyURL, c.policyServiceToken)
|
||||
dsn string
|
||||
serviceToken string
|
||||
addr string
|
||||
model string
|
||||
moduleURLs map[string]string
|
||||
coreBaseURL string
|
||||
coreServiceToken string
|
||||
}
|
||||
|
||||
// parseModuleBaseURLs liest "modul=url,modul2=url2" (EXT-05: je Modul eine
|
||||
@@ -112,19 +84,13 @@ func parseModuleBaseURLs(raw string) map[string]string {
|
||||
// Programmabbruchs weiter unten im Code.
|
||||
func loadConfig(getenv func(string) string) (config, error) {
|
||||
cfg := config{
|
||||
dsn: getenv("NEXARCH_AI_APP_TENANT_DSN"),
|
||||
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")),
|
||||
coreBaseURL: getenv("NEXARCH_AI_APP_CORE_BASE_URL"),
|
||||
coreServiceToken: getenv("NEXARCH_AI_APP_CORE_SERVICE_TOKEN"),
|
||||
policyURL: getenv("NEXARCH_AI_APP_POLICY_URL"),
|
||||
policyServiceToken: getenv("NEXARCH_AI_APP_POLICY_SERVICE_TOKEN"),
|
||||
policyDisabled: getenv("NEXARCH_AI_APP_POLICY_DISABLED") == "1",
|
||||
}
|
||||
if cfg.policyURL != "" && cfg.policyServiceToken == "" {
|
||||
return config{}, errors.New("ai-app: NEXARCH_AI_APP_POLICY_SERVICE_TOKEN ist erforderlich, wenn NEXARCH_AI_APP_POLICY_URL gesetzt ist")
|
||||
dsn: getenv("NEXARCH_AI_APP_TENANT_DSN"),
|
||||
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")),
|
||||
coreBaseURL: getenv("NEXARCH_AI_APP_CORE_BASE_URL"),
|
||||
coreServiceToken: getenv("NEXARCH_AI_APP_CORE_SERVICE_TOKEN"),
|
||||
}
|
||||
if cfg.dsn == "" {
|
||||
return config{}, errors.New("ai-app: NEXARCH_AI_APP_TENANT_DSN ist erforderlich")
|
||||
@@ -132,14 +98,6 @@ func loadConfig(getenv func(string) string) (config, error) {
|
||||
if cfg.serviceToken == "" {
|
||||
return config{}, errors.New("ai-app: NEXARCH_AI_APP_SERVICE_TOKEN ist erforderlich")
|
||||
}
|
||||
if cfg.policyURL == "" && !cfg.policyDisabled {
|
||||
return config{}, errors.New("ai-app: NEXARCH_AI_APP_POLICY_URL ist erforderlich (oder NEXARCH_AI_APP_POLICY_DISABLED=1 als Uebergang)")
|
||||
}
|
||||
languages, err := doclang.LoadRegistryFile(getenv(doclang.EnvLanguagesFile))
|
||||
if err != nil {
|
||||
return config{}, err
|
||||
}
|
||||
cfg.languages = languages
|
||||
if cfg.addr == "" {
|
||||
cfg.addr = defaultListenAddr
|
||||
}
|
||||
@@ -171,11 +129,7 @@ func build(pool *pgxpool.Pool, cfg config, prov provider.Provider, workerID stri
|
||||
).RegisterRoutes(mux)
|
||||
|
||||
searchSvc := semanticsearch.New(prov, vectorindex.New(pool), cfg.model)
|
||||
policy := cfg.policyClient()
|
||||
if policy == nil {
|
||||
log.Print("ai-app: WARNUNG: Rechtepruefung ai.search.read deaktiviert (NEXARCH_AI_APP_POLICY_DISABLED=1)")
|
||||
}
|
||||
semanticsearchapi.NewServer(searchSvc, cfg.serviceToken).WithPolicy(policy).RegisterRoutes(mux)
|
||||
semanticsearchapi.NewServer(searchSvc, cfg.serviceToken).RegisterRoutes(mux)
|
||||
|
||||
chunkStore := vectorindex.New(pool)
|
||||
docchatapi.NewServer(
|
||||
@@ -214,61 +168,12 @@ func build(pool *pgxpool.Pool, cfg config, prov provider.Provider, workerID stri
|
||||
log.Print("ai-app: NEXARCH_AI_APP_CORE_BASE_URL nicht gesetzt, Objekttyp-Registrierung (GOV-01) deaktiviert")
|
||||
}
|
||||
|
||||
registry := cfg.languages
|
||||
if registry == nil {
|
||||
registry = doclang.DefaultRegistry()
|
||||
}
|
||||
langStore := &doclang.Store{Pool: pool}
|
||||
markerStore := &originmark.Store{Pool: pool}
|
||||
suggest := &suggestapi.Server{
|
||||
Provider: prov,
|
||||
Model: cfg.model,
|
||||
Resolver: &doclang.Resolver{Registry: registry, Tenants: langStore},
|
||||
Languages: langStore,
|
||||
Markers: markerStore,
|
||||
ServiceToken: cfg.serviceToken,
|
||||
}
|
||||
// CLS-09: ohne Core-Audit gibt es keine Uebernahme-Protokollierung.
|
||||
if cfg.coreBaseURL != "" {
|
||||
suggest.Adopt = &originmark.Service{
|
||||
Markers: markerStore,
|
||||
Audit: decisionaudit.NewRecorder(auditclient.New(cfg.coreBaseURL, cfg.coreServiceToken)),
|
||||
}
|
||||
} else {
|
||||
log.Print("ai-app: NEXARCH_AI_APP_CORE_BASE_URL nicht gesetzt, Uebernahme-Protokollierung (/suggest/adopt) deaktiviert")
|
||||
}
|
||||
suggest.RegisterRoutes(mux)
|
||||
|
||||
quotaStore := quota.NewStore(pool, nil)
|
||||
q := queue.NewQueue(pool, 5*time.Minute)
|
||||
q.SetQuotaEnforcer(quotaStore)
|
||||
worker := queue.NewWorker(q, prov, workerID)
|
||||
worker.SetUsageRecorder(quotaStore)
|
||||
|
||||
taxonomy := &doctype.Taxonomy{Pool: pool}
|
||||
listers := map[string]modelconfig.ModelLister{}
|
||||
if l, ok := prov.(modelconfig.ModelLister); ok {
|
||||
listers["ollama"] = l
|
||||
}
|
||||
classifier := &classify.Classifier{Provider: prov, Model: cfg.model}
|
||||
features := &featureapi.Server{
|
||||
Classifier: classifier,
|
||||
Doctype: &doctype.Detector{Classifier: classifier, Taxonomy: taxonomy},
|
||||
Highlight: &highlight.Detector{Provider: prov, Model: cfg.model},
|
||||
Taxonomy: taxonomy,
|
||||
Features: &tenantfeature.Store{Pool: pool},
|
||||
Thresholds: &confidence.Store{Pool: pool},
|
||||
Corrections: &correction.Recorder{Pool: pool},
|
||||
Embeddings: embedpipeline.New(pool, q, cfg.model),
|
||||
Models: modelconfig.NewStore(pool, listers),
|
||||
Templates: prompttemplate.New(pool),
|
||||
ServiceToken: cfg.serviceToken,
|
||||
}
|
||||
if cfg.coreBaseURL != "" {
|
||||
features.Audit = decisionaudit.NewRecorder(auditclient.New(cfg.coreBaseURL, cfg.coreServiceToken))
|
||||
}
|
||||
features.RegisterRoutes(mux)
|
||||
|
||||
return wiring{mux: mux, queue: q, worker: worker, quotaStore: quotaStore}
|
||||
}
|
||||
|
||||
|
||||
+2
-165
@@ -2,7 +2,6 @@ package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
@@ -18,7 +17,7 @@ import (
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/mock"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/queue"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
|
||||
)
|
||||
|
||||
// AK3 + AK4: eine fehlende Pflicht-Umgebungsvariable liefert einen klaren
|
||||
@@ -45,36 +44,10 @@ func TestLoadConfig_MissingRequiredVars(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Issue #369: Policy-URL ohne Service-Token darf nicht still ohne
|
||||
// Rechtepruefung starten.
|
||||
func TestLoadConfig_PolicyURLRequiresServiceToken(t *testing.T) {
|
||||
func TestLoadConfig_Defaults(t *testing.T) {
|
||||
env := map[string]string{
|
||||
"NEXARCH_AI_APP_TENANT_DSN": "postgres://x",
|
||||
"NEXARCH_AI_APP_SERVICE_TOKEN": "tok",
|
||||
"NEXARCH_AI_APP_POLICY_URL": "http://127.0.0.1:8093",
|
||||
}
|
||||
get := func(k string) string { return env[k] }
|
||||
if _, err := loadConfig(get); err == nil || !strings.Contains(err.Error(), "NEXARCH_AI_APP_POLICY_SERVICE_TOKEN") {
|
||||
t.Fatalf("erwartete fehler zur fehlenden NEXARCH_AI_APP_POLICY_SERVICE_TOKEN, bekam %v", err)
|
||||
}
|
||||
env["NEXARCH_AI_APP_POLICY_SERVICE_TOKEN"] = "policy-tok"
|
||||
cfg, err := loadConfig(get)
|
||||
if err != nil {
|
||||
t.Fatalf("unerwarteter fehler: %v", err)
|
||||
}
|
||||
if cfg.policyClient() == nil {
|
||||
t.Fatal("policy-client muss bei gesetzter URL aktiv sein")
|
||||
}
|
||||
if (config{}).policyClient() != nil {
|
||||
t.Fatal("ohne URL darf kein policy-client entstehen")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_Defaults(t *testing.T) {
|
||||
env := map[string]string{
|
||||
"NEXARCH_AI_APP_TENANT_DSN": "postgres://x",
|
||||
"NEXARCH_AI_APP_SERVICE_TOKEN": "tok",
|
||||
"NEXARCH_AI_APP_POLICY_DISABLED": "1",
|
||||
}
|
||||
cfg, err := loadConfig(func(k string) string { return env[k] })
|
||||
if err != nil {
|
||||
@@ -88,96 +61,6 @@ func TestLoadConfig_Defaults(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuild_FeatureAPIAgainstRealTables(t *testing.T) {
|
||||
pool := newTestPool(t)
|
||||
prov := &mock.Provider{ChatResponse: provider.ChatResponse{Message: provider.Message{Role: provider.RoleAssistant, Content: `{"category":"rechnung","confidence":0.9,"reasoning":"x"}`}}}
|
||||
w := build(pool, config{serviceToken: "tok", model: "mock"}, prov, "feature-worker")
|
||||
srv := httptest.NewServer(w.mux)
|
||||
defer srv.Close()
|
||||
|
||||
call := func(method, path, body string) (int, map[string]any) {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest(method, srv.URL+path, strings.NewReader(body))
|
||||
req.Header.Set("X-Service-Token", "tok")
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("%s %s: %v", method, path, err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var out map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||
return resp.StatusCode, out
|
||||
}
|
||||
want := func(code int, got int, what string) {
|
||||
t.Helper()
|
||||
if got != code {
|
||||
t.Errorf("%s: status %d, want %d", what, got, code)
|
||||
}
|
||||
}
|
||||
|
||||
code, _ := call("POST", "/tenants/featt/doctypes/defaults", `{}`)
|
||||
want(200, code, "doctype defaults")
|
||||
code, out := call("POST", "/doctype/detect", `{"tenant_slug":"featt","text":"Rechnung Nr. 5"}`)
|
||||
want(200, code, "doctype detect")
|
||||
if out["doc_type"] != "rechnung" {
|
||||
t.Errorf("doc_type = %v", out["doc_type"])
|
||||
}
|
||||
|
||||
code, out = call("POST", "/classify", `{"tenant_slug":"featt","categories":["rechnung","vertrag"],"text":"x"}`)
|
||||
want(200, code, "classify")
|
||||
if out["meets_threshold"] != true {
|
||||
t.Errorf("0.9 gegen default 0.7: meets_threshold = %v", out["meets_threshold"])
|
||||
}
|
||||
code, _ = call("PUT", "/tenants/featt/confidence/classify", `{"threshold":0.95}`)
|
||||
want(200, code, "confidence put")
|
||||
_, out = call("POST", "/classify", `{"tenant_slug":"featt","categories":["rechnung","vertrag"],"text":"x"}`)
|
||||
if out["meets_threshold"] != false {
|
||||
t.Errorf("0.9 gegen 0.95: meets_threshold = %v", out["meets_threshold"])
|
||||
}
|
||||
|
||||
code, _ = call("PUT", "/tenants/featt/features/classify", `{"enabled":false}`)
|
||||
want(200, code, "feature off")
|
||||
code, _ = call("POST", "/classify", `{"tenant_slug":"featt","categories":["rechnung"],"text":"x"}`)
|
||||
want(403, code, "classify deaktiviert")
|
||||
code, _ = call("POST", "/classify", `{"tenant_slug":"featt2","categories":["rechnung"],"text":"x"}`)
|
||||
want(200, code, "classify anderer mandant")
|
||||
|
||||
code, _ = call("POST", "/corrections", `{"tenant_slug":"featt","function":"classify","input_ref":"d1","suggested_category":"rechnung","suggested_confidence":0.9,"corrected_category":"vertrag","actor":"u1"}`)
|
||||
want(204, code, "correction record")
|
||||
_, out = call("GET", "/tenants/featt/corrections/classify/stats", "")
|
||||
if out["total_corrections"] != 1.0 {
|
||||
t.Errorf("total_corrections = %v", out["total_corrections"])
|
||||
}
|
||||
|
||||
code, _ = call("POST", "/prompt-templates/featt-key", `{"body":"eins","created_by":"u"}`)
|
||||
want(201, code, "template save")
|
||||
code, out = call("GET", "/prompt-templates/featt-key", "")
|
||||
want(200, code, "template active")
|
||||
if out["body"] != "eins" {
|
||||
t.Errorf("body = %v", out["body"])
|
||||
}
|
||||
|
||||
code, out = call("POST", "/embeddings/ingest", `{"tenant_slug":"featt","source_type":"doc","source_id":"1","text":"Ein kurzer Text."}`)
|
||||
want(202, code, "embedding ingest")
|
||||
code, out = call("GET", "/embeddings/status?tenant_slug=featt&source_type=doc&source_id=1", "")
|
||||
want(200, code, "embedding status")
|
||||
if cs, _ := out["chunks"].([]any); len(cs) != 1 {
|
||||
t.Errorf("chunks = %v", out["chunks"])
|
||||
}
|
||||
|
||||
code, _ = call("GET", "/tenants/featt/model-config/chat", "")
|
||||
want(404, code, "model-config ungesetzt")
|
||||
code, _ = call("PUT", "/tenants/featt/model-config/chat", `{"provider":"ollama","model":"x"}`)
|
||||
want(400, code, "model-config ohne lister")
|
||||
|
||||
code, _ = call("POST", "/summary-quality/evaluate", `{"samples":[{"type":"t","source":"a b c","summary":"a b"}]}`)
|
||||
want(200, code, "summary-quality")
|
||||
code, _ = call("POST", "/highlight", `{"tenant_slug":"featt","text":"Frist endet morgen."}`)
|
||||
if code == http.StatusNotFound || code == http.StatusUnauthorized {
|
||||
t.Errorf("highlight nicht registriert: %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestPool(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_TENANT_DSN")
|
||||
@@ -256,20 +139,6 @@ func TestBuild_AllPackagesRegistered(t *testing.T) {
|
||||
if resp := get("POST", "/invoices/extract", `{"text":""}`); resp.StatusCode == http.StatusNotFound {
|
||||
t.Errorf("extractapi route nicht registriert (404)")
|
||||
}
|
||||
|
||||
// suggestapi (CLS-06..09): Mandantenkonfiguration laeuft gegen die echten Tabellen.
|
||||
if resp := get("PUT", "/tenants/acme/language", `{"language":"en"}`); resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("suggestapi language status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if resp := get("PUT", "/tenants/acme/origin-marker", `{"marker":"ki"}`); resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("suggestapi origin-marker status = %d, want 200", resp.StatusCode)
|
||||
}
|
||||
if resp := get("POST", "/suggest/title", `{"tenant_slug":"acme","text":""}`); resp.StatusCode != http.StatusBadRequest {
|
||||
t.Errorf("suggestapi title status = %d, want 400", resp.StatusCode)
|
||||
}
|
||||
if resp := get("POST", "/suggest/adopt", `{}`); resp.StatusCode != http.StatusServiceUnavailable {
|
||||
t.Errorf("suggestapi adopt ohne Core-URL status = %d, want 503", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
// AK2 + Pruefung 2: ein echter Inferenz-Auftrag durchlaeuft die Warteschlange
|
||||
@@ -315,35 +184,3 @@ func TestBuild_QueueWorkerProcessesJobWithQuota(t *testing.T) {
|
||||
t.Errorf("quota-zaehlerstand = %d, want 1 (GOV-04-Verbrauchszaehlung nicht verdrahtet)", value)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_PolicyRequiredUnlessDisabled(t *testing.T) {
|
||||
env := map[string]string{
|
||||
"NEXARCH_AI_APP_TENANT_DSN": "postgres://x",
|
||||
"NEXARCH_AI_APP_SERVICE_TOKEN": "tok",
|
||||
}
|
||||
get := func(k string) string { return env[k] }
|
||||
if _, err := loadConfig(get); err == nil || !strings.Contains(err.Error(), "NEXARCH_AI_APP_POLICY_URL") {
|
||||
t.Fatalf("erwartete fehler zur fehlenden NEXARCH_AI_APP_POLICY_URL, bekam %v", err)
|
||||
}
|
||||
env["NEXARCH_AI_APP_POLICY_DISABLED"] = "1"
|
||||
if _, err := loadConfig(get); err != nil {
|
||||
t.Fatalf("mit POLICY_DISABLED=1 darf der Start nicht scheitern: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_LanguagesFile(t *testing.T) {
|
||||
env := map[string]string{
|
||||
"NEXARCH_AI_APP_TENANT_DSN": "postgres://x",
|
||||
"NEXARCH_AI_APP_SERVICE_TOKEN": "tok",
|
||||
"NEXARCH_AI_APP_POLICY_DISABLED": "1",
|
||||
}
|
||||
get := func(k string) string { return env[k] }
|
||||
cfg, err := loadConfig(get)
|
||||
if err != nil || cfg.languages == nil {
|
||||
t.Fatalf("standardsprachen erwartet: %v", err)
|
||||
}
|
||||
env["NEXARCH_AI_LANGUAGES_FILE"] = filepath.Join(t.TempDir(), "fehlt.json")
|
||||
if _, err := loadConfig(get); err == nil || !strings.Contains(err.Error(), "NEXARCH_AI_LANGUAGES_FILE") {
|
||||
t.Fatalf("klarer fehler bei fehlender sprachdatei erwartet, bekam %v", err)
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
fixtureTenant = "beispiel-mandant"
|
||||
fixtureTenant = "beispiel-mandant"
|
||||
fixtureSourceType = "dms_document"
|
||||
fixtureSourceID = "beispiel-dokument-1"
|
||||
)
|
||||
@@ -77,6 +77,5 @@ func main() {
|
||||
docchatapi.NewServer(chatSvc, "test-token").RegisterRoutes(mux)
|
||||
|
||||
log.Println("searchchatapi-mockserver listening on 127.0.0.1:19603")
|
||||
// archtest:allow Einmaliges lokales pa11y-Testwerkzeug, kein Produktdienst
|
||||
log.Fatal(http.ListenAndServe("127.0.0.1:19603", mux))
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,33 +0,0 @@
|
||||
# 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.
|
||||
@@ -1,34 +0,0 @@
|
||||
# CLS-08 – Mehrsprachige Dokumentanalyse – Prüfprotokoll
|
||||
|
||||
Datum: 2026-11-06. Host: Entwicklungshost 192.168.1.204 (Wegwerf-Rolle/DB `cls08_test`, nach Abschluss entfernt).
|
||||
|
||||
## Umsetzung
|
||||
|
||||
- Neues Paket `ai/internal/doclang`: `Registry` mit Stoppwortlisten je Sprache (Deutsch, Englisch eingebaut), `Detect(text)` und `Resolver.Resolve(ctx, tenant, explicitDocLang, text)`.
|
||||
- Weitere Sprachen ohne Codeänderung: JSON-Datei über `NEXARCH_AI_LANGUAGES_FILE` (`[{"code","name","stopwords"}]`).
|
||||
- Ausgabesprache je Mandant in Tabelle `ai_tenant_languages` (Migration `0013`), bei jedem Aufruf live gelesen.
|
||||
- `tagsuggest.Suggester.Language` nimmt die Ausgabesprache in den Prompt auf; bestehende Tags anderer Sprache behalten Vorrang vor neuen. `titlesuggest` übernahm die Sprache bereits als Parameter (CLS-06).
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
| # | Kriterium | Ergebnis |
|
||||
|---|---|---|
|
||||
| 1 | Dokumentsprache erkannt oder per Parameter vorgegeben | **Erfüllt.** `TestDetect_GermanAndEnglish`, `TestResolve_ExplicitOverridesDetectionAndUnknownFails` (unbekannter Code ergibt `ErrUnknownLanguage`). Unklare Texte ergeben keine Sprache statt einer Fehlzuordnung (`TestDetect_UnclearYieldsNothing`). |
|
||||
| 2 | Ausgabesprache je Mandant konfigurierbar | **Erfüllt.** `TestStore_PerTenantAndLive` (echte DB, Mandantentrennung), `TestResolve_OutputFollowsTenantOtherwiseDocument`. |
|
||||
| 3 | Bestehende Tag-Taxonomie wird bei abweichender Dokumentsprache respektiert | **Teilweise belegt.** `TestSuggest_LanguageInPromptAndTaxonomyKept`: Prompt enthält Sprachvorgabe und Vorrang bestehender Tags; englisches Dokument gegen deutschen Tag ergibt die bestehende Schreibweise. Mit Stub-Provider, nicht mit echtem Modell. |
|
||||
| 4 | Deutsch und Englisch belegt, weitere Sprachen per Konfiguration | **Erfüllt.** `TestRegistry_ExtensibleByJSONWithoutCodeChange` (Französisch per JSON), `TestRegistryFromEnv`, `TestLoadJSON_RejectsBadCode`. |
|
||||
|
||||
## Prüfungen vor der Abnahme
|
||||
|
||||
| # | Prüfung | Ergebnis |
|
||||
|---|---|---|
|
||||
| 1 | Testset deutsch/englisch gemessen | **Bestanden, 20/20** (10 de, 10 en), Zielwert 90 %. `TestDetect_SyntheticTestsetAccuracy`. |
|
||||
| 2 | Wechsel der Ausgabesprache wirkt ohne Neustart | **Bestanden.** In `TestStore_PerTenantAndLive` wird die Sprache gewechselt und sofort neu gelesen. |
|
||||
| 3 | Regressionstest bestehender CLS-Funktionen | **Bestanden.** Alle bestehenden `tagsuggest`-Tests grün (Unit; die zwei Realtests wurden ohne `TEST_OLLAMA_*` übersprungen). Neues Feld `Language` ist optional, leer ergibt unveränderten Prompt (`TestSuggest_NoLanguageNoInstruction`). |
|
||||
|
||||
## Einschränkungen
|
||||
|
||||
- Das Testset hat derselbe Autor wie die Stoppwortlisten geschrieben; 20/20 ist daher optimistisch. Kurze Texte, Texte mit vielen Zahlen/Namen und gemischtsprachige Dokumente sind nicht gemessen und liefern bei zu wenigen Treffern keine Sprache.
|
||||
- AK3 ist nur mit Stub-Provider belegt, nicht gegen ein echtes Modell.
|
||||
- `classify`, `extract` und `correspondentsuggest` nutzen die Sprache noch nicht; keine Verdrahtung in `cmd/ai-app` und kein API-Endpunkt zum Setzen der Mandantensprache.
|
||||
- Keine Übersetzung von Dokumentinhalten (ausdrücklich nicht Teil der Kachel).
|
||||
@@ -1,32 +0,0 @@
|
||||
# CLS-09 – Kennzeichnung KI-gesetzter Zuordnungen – Prüfprotokoll
|
||||
|
||||
Datum: 2026-11-06. Host: Entwicklungshost 192.168.1.204 (Wegwerf-Rolle/DB `cls09_test`, nach Abschluss entfernt).
|
||||
|
||||
## Umsetzung
|
||||
|
||||
- Migration `0012_ai_origin_marker`: Tabelle `ai_origin_markers` (eine Markierung je Mandant, nicht leer).
|
||||
- Paket `ai/internal/originmark`: `Store` (Set/Get/Clear, Validierung 1 bis 64 Zeichen ohne Steuerzeichen) und `Service.Apply(ctx, Adoption)`.
|
||||
- `Apply` liefert die Markierung, die der Aufrufer an die übernommene Zuordnung hängt. Nur bei `Source=ai`; manuelle Zuordnungen bleiben unmarkiert und unprotokolliert. KI-Übernahmen gehen über `decisionaudit.Recorder` ins zentrale Audit, mit `origin_marker` im Ergebnis.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
| # | Kriterium | Ergebnis |
|
||||
|---|---|---|
|
||||
| 1 | Übernommene KI-Vorschläge tragen eine mandantenweit konfigurierbare Markierung | **Erfüllt auf Paketebene.** `TestApply_AIAdoptionGetsMarkerAndAudit`, `TestStore_PerTenantSetGetClear` (echte DB). Der Aufrufer (DMS/Mail) hängt die Markierung an; diese Anbindung existiert noch nicht. |
|
||||
| 2 | Manuelle Zuordnungen werden nicht markiert | **Erfüllt.** `TestApply_ManualNeverMarkedNorAudited`. |
|
||||
| 3 | Markierung im Audit der KI-Entscheidung nachvollziehbar | **Teilweise belegt.** Mit Fake-Recorder: `origin_marker`, `kind` und `value` stehen im Ergebnis. Echter Core-Audit (`auditclient`) nicht getestet. |
|
||||
| 4 | Ohne Konfiguration keine Markierung | **Erfüllt.** `TestApply_NoConfigNoMarkerButAudited`; die KI-Übernahme wird trotzdem protokolliert. |
|
||||
|
||||
## Prüfungen vor der Abnahme
|
||||
|
||||
| # | Prüfung | Ergebnis |
|
||||
|---|---|---|
|
||||
| 1 | Übernahme setzt die Markierung nachweislich, manuelle Zuordnung nicht | **Bestanden** (Unit). Ende-zu-Ende mit DMS: **offen**. |
|
||||
| 2 | Markierung erscheint im Entscheidungs-Audit | **Teilweise.** Siehe AK3. |
|
||||
| 3 | Mandantentrennung | **Bestanden.** `TestApply_TenantIsolation` und DB-Test (`acme`/`other`, Clear berührt `other` nicht). |
|
||||
|
||||
## Einschränkungen
|
||||
|
||||
- Kein API-Endpunkt zum Setzen der Markierung, keine Verdrahtung in `cmd/ai-app`, keine Anbindung an DMS/Mail.
|
||||
- Schlägt das Audit fehl, liefert `Apply` einen Fehler und keine Markierung; ob die Fachaktion dann trotzdem Bestand hat, entscheidet der Aufrufer.
|
||||
- Oberfläche zur Filterung nach Markierung ist nicht Teil der Kachel.
|
||||
@@ -1,32 +0,0 @@
|
||||
# ENG-09 – Weitere Provider (OpenAI-kompatible Presets, Gemini) – Prüfprotokoll
|
||||
|
||||
Datum: 2026-11-06. Host: Entwicklungshost 192.168.1.204.
|
||||
|
||||
## Umsetzung
|
||||
|
||||
- Neues Paket `ai/internal/provider/gemini`: Chat, Completion (über Chat) und Embedding gegen die Gemini-REST-API (`generateContent`, `embedContent`). Schlüssel aus `NEXARCH_AI_GEMINI_API_KEY`, optionale Basis-URL `NEXARCH_AI_GEMINI_BASE_URL`; Schlüssel nur im Header `x-goog-api-key`. Ohne Schlüssel liefert `NewFromEnv` `ErrDisabled`.
|
||||
- OpenAI-kompatible Dienste: der vorhandene `openai`-Adapter nimmt bereits eine Basis-URL (`NEXARCH_AI_OPENAI_BASE_URL`); es war keine Codeänderung nötig.
|
||||
- Opt-in je Mandant: `openai.GuardedProvider` mit `TenantGate` umschließt jeden `provider.Provider`, auch den Gemini-Adapter.
|
||||
|
||||
## Akzeptanzkriterien
|
||||
|
||||
| # | Kriterium | Ergebnis |
|
||||
|---|---|---|
|
||||
| 1 | OpenAI-kompatible Dienste ohne Codeänderung über Basis-URL und Schlüssel aus Umgebungsvariablen | **Teilweise belegt.** Mechanismus vorhanden und durch bestehende `openai`-Tests (Fake-Server mit abweichender Basis-URL) abgedeckt. Gegen OpenRouter, vLLM, LiteLLM oder DeepSeek nicht getestet (keine Zugänge). |
|
||||
| 2 | Gemini-Adapter erfüllt den Provider-Vertragstest | **Erfüllt gegen Fake-Server.** `TestContract_Gemini` (Chat, Complete, Embed über `runContract`), `TestUniformErrors` (404, 429, 500 auf die einheitlichen Codes), Timeout/Nicht-Erreichbar (`TestUnreachableAndCancelled`). Nicht gegen die echte Gemini-API. |
|
||||
| 3 | Externe Anbieter nur nach Opt-in und Residenz-Prüfung | **Teilweise belegt.** Opt-in per `GuardedProvider` belegt (`TestOptIn_GuardedProviderBlocksWithoutCallingServer`: kein Aufruf ohne Freigabe oder ohne Mandant). Datenresidenz-Prüfung (`residency`) ist nicht an den Adapter gekoppelt. |
|
||||
| 4 | Zugangsdaten ausschließlich über Umgebungsvariablen | **Erfüllt.** `TestDisabledWithoutKey`; `TestKeyNotInErrors`; Schlüssel nie in der URL (`fakeServer` prüft den Query-Parameter `key`). |
|
||||
|
||||
## Prüfungen vor der Abnahme
|
||||
|
||||
| # | Prüfung | Ergebnis |
|
||||
|---|---|---|
|
||||
| 1 | Vertragstest gegen jeden neuen Adapter | **Bestanden** (Fake-Server). Echte Gemini-API: **offen**, kein API-Schlüssel vorhanden. |
|
||||
| 2 | Opt-in-Verweigerung blockiert den Aufruf nachweislich | **Bestanden.** Server-Zähler bleibt unberührt. |
|
||||
| 3 | Keine echten Kundendaten in Tests gegen externe Dienste | **Bestanden.** Es gibt keine Tests gegen externe Dienste. |
|
||||
|
||||
## Einschränkungen
|
||||
|
||||
- Kein Lauf gegen eine echte Gemini-API; Request-/Response-Format nach der öffentlichen REST-Dokumentation nachgebaut, nicht gegen den echten Dienst verifiziert.
|
||||
- `ai-app` bindet den Adapter nicht ein; die Fehlermeldung von `GuardedProvider` nennt den Anbieter `openai`, auch wenn Gemini umschlossen ist.
|
||||
- Kopplung an `residency` und Streaming sind nicht Teil dieser Kachel.
|
||||
@@ -1,27 +0,0 @@
|
||||
# ai-app: Routen aus `featureapi`
|
||||
|
||||
Alle Routen verlangen `X-Service-Token`. Verdrahtet in `ai/cmd/ai-app/main.go` (`build`), Tests in `ai/internal/featureapi` und `TestBuild_FeatureAPIAgainstRealTables`.
|
||||
|
||||
| Route | Paket |
|
||||
|---|---|
|
||||
| `POST /classify` | classify (mit Audit, wenn Core konfiguriert und `actor` + `input_ref` gesetzt) |
|
||||
| `POST /doctype/detect`, `GET`/`POST /tenants/{t}/doctypes`, `POST /tenants/{t}/doctypes/defaults` | doctype |
|
||||
| `POST /highlight` | highlight |
|
||||
| `GET`/`PUT /tenants/{t}/features/{function}` | tenantfeature |
|
||||
| `GET`/`PUT /tenants/{t}/confidence/{function}` | confidence |
|
||||
| `GET`/`PUT /tenants/{t}/model-config/{function}` | modelconfig |
|
||||
| `POST /corrections`, `GET /tenants/{t}/corrections/{function}/stats` | correction |
|
||||
| `POST /embeddings/ingest`, `GET /embeddings/status` | embedpipeline |
|
||||
| `POST`/`GET /prompt-templates/{key}`, `GET .../history`, `PUT .../active` | prompttemplate |
|
||||
| `POST /summary-quality/evaluate` | summaryquality |
|
||||
|
||||
## Verhalten
|
||||
|
||||
- `classify`, `doctype`, `highlight` prüfen den Mandantenschalter (`tenantfeature`, Funktionsnamen `classify`, `doctype`, `highlight`). Deaktiviert ergibt 403. Der globale Core-Schalter (LIC-02) ist nicht angebunden und gilt als an.
|
||||
- `classify` und `doctype` liefern `meets_threshold` gegen die Schwelle aus `confidence` (Standard 0,7). Die Schwelle entscheidet nur die Anzeige, nichts wird automatisch übernommen.
|
||||
- Embeddings: `ingest` reiht nur ein. Verarbeitet werden die Aufträge vom vorhandenen Queue-Worker.
|
||||
|
||||
## Nicht angebunden
|
||||
|
||||
- `modelconfig` und `prompttemplate` speichern nur. Kein Inferenzpfad liest die Mandanten-Modellwahl oder die aktive Vorlage; Suggest-, Summary- und Extract-Routen nutzen weiter `NEXARCH_AI_APP_MODEL` und die festen Prompts. `modelconfig` kennt nur den Provider `ollama`.
|
||||
- `notraining` ist ein reiner Architekturtest, `refcorpus` ein Testkorpus. Beide haben keine Laufzeitfunktion und bekommen keine Route.
|
||||
@@ -4,10 +4,7 @@ go 1.25.0
|
||||
|
||||
toolchain go1.25.13
|
||||
|
||||
require (
|
||||
gitea.perlbach24.de/scripte/nexarch/common v0.2.0
|
||||
github.com/jackc/pgx/v5 v5.11.0
|
||||
)
|
||||
require github.com/jackc/pgx/v5 v5.11.0
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
@@ -16,3 +13,7 @@ require (
|
||||
golang.org/x/sync v0.17.0 // indirect
|
||||
golang.org/x/text v0.29.0 // indirect
|
||||
)
|
||||
|
||||
require gitea.perlbach24.de/scripte/nexarch/common v0.0.0
|
||||
|
||||
replace gitea.perlbach24.de/scripte/nexarch/common => ../common
|
||||
@@ -1,5 +1,3 @@
|
||||
gitea.perlbach24.de/scripte/nexarch/common v0.2.0 h1:gcOjoOvUkT0Zmqq3m7WQp1MfXSgmzrGTF2E/+NkO2bw=
|
||||
gitea.perlbach24.de/scripte/nexarch/common v0.2.0/go.mod h1:CiEImG++QMt1xZqSMeDJhPiB7sfWTLZOwjS6OY/Iguc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
|
||||
)
|
||||
|
||||
func newStore(t *testing.T) *Store {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/moduletype"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
|
||||
)
|
||||
|
||||
// --- Vertrag 1: GOV-01 registrierter Objekttyp (moduletype.ObjectType) ---
|
||||
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
|
||||
)
|
||||
|
||||
func newRecorder(t *testing.T) *Recorder {
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
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 (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")
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -1,52 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -1,210 +0,0 @@
|
||||
package doclang
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"regexp"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
const EnvLanguagesFile = "NEXARCH_AI_LANGUAGES_FILE"
|
||||
|
||||
const (
|
||||
minTokens = 5
|
||||
minBestHits = 2
|
||||
codePatternRe = `^[a-z]{2,3}$`
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnknownLanguage = errors.New("doclang: sprache nicht registriert")
|
||||
ErrInvalidCode = errors.New("doclang: sprachcode muss 2 bis 3 kleinbuchstaben haben")
|
||||
codePattern = regexp.MustCompile(codePatternRe)
|
||||
)
|
||||
|
||||
type Language struct {
|
||||
Code string `json:"code"`
|
||||
Name string `json:"name"`
|
||||
Stopwords []string `json:"stopwords"`
|
||||
}
|
||||
|
||||
type Registry struct {
|
||||
langs map[string]Language
|
||||
words map[string]map[string]bool
|
||||
}
|
||||
|
||||
func DefaultRegistry() *Registry {
|
||||
r := &Registry{langs: map[string]Language{}, words: map[string]map[string]bool{}}
|
||||
_ = r.Register(Language{Code: "de", Name: "Deutsch", Stopwords: strings.Fields(
|
||||
"der die das und ist nicht ein eine mit von zu den für auf dem des auch sich wir sie ich bei nach wird sind oder aus werden wurde über sehr nur noch")})
|
||||
_ = r.Register(Language{Code: "en", Name: "English", Stopwords: strings.Fields(
|
||||
"the and is of to that for with on are this by or from at it not have was were will would which their been you your has had")})
|
||||
return r
|
||||
}
|
||||
|
||||
func (r *Registry) Register(l Language) error {
|
||||
l.Code = strings.ToLower(strings.TrimSpace(l.Code))
|
||||
if !codePattern.MatchString(l.Code) {
|
||||
return ErrInvalidCode
|
||||
}
|
||||
if strings.TrimSpace(l.Name) == "" {
|
||||
l.Name = l.Code
|
||||
}
|
||||
set := make(map[string]bool, len(l.Stopwords))
|
||||
for _, w := range l.Stopwords {
|
||||
if w = strings.ToLower(strings.TrimSpace(w)); w != "" {
|
||||
set[w] = true
|
||||
}
|
||||
}
|
||||
r.langs[l.Code] = l
|
||||
r.words[l.Code] = set
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Registry) Lookup(code string) (Language, bool) {
|
||||
l, ok := r.langs[strings.ToLower(strings.TrimSpace(code))]
|
||||
return l, ok
|
||||
}
|
||||
|
||||
func (r *Registry) LoadJSON(src io.Reader) error {
|
||||
var langs []Language
|
||||
if err := json.NewDecoder(src).Decode(&langs); err != nil {
|
||||
return fmt.Errorf("doclang: sprachdatei lesen: %w", err)
|
||||
}
|
||||
for _, l := range langs {
|
||||
if err := r.Register(l); err != nil {
|
||||
return fmt.Errorf("doclang: sprache %q: %w", l.Code, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func RegistryFromEnv() (*Registry, error) {
|
||||
return LoadRegistryFile(os.Getenv(EnvLanguagesFile))
|
||||
}
|
||||
|
||||
// LoadRegistryFile ergaenzt die Standardsprachen um die Sprachen aus path; leerer Pfad = nur Standard.
|
||||
func LoadRegistryFile(path string) (*Registry, error) {
|
||||
r := DefaultRegistry()
|
||||
if path == "" {
|
||||
return r, nil
|
||||
}
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("doclang: %s: %w", EnvLanguagesFile, err)
|
||||
}
|
||||
defer func() { _ = f.Close() }()
|
||||
if err := r.LoadJSON(f); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
// Detect liefert die wahrscheinlichste registrierte Sprache oder "" bei unklarem Text.
|
||||
func (r *Registry) Detect(text string) (Language, bool) {
|
||||
tokens := strings.FieldsFunc(strings.ToLower(text), func(c rune) bool { return !unicode.IsLetter(c) })
|
||||
if len(tokens) < minTokens {
|
||||
return Language{}, false
|
||||
}
|
||||
best, second, bestCode := 0, 0, ""
|
||||
for code, words := range r.words {
|
||||
hits := 0
|
||||
for _, t := range tokens {
|
||||
if words[t] {
|
||||
hits++
|
||||
}
|
||||
}
|
||||
switch {
|
||||
case hits > best || (hits == best && code < bestCode):
|
||||
second, best, bestCode = best, hits, code
|
||||
case hits > second:
|
||||
second = hits
|
||||
}
|
||||
}
|
||||
if best < minBestHits || best == second {
|
||||
return Language{}, false
|
||||
}
|
||||
return r.langs[bestCode], true
|
||||
}
|
||||
|
||||
type TenantLanguages interface {
|
||||
Get(ctx context.Context, tenantSlug string) (string, error)
|
||||
}
|
||||
|
||||
type Resolved struct {
|
||||
Document Language
|
||||
Output Language
|
||||
}
|
||||
|
||||
type Resolver struct {
|
||||
Registry *Registry
|
||||
Tenants TenantLanguages
|
||||
}
|
||||
|
||||
// Resolve ermittelt Dokument- und Ausgabesprache. Dokumentsprache: explizit, sonst erkannt.
|
||||
// Ausgabesprache: Mandantenkonfiguration, sonst Dokumentsprache. Leer, wenn beides unbekannt.
|
||||
func (r *Resolver) Resolve(ctx context.Context, tenantSlug, explicitDocLang, text string) (Resolved, error) {
|
||||
var out Resolved
|
||||
if explicitDocLang != "" {
|
||||
l, ok := r.Registry.Lookup(explicitDocLang)
|
||||
if !ok {
|
||||
return Resolved{}, fmt.Errorf("%w: %q", ErrUnknownLanguage, explicitDocLang)
|
||||
}
|
||||
out.Document = l
|
||||
} else if l, ok := r.Registry.Detect(text); ok {
|
||||
out.Document = l
|
||||
}
|
||||
out.Output = out.Document
|
||||
code, err := r.Tenants.Get(ctx, tenantSlug)
|
||||
if err != nil {
|
||||
return Resolved{}, err
|
||||
}
|
||||
if l, ok := r.Registry.Lookup(code); ok {
|
||||
out.Output = l
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func (s *Store) Set(ctx context.Context, tenantSlug, code string) error {
|
||||
tenantSlug = strings.TrimSpace(tenantSlug)
|
||||
code = strings.ToLower(strings.TrimSpace(code))
|
||||
if tenantSlug == "" {
|
||||
return fmt.Errorf("doclang: tenantSlug darf nicht leer sein")
|
||||
}
|
||||
if !codePattern.MatchString(code) {
|
||||
return ErrInvalidCode
|
||||
}
|
||||
_, err := s.Pool.Exec(ctx, `
|
||||
INSERT INTO ai_tenant_languages (tenant_slug, language, updated_at)
|
||||
VALUES ($1, $2, now())
|
||||
ON CONFLICT (tenant_slug) DO UPDATE SET language = EXCLUDED.language, updated_at = now()
|
||||
`, tenantSlug, code)
|
||||
if err != nil {
|
||||
return fmt.Errorf("doclang: speichern: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get liefert "" ohne Konfiguration; liest live, Aenderungen wirken ohne Neustart.
|
||||
func (s *Store) Get(ctx context.Context, tenantSlug string) (string, error) {
|
||||
var code string
|
||||
err := s.Pool.QueryRow(ctx, `SELECT language FROM ai_tenant_languages WHERE tenant_slug = $1`, tenantSlug).Scan(&code)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("doclang: lesen: %w", err)
|
||||
}
|
||||
return code, nil
|
||||
}
|
||||
@@ -1,202 +0,0 @@
|
||||
package doclang
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
|
||||
)
|
||||
|
||||
const (
|
||||
deText = "Sehr geehrte Damen und Herren, die Rechnung ist nicht bezahlt und wird von uns mit der Mahnung an Sie gesendet."
|
||||
enText = "Dear Sirs, the invoice is not paid and will be sent to you by the reminder that we have prepared for your account."
|
||||
frJSON = `[{"code":"fr","name":"Français","stopwords":["le","la","les","et","est","des","une","pour","dans","avec"]}]`
|
||||
)
|
||||
|
||||
type fakeTenants map[string]string
|
||||
|
||||
func (f fakeTenants) Get(_ context.Context, tenant string) (string, error) { return f[tenant], nil }
|
||||
|
||||
func TestDetect_GermanAndEnglish(t *testing.T) {
|
||||
r := DefaultRegistry()
|
||||
if l, ok := r.Detect(deText); !ok || l.Code != "de" {
|
||||
t.Fatalf("de: %+v %v", l, ok)
|
||||
}
|
||||
if l, ok := r.Detect(enText); !ok || l.Code != "en" {
|
||||
t.Fatalf("en: %+v %v", l, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDetect_UnclearYieldsNothing(t *testing.T) {
|
||||
r := DefaultRegistry()
|
||||
for _, txt := range []string{"", "Rechnung 2026", "xyz abc qwe rty uio pas dfg hjk"} {
|
||||
if l, ok := r.Detect(txt); ok {
|
||||
t.Errorf("%q: unerwartet %+v", txt, l)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_ExtensibleByJSONWithoutCodeChange(t *testing.T) {
|
||||
r := DefaultRegistry()
|
||||
if err := r.LoadJSON(strings.NewReader(frJSON)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
l, ok := r.Detect("Le contrat est signé et les parties des deux côtés sont dans une situation pour avec")
|
||||
if !ok || l.Code != "fr" || l.Name != "Français" {
|
||||
t.Fatalf("fr: %+v %v", l, ok)
|
||||
}
|
||||
if l, ok := r.Detect(deText); !ok || l.Code != "de" {
|
||||
t.Fatalf("de nach erweiterung: %+v %v", l, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistryFromEnv(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "langs.json")
|
||||
if err := os.WriteFile(path, []byte(frJSON), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv(EnvLanguagesFile, path)
|
||||
r, err := RegistryFromEnv()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := r.Lookup("fr"); !ok {
|
||||
t.Fatal("fr fehlt")
|
||||
}
|
||||
t.Setenv(EnvLanguagesFile, filepath.Join(t.TempDir(), "fehlt.json"))
|
||||
if _, err := RegistryFromEnv(); err == nil {
|
||||
t.Fatal("fehler erwartet")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadJSON_RejectsBadCode(t *testing.T) {
|
||||
r := DefaultRegistry()
|
||||
if err := r.LoadJSON(strings.NewReader(`[{"code":"Deutsch","stopwords":[]}]`)); !errors.Is(err, ErrInvalidCode) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_OutputFollowsTenantOtherwiseDocument(t *testing.T) {
|
||||
r := &Resolver{Registry: DefaultRegistry(), Tenants: fakeTenants{"acme": "de"}}
|
||||
got, err := r.Resolve(context.Background(), "acme", "", enText)
|
||||
if err != nil || got.Document.Code != "en" || got.Output.Code != "de" {
|
||||
t.Fatalf("%+v %v", got, err)
|
||||
}
|
||||
got, _ = r.Resolve(context.Background(), "other", "", enText)
|
||||
if got.Output.Code != "en" {
|
||||
t.Fatalf("ohne konfiguration: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_ExplicitOverridesDetectionAndUnknownFails(t *testing.T) {
|
||||
r := &Resolver{Registry: DefaultRegistry(), Tenants: fakeTenants{}}
|
||||
got, err := r.Resolve(context.Background(), "acme", "de", enText)
|
||||
if err != nil || got.Document.Code != "de" {
|
||||
t.Fatalf("%+v %v", got, err)
|
||||
}
|
||||
if _, err := r.Resolve(context.Background(), "acme", "xx", enText); !errors.Is(err, ErrUnknownLanguage) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolve_UnknownTenantCodeFallsBackToDocument(t *testing.T) {
|
||||
r := &Resolver{Registry: DefaultRegistry(), Tenants: fakeTenants{"acme": "fr"}}
|
||||
got, _ := r.Resolve(context.Background(), "acme", "", enText)
|
||||
if got.Output.Code != "en" {
|
||||
t.Fatalf("%+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func newStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_TENANT_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen")
|
||||
}
|
||||
pool := testdb.Pool(t, dsn)
|
||||
dir, err := filepath.Abs("../../migrations")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
migrations, err := migrate.Load(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := migrate.Up(context.Background(), pool, migrations); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &Store{Pool: pool}
|
||||
}
|
||||
|
||||
func TestStore_PerTenantAndLive(t *testing.T) {
|
||||
s := newStore(t)
|
||||
ctx := context.Background()
|
||||
if got, err := s.Get(ctx, "acme"); err != nil || got != "" {
|
||||
t.Fatalf("%q %v", got, err)
|
||||
}
|
||||
if err := s.Set(ctx, "acme", " DE "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Set(ctx, "other", "en"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := s.Get(ctx, "acme"); got != "de" {
|
||||
t.Fatalf("acme: %q", got)
|
||||
}
|
||||
if err := s.Set(ctx, "acme", "en"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := s.Get(ctx, "acme"); got != "en" {
|
||||
t.Fatalf("wechsel ohne neustart: %q", got)
|
||||
}
|
||||
if got, _ := s.Get(ctx, "other"); got != "en" {
|
||||
t.Fatalf("other: %q", got)
|
||||
}
|
||||
if err := s.Set(ctx, "acme", "Deutsch"); !errors.Is(err, ErrInvalidCode) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
var testset = []struct{ code, text string }{
|
||||
{"de", "Hiermit kündigen wir den Vertrag zum Ende des Quartals und bitten um eine schriftliche Bestätigung."},
|
||||
{"de", "Die Rechnung wurde am 15. November versendet und ist innerhalb von 14 Tagen zu bezahlen."},
|
||||
{"de", "Wir bedanken uns für Ihre Bestellung und senden Ihnen die Ware in den nächsten Tagen zu."},
|
||||
{"de", "Der Mieter ist verpflichtet, die Wohnung bei Auszug in einem sauberen Zustand zu übergeben."},
|
||||
{"de", "Sehr geehrte Frau Muster, anbei erhalten Sie die Unterlagen, die Sie angefordert haben."},
|
||||
{"de", "Das Angebot gilt nur für Kunden, die sich bis zum Jahresende für den Vertrag entscheiden."},
|
||||
{"de", "Aus technischen Gründen wird die Lieferung erst nach dem Feiertag bei Ihnen eintreffen."},
|
||||
{"de", "Ich bitte Sie, mir den Termin für die Besichtigung der Räume noch heute mitzuteilen."},
|
||||
{"de", "Die Gesellschafter haben in der Versammlung beschlossen, den Gewinn auf neue Rechnung vorzutragen."},
|
||||
{"de", "Eine Kopie des Bescheids ist diesem Schreiben beigefügt und wird auch an Ihren Steuerberater gesendet."},
|
||||
{"en", "We hereby terminate the contract at the end of the quarter and ask for a written confirmation."},
|
||||
{"en", "The invoice has been sent on the fifteenth of November and is payable within fourteen days."},
|
||||
{"en", "Thank you for your order, the goods will be shipped to you within the next few days."},
|
||||
{"en", "The tenant is obliged to hand over the apartment in a clean condition when moving out."},
|
||||
{"en", "Dear Ms Sample, please find attached the documents that you have requested from us."},
|
||||
{"en", "This offer is only valid for customers who decide on the contract before the end of the year."},
|
||||
{"en", "Due to technical reasons the delivery will not arrive before the public holiday."},
|
||||
{"en", "I would like to ask you to let me know the date for the inspection of the premises today."},
|
||||
{"en", "The shareholders have resolved at the meeting that the profit will be carried forward to the next year."},
|
||||
{"en", "A copy of the decision is attached to this letter and will also be sent to your tax advisor."},
|
||||
}
|
||||
|
||||
func TestDetect_SyntheticTestsetAccuracy(t *testing.T) {
|
||||
r := DefaultRegistry()
|
||||
hits := 0
|
||||
for _, c := range testset {
|
||||
if l, ok := r.Detect(c.text); ok && l.Code == c.code {
|
||||
hits++
|
||||
} else {
|
||||
t.Logf("fehlklassifiziert/unklar (%s): %q -> %+v ok=%v", c.code, c.text, l, ok)
|
||||
}
|
||||
}
|
||||
t.Logf("Erkennungsquote: %d/%d", hits, len(testset))
|
||||
if hits < len(testset)*9/10 {
|
||||
t.Errorf("erkennungsquote %d/%d unter 90%%", hits, len(testset))
|
||||
}
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/classify"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/queue"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
|
||||
)
|
||||
|
||||
func newPool(t *testing.T) *pgxpool.Pool {
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"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/common/testdb"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
|
||||
)
|
||||
|
||||
func newMappingRegistry(t *testing.T) *extractmapping.Registry {
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
|
||||
)
|
||||
|
||||
func newRegistry(t *testing.T) *Registry {
|
||||
|
||||
@@ -1,641 +0,0 @@
|
||||
package featureapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/classify"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/confidence"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/correction"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/decisionaudit"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/doctype"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/embedpipeline"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/highlight"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/modelconfig"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/prompttemplate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/summaryquality"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/svcauth"
|
||||
)
|
||||
|
||||
const maxBodyBytes = 4 << 20
|
||||
|
||||
// Namen der abschaltbaren Funktionen (tenantfeature) und der Schwellen (confidence).
|
||||
const (
|
||||
FunctionClassify = "classify"
|
||||
FunctionDoctype = "doctype"
|
||||
FunctionHighlight = "highlight"
|
||||
)
|
||||
|
||||
type Features interface {
|
||||
Set(ctx context.Context, tenantSlug, function string, enabled bool) error
|
||||
IsEnabled(ctx context.Context, tenantSlug, function string, globalEnabled bool) (bool, error)
|
||||
}
|
||||
|
||||
type Thresholds interface {
|
||||
Set(ctx context.Context, tenantSlug, function string, threshold float64) error
|
||||
Get(ctx context.Context, tenantSlug, function string) (float64, error)
|
||||
MeetsThreshold(ctx context.Context, tenantSlug, function string, value float64) (bool, error)
|
||||
}
|
||||
|
||||
type Taxonomies interface {
|
||||
List(ctx context.Context, tenantSlug string) ([]string, error)
|
||||
Add(ctx context.Context, tenantSlug, code, label string) error
|
||||
SeedDefaults(ctx context.Context, tenantSlug string) error
|
||||
}
|
||||
|
||||
type Corrections interface {
|
||||
Record(ctx context.Context, c correction.Correction) error
|
||||
Aggregate(ctx context.Context, tenantSlug, function string) (correction.Stats, error)
|
||||
}
|
||||
|
||||
type Embeddings interface {
|
||||
Ingest(ctx context.Context, tenantSlug, sourceType, sourceID, text string) ([]embedpipeline.Chunk, error)
|
||||
Status(ctx context.Context, tenantSlug, sourceType, sourceID string) ([]embedpipeline.ChunkStatus, error)
|
||||
}
|
||||
|
||||
type Models interface {
|
||||
Set(ctx context.Context, tenantSlug, function, providerName, model string) error
|
||||
Get(ctx context.Context, tenantSlug, function string) (modelconfig.Selection, error)
|
||||
}
|
||||
|
||||
type Templates interface {
|
||||
Save(ctx context.Context, functionKey, body, createdBy string) (prompttemplate.Version, error)
|
||||
Activate(ctx context.Context, functionKey string, version int, activatedBy string) error
|
||||
Active(ctx context.Context, functionKey string) (prompttemplate.Version, error)
|
||||
History(ctx context.Context, functionKey string) ([]prompttemplate.Version, error)
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
Classifier *classify.Classifier
|
||||
Doctype *doctype.Detector
|
||||
Highlight *highlight.Detector
|
||||
Taxonomy Taxonomies
|
||||
Features Features
|
||||
Thresholds Thresholds
|
||||
Corrections Corrections
|
||||
Embeddings Embeddings
|
||||
Models Models
|
||||
Templates Templates
|
||||
// Audit ist nil ohne Core-Anbindung; dann laufen Klassifizierungen unprotokolliert.
|
||||
Audit *decisionaudit.Recorder
|
||||
ServiceToken string
|
||||
}
|
||||
|
||||
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /classify", s.auth(s.handleClassify))
|
||||
mux.HandleFunc("POST /doctype/detect", s.auth(s.handleDoctypeDetect))
|
||||
mux.HandleFunc("GET /tenants/{tenant}/doctypes", s.auth(s.handleDoctypeList))
|
||||
mux.HandleFunc("POST /tenants/{tenant}/doctypes", s.auth(s.handleDoctypeAdd))
|
||||
mux.HandleFunc("POST /tenants/{tenant}/doctypes/defaults", s.auth(s.handleDoctypeSeed))
|
||||
mux.HandleFunc("POST /highlight", s.auth(s.handleHighlight))
|
||||
|
||||
mux.HandleFunc("GET /tenants/{tenant}/features/{function}", s.auth(s.handleFeatureGet))
|
||||
mux.HandleFunc("PUT /tenants/{tenant}/features/{function}", s.auth(s.handleFeaturePut))
|
||||
mux.HandleFunc("GET /tenants/{tenant}/confidence/{function}", s.auth(s.handleConfidenceGet))
|
||||
mux.HandleFunc("PUT /tenants/{tenant}/confidence/{function}", s.auth(s.handleConfidencePut))
|
||||
mux.HandleFunc("GET /tenants/{tenant}/model-config/{function}", s.auth(s.handleModelGet))
|
||||
mux.HandleFunc("PUT /tenants/{tenant}/model-config/{function}", s.auth(s.handleModelPut))
|
||||
|
||||
mux.HandleFunc("POST /corrections", s.auth(s.handleCorrectionRecord))
|
||||
mux.HandleFunc("GET /tenants/{tenant}/corrections/{function}/stats", s.auth(s.handleCorrectionStats))
|
||||
|
||||
mux.HandleFunc("POST /embeddings/ingest", s.auth(s.handleEmbedIngest))
|
||||
mux.HandleFunc("GET /embeddings/status", s.auth(s.handleEmbedStatus))
|
||||
|
||||
mux.HandleFunc("POST /prompt-templates/{key}", s.auth(s.handleTemplateSave))
|
||||
mux.HandleFunc("GET /prompt-templates/{key}", s.auth(s.handleTemplateActive))
|
||||
mux.HandleFunc("GET /prompt-templates/{key}/history", s.auth(s.handleTemplateHistory))
|
||||
mux.HandleFunc("PUT /prompt-templates/{key}/active", s.auth(s.handleTemplateActivate))
|
||||
|
||||
mux.HandleFunc("POST /summary-quality/evaluate", s.auth(s.handleSummaryQuality))
|
||||
}
|
||||
|
||||
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !svcauth.Equal(r.Header.Get("X-Service-Token"), s.ServiceToken) {
|
||||
writeError(w, http.StatusUnauthorized, "service-token fehlt oder ist ungueltig")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
func decode(w http.ResponseWriter, r *http.Request, v any) bool {
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodyBytes)).Decode(v); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// enabled prueft den Mandantenschalter; der globale Schalter (Core LIC-02) ist hier nicht angebunden und gilt als an.
|
||||
func (s *Server) enabled(w http.ResponseWriter, r *http.Request, tenant, function string) bool {
|
||||
ok, err := s.Features.IsEnabled(r.Context(), tenant, function, true)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "funktionsschalter lesen fehlgeschlagen")
|
||||
return false
|
||||
}
|
||||
if !ok {
|
||||
writeError(w, http.StatusForbidden, "funktion fuer diesen mandanten deaktiviert")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) meets(ctx context.Context, tenant, function string, value float64) (bool, error) {
|
||||
return s.Thresholds.MeetsThreshold(ctx, tenant, function, value)
|
||||
}
|
||||
|
||||
type classifyRequest struct {
|
||||
TenantSlug string `json:"tenant_slug"`
|
||||
Categories []string `json:"categories"`
|
||||
Text string `json:"text"`
|
||||
Actor string `json:"actor"`
|
||||
InputRef string `json:"input_ref"`
|
||||
PromptVersion int `json:"prompt_version"`
|
||||
}
|
||||
|
||||
type classifyResponse struct {
|
||||
classify.Result
|
||||
MeetsThreshold bool `json:"meets_threshold"`
|
||||
Audited bool `json:"audited"`
|
||||
}
|
||||
|
||||
func (s *Server) handleClassify(w http.ResponseWriter, r *http.Request) {
|
||||
var req classifyRequest
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.TenantSlug) == "" || strings.TrimSpace(req.Text) == "" || len(req.Categories) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "tenant_slug, categories und text sind erforderlich")
|
||||
return
|
||||
}
|
||||
if !s.enabled(w, r, req.TenantSlug, FunctionClassify) {
|
||||
return
|
||||
}
|
||||
schema := classify.Schema{Categories: req.Categories}
|
||||
audited := s.Audit != nil && strings.TrimSpace(req.Actor) != "" && strings.TrimSpace(req.InputRef) != ""
|
||||
var (
|
||||
res classify.Result
|
||||
err error
|
||||
)
|
||||
if audited {
|
||||
dc := classify.DecisionContext{Recorder: s.Audit, TenantSlug: req.TenantSlug, Actor: req.Actor, PromptVersion: req.PromptVersion}
|
||||
res, err = s.Classifier.ClassifyWithSchemaAudited(r.Context(), schema, req.Text, dc, req.InputRef)
|
||||
} else {
|
||||
res, err = s.Classifier.ClassifyWithSchema(r.Context(), schema, req.Text)
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "klassifizierung fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
ok, err := s.meets(r.Context(), req.TenantSlug, FunctionClassify, res.Confidence)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "schwellenwert lesen fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, classifyResponse{Result: res, MeetsThreshold: ok, Audited: audited})
|
||||
}
|
||||
|
||||
type doctypeResponse struct {
|
||||
DocType string `json:"doc_type"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Ambiguous bool `json:"ambiguous"`
|
||||
Reasoning string `json:"reasoning,omitempty"`
|
||||
MeetsThreshold bool `json:"meets_threshold"`
|
||||
}
|
||||
|
||||
func (s *Server) handleDoctypeDetect(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
TenantSlug string `json:"tenant_slug"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.TenantSlug) == "" || strings.TrimSpace(req.Text) == "" {
|
||||
writeError(w, http.StatusBadRequest, "tenant_slug und text sind erforderlich")
|
||||
return
|
||||
}
|
||||
if !s.enabled(w, r, req.TenantSlug, FunctionDoctype) {
|
||||
return
|
||||
}
|
||||
cats, err := s.Taxonomy.List(r.Context(), req.TenantSlug)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "taxonomie lesen fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
if len(cats) == 0 {
|
||||
writeError(w, http.StatusConflict, "keine dokumenttyp-taxonomie fuer diesen mandanten")
|
||||
return
|
||||
}
|
||||
res, err := s.Doctype.Detect(r.Context(), req.TenantSlug, req.Text)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "dokumenttyp-erkennung fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
ok, err := s.meets(r.Context(), req.TenantSlug, FunctionDoctype, res.Confidence)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "schwellenwert lesen fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, doctypeResponse{DocType: res.DocType, Confidence: res.Confidence, Ambiguous: res.Ambiguous, Reasoning: res.Reasoning, MeetsThreshold: ok})
|
||||
}
|
||||
|
||||
func (s *Server) handleDoctypeList(w http.ResponseWriter, r *http.Request) {
|
||||
codes, err := s.Taxonomy.List(r.Context(), r.PathValue("tenant"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "taxonomie lesen fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
if codes == nil {
|
||||
codes = []string{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"doc_types": codes})
|
||||
}
|
||||
|
||||
func (s *Server) handleDoctypeAdd(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Code string `json:"code"`
|
||||
Label string `json:"label"`
|
||||
}
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Code) == "" || strings.TrimSpace(req.Label) == "" {
|
||||
writeError(w, http.StatusBadRequest, "code und label sind erforderlich")
|
||||
return
|
||||
}
|
||||
if err := s.Taxonomy.Add(r.Context(), r.PathValue("tenant"), req.Code, req.Label); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "dokumenttyp speichern fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
s.handleDoctypeList(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) handleDoctypeSeed(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.Taxonomy.SeedDefaults(r.Context(), r.PathValue("tenant")); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "standard-dokumenttypen anlegen fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
s.handleDoctypeList(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) handleHighlight(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
TenantSlug string `json:"tenant_slug"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.TenantSlug) == "" || strings.TrimSpace(req.Text) == "" {
|
||||
writeError(w, http.StatusBadRequest, "tenant_slug und text sind erforderlich")
|
||||
return
|
||||
}
|
||||
if !s.enabled(w, r, req.TenantSlug, FunctionHighlight) {
|
||||
return
|
||||
}
|
||||
passages, err := s.Highlight.DetectImportant(r.Context(), req.Text)
|
||||
resp := map[string]any{"passages": nonNil(passages)}
|
||||
if err != nil {
|
||||
// Teilergebnisse bleiben erhalten (highlight liefert sie neben dem Fehler).
|
||||
if len(passages) == 0 {
|
||||
writeError(w, http.StatusBadGateway, "textstellen-erkennung fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
resp["partial"] = true
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
func nonNil(p []highlight.Passage) []highlight.Passage {
|
||||
if p == nil {
|
||||
return []highlight.Passage{}
|
||||
}
|
||||
return p
|
||||
}
|
||||
|
||||
func (s *Server) handleFeatureGet(w http.ResponseWriter, r *http.Request) {
|
||||
on, err := s.Features.IsEnabled(r.Context(), r.PathValue("tenant"), r.PathValue("function"), true)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "funktionsschalter lesen fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]bool{"enabled": on})
|
||||
}
|
||||
|
||||
func (s *Server) handleFeaturePut(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Enabled *bool `json:"enabled"`
|
||||
}
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.Enabled == nil {
|
||||
writeError(w, http.StatusBadRequest, "enabled ist erforderlich")
|
||||
return
|
||||
}
|
||||
if err := s.Features.Set(r.Context(), r.PathValue("tenant"), r.PathValue("function"), *req.Enabled); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "funktionsschalter speichern fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
s.handleFeatureGet(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) handleConfidenceGet(w http.ResponseWriter, r *http.Request) {
|
||||
v, err := s.Thresholds.Get(r.Context(), r.PathValue("tenant"), r.PathValue("function"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "schwellenwert lesen fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]float64{"threshold": v})
|
||||
}
|
||||
|
||||
func (s *Server) handleConfidencePut(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Threshold *float64 `json:"threshold"`
|
||||
}
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.Threshold == nil {
|
||||
writeError(w, http.StatusBadRequest, "threshold ist erforderlich")
|
||||
return
|
||||
}
|
||||
if err := s.Thresholds.Set(r.Context(), r.PathValue("tenant"), r.PathValue("function"), *req.Threshold); err != nil {
|
||||
if errors.Is(err, confidence.ErrInvalidThreshold) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "schwellenwert speichern fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
s.handleConfidenceGet(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) handleModelGet(w http.ResponseWriter, r *http.Request) {
|
||||
sel, err := s.Models.Get(r.Context(), r.PathValue("tenant"), r.PathValue("function"))
|
||||
if errors.Is(err, modelconfig.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "keine modellwahl gesetzt")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "modellwahl lesen fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"provider": sel.ProviderName, "model": sel.Model})
|
||||
}
|
||||
|
||||
func (s *Server) handleModelPut(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Provider string `json:"provider"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Provider) == "" || strings.TrimSpace(req.Model) == "" {
|
||||
writeError(w, http.StatusBadRequest, "provider und model sind erforderlich")
|
||||
return
|
||||
}
|
||||
err := s.Models.Set(r.Context(), r.PathValue("tenant"), r.PathValue("function"), req.Provider, req.Model)
|
||||
switch {
|
||||
case errors.Is(err, modelconfig.ErrUnknownProvider), errors.Is(err, modelconfig.ErrModelNotAvailable):
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
case err != nil:
|
||||
writeError(w, http.StatusBadGateway, "modellwahl speichern fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
s.handleModelGet(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) handleCorrectionRecord(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
TenantSlug string `json:"tenant_slug"`
|
||||
Function string `json:"function"`
|
||||
InputRef string `json:"input_ref"`
|
||||
SuggestedCategory string `json:"suggested_category"`
|
||||
SuggestedConfidence float64 `json:"suggested_confidence"`
|
||||
CorrectedCategory string `json:"corrected_category"`
|
||||
Actor string `json:"actor"`
|
||||
}
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
err := s.Corrections.Record(r.Context(), correction.Correction{
|
||||
TenantSlug: req.TenantSlug, Function: req.Function, InputRef: req.InputRef,
|
||||
SuggestedCategory: req.SuggestedCategory, SuggestedConfidence: req.SuggestedConfidence,
|
||||
CorrectedCategory: req.CorrectedCategory, Actor: req.Actor,
|
||||
})
|
||||
if err != nil {
|
||||
// Record validiert vor dem Schreiben; ein Fehler ist hier fast immer eine unvollstaendige Anfrage.
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNoContent)
|
||||
}
|
||||
|
||||
func (s *Server) handleCorrectionStats(w http.ResponseWriter, r *http.Request) {
|
||||
st, err := s.Corrections.Aggregate(r.Context(), r.PathValue("tenant"), r.PathValue("function"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "korrekturstatistik lesen fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{
|
||||
"total_corrections": st.TotalCorrections,
|
||||
"top_suggested": nonNilMap(st.TopSuggested),
|
||||
"top_corrected_to": nonNilMap(st.TopCorrectedTo),
|
||||
"average_suggested": st.AverageSuggested,
|
||||
})
|
||||
}
|
||||
|
||||
func nonNilMap(m map[string]int) map[string]int {
|
||||
if m == nil {
|
||||
return map[string]int{}
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func (s *Server) handleEmbedIngest(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
TenantSlug string `json:"tenant_slug"`
|
||||
SourceType string `json:"source_type"`
|
||||
SourceID string `json:"source_id"`
|
||||
Text string `json:"text"`
|
||||
}
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.TenantSlug) == "" || strings.TrimSpace(req.SourceType) == "" || strings.TrimSpace(req.SourceID) == "" {
|
||||
writeError(w, http.StatusBadRequest, "tenant_slug, source_type und source_id sind erforderlich")
|
||||
return
|
||||
}
|
||||
chunks, err := s.Embeddings.Ingest(r.Context(), req.TenantSlug, req.SourceType, req.SourceID, req.Text)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "einreihen fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusAccepted, map[string]int{"chunks": len(chunks)})
|
||||
}
|
||||
|
||||
func (s *Server) handleEmbedStatus(w http.ResponseWriter, r *http.Request) {
|
||||
q := r.URL.Query()
|
||||
tenant, st, sid := q.Get("tenant_slug"), q.Get("source_type"), q.Get("source_id")
|
||||
if tenant == "" || st == "" || sid == "" {
|
||||
writeError(w, http.StatusBadRequest, "tenant_slug, source_type und source_id sind erforderlich")
|
||||
return
|
||||
}
|
||||
rows, err := s.Embeddings.Status(r.Context(), tenant, st, sid)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "status lesen fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
type chunk struct {
|
||||
ChunkIndex int `json:"chunk_index"`
|
||||
JobStatus string `json:"job_status"`
|
||||
Attempts int `json:"attempts"`
|
||||
LastError *string `json:"last_error"`
|
||||
HasVector bool `json:"has_vector"`
|
||||
}
|
||||
out := make([]chunk, 0, len(rows))
|
||||
for _, c := range rows {
|
||||
out = append(out, chunk{c.ChunkIndex, c.JobStatus, c.Attempts, c.LastError, c.HasVector})
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"chunks": out})
|
||||
}
|
||||
|
||||
type versionJSON struct {
|
||||
FunctionKey string `json:"function_key"`
|
||||
Version int `json:"version"`
|
||||
Body string `json:"body"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
}
|
||||
|
||||
func toVersionJSON(v prompttemplate.Version) versionJSON {
|
||||
return versionJSON{v.FunctionKey, v.Version, v.Body, v.CreatedBy}
|
||||
}
|
||||
|
||||
func (s *Server) handleTemplateSave(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Body string `json:"body"`
|
||||
CreatedBy string `json:"created_by"`
|
||||
}
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Body) == "" || strings.TrimSpace(req.CreatedBy) == "" {
|
||||
writeError(w, http.StatusBadRequest, "body und created_by sind erforderlich")
|
||||
return
|
||||
}
|
||||
v, err := s.Templates.Save(r.Context(), r.PathValue("key"), req.Body, req.CreatedBy)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "vorlage speichern fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusCreated, toVersionJSON(v))
|
||||
}
|
||||
|
||||
func (s *Server) handleTemplateActive(w http.ResponseWriter, r *http.Request) {
|
||||
v, err := s.Templates.Active(r.Context(), r.PathValue("key"))
|
||||
if errors.Is(err, prompttemplate.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "keine vorlage fuer diese funktion")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "vorlage lesen fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, toVersionJSON(v))
|
||||
}
|
||||
|
||||
func (s *Server) handleTemplateHistory(w http.ResponseWriter, r *http.Request) {
|
||||
vs, err := s.Templates.History(r.Context(), r.PathValue("key"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "verlauf lesen fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
out := make([]versionJSON, 0, len(vs))
|
||||
for _, v := range vs {
|
||||
out = append(out, toVersionJSON(v))
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"versions": out})
|
||||
}
|
||||
|
||||
func (s *Server) handleTemplateActivate(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Version int `json:"version"`
|
||||
ActivatedBy string `json:"activated_by"`
|
||||
}
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if req.Version < 1 || strings.TrimSpace(req.ActivatedBy) == "" {
|
||||
writeError(w, http.StatusBadRequest, "version und activated_by sind erforderlich")
|
||||
return
|
||||
}
|
||||
err := s.Templates.Activate(r.Context(), r.PathValue("key"), req.Version, req.ActivatedBy)
|
||||
if errors.Is(err, prompttemplate.ErrNotFound) {
|
||||
writeError(w, http.StatusNotFound, "version unbekannt")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "version aktivieren fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
s.handleTemplateActive(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) handleSummaryQuality(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Samples []struct {
|
||||
Type string `json:"type"`
|
||||
Source string `json:"source"`
|
||||
Summary string `json:"summary"`
|
||||
} `json:"samples"`
|
||||
}
|
||||
if !decode(w, r, &req) {
|
||||
return
|
||||
}
|
||||
if len(req.Samples) == 0 {
|
||||
writeError(w, http.StatusBadRequest, "samples sind erforderlich")
|
||||
return
|
||||
}
|
||||
type metricJSON struct {
|
||||
Type string `json:"type"`
|
||||
GroundednessScore float64 `json:"groundedness_score"`
|
||||
Flagged bool `json:"flagged"`
|
||||
Reason string `json:"reason"`
|
||||
}
|
||||
metrics := make([]summaryquality.Metric, 0, len(req.Samples))
|
||||
items := make([]metricJSON, 0, len(req.Samples))
|
||||
for _, sm := range req.Samples {
|
||||
m := summaryquality.Evaluate(summaryquality.Sample{Type: sm.Type, Source: sm.Source, Summary: sm.Summary})
|
||||
metrics = append(metrics, m)
|
||||
items = append(items, metricJSON{sm.Type, m.GroundednessScore, m.Flagged, m.Reason})
|
||||
}
|
||||
type typeJSON struct {
|
||||
Count int `json:"count"`
|
||||
AverageGroundedness float64 `json:"average_groundedness"`
|
||||
FlaggedCount int `json:"flagged_count"`
|
||||
}
|
||||
byType := map[string]typeJSON{}
|
||||
for k, v := range summaryquality.AggregateByType(metrics) {
|
||||
byType[k] = typeJSON{v.Count, v.AverageGroundedness, v.FlaggedCount}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]any{"metrics": items, "by_type": byType})
|
||||
}
|
||||
|
||||
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, msg string) {
|
||||
writeJSON(w, status, map[string]string{"error": msg})
|
||||
}
|
||||
@@ -1,387 +0,0 @@
|
||||
package featureapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/classify"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/confidence"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/correction"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/embedpipeline"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/highlight"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/modelconfig"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/prompttemplate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/mock"
|
||||
)
|
||||
|
||||
type fakeFeatures struct{ off map[string]bool }
|
||||
|
||||
func (f *fakeFeatures) Set(_ context.Context, t, fn string, on bool) error {
|
||||
f.off[t+"/"+fn] = !on
|
||||
return nil
|
||||
}
|
||||
func (f *fakeFeatures) IsEnabled(_ context.Context, t, fn string, global bool) (bool, error) {
|
||||
return global && !f.off[t+"/"+fn], nil
|
||||
}
|
||||
|
||||
type fakeThresholds struct{ v map[string]float64 }
|
||||
|
||||
func (f *fakeThresholds) Set(_ context.Context, t, fn string, th float64) error {
|
||||
if th < 0 || th > 1 {
|
||||
return confidence.ErrInvalidThreshold
|
||||
}
|
||||
f.v[t+"/"+fn] = th
|
||||
return nil
|
||||
}
|
||||
func (f *fakeThresholds) Get(_ context.Context, t, fn string) (float64, error) {
|
||||
if v, ok := f.v[t+"/"+fn]; ok {
|
||||
return v, nil
|
||||
}
|
||||
return confidence.DefaultThreshold, nil
|
||||
}
|
||||
func (f *fakeThresholds) MeetsThreshold(ctx context.Context, t, fn string, value float64) (bool, error) {
|
||||
th, _ := f.Get(ctx, t, fn)
|
||||
return value >= th, nil
|
||||
}
|
||||
|
||||
type fakeTaxonomy struct{ codes map[string][]string }
|
||||
|
||||
func (f *fakeTaxonomy) List(_ context.Context, t string) ([]string, error) { return f.codes[t], nil }
|
||||
func (f *fakeTaxonomy) Add(_ context.Context, t, code, _ string) error {
|
||||
f.codes[t] = append(f.codes[t], code)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeTaxonomy) SeedDefaults(_ context.Context, t string) error {
|
||||
f.codes[t] = []string{"rechnung", "vertrag"}
|
||||
return nil
|
||||
}
|
||||
|
||||
type fakeCorrections struct{ got []correction.Correction }
|
||||
|
||||
func (f *fakeCorrections) Record(_ context.Context, c correction.Correction) error {
|
||||
if c.Actor == "" {
|
||||
return errors.New("correction: actor darf nicht leer sein")
|
||||
}
|
||||
f.got = append(f.got, c)
|
||||
return nil
|
||||
}
|
||||
func (f *fakeCorrections) Aggregate(_ context.Context, t, fn string) (correction.Stats, error) {
|
||||
return correction.Stats{TenantSlug: t, Function: fn, TotalCorrections: len(f.got)}, nil
|
||||
}
|
||||
|
||||
type fakeEmbeddings struct{}
|
||||
|
||||
func (fakeEmbeddings) Ingest(_ context.Context, _, _, _, text string) ([]embedpipeline.Chunk, error) {
|
||||
return []embedpipeline.Chunk{{Index: 0, Text: text}, {Index: 1, Text: text}}, nil
|
||||
}
|
||||
func (fakeEmbeddings) Status(_ context.Context, _, _, _ string) ([]embedpipeline.ChunkStatus, error) {
|
||||
return []embedpipeline.ChunkStatus{{ChunkIndex: 0, JobStatus: "done", Attempts: 1, HasVector: true}}, nil
|
||||
}
|
||||
|
||||
type fakeModels struct {
|
||||
sel map[string]modelconfig.Selection
|
||||
}
|
||||
|
||||
func (f *fakeModels) Set(_ context.Context, t, fn, prov, model string) error {
|
||||
if prov != "ollama" {
|
||||
return modelconfig.ErrUnknownProvider
|
||||
}
|
||||
if model != "known" {
|
||||
return modelconfig.ErrModelNotAvailable
|
||||
}
|
||||
f.sel[t+"/"+fn] = modelconfig.Selection{ProviderName: prov, Model: model}
|
||||
return nil
|
||||
}
|
||||
func (f *fakeModels) Get(_ context.Context, t, fn string) (modelconfig.Selection, error) {
|
||||
if s, ok := f.sel[t+"/"+fn]; ok {
|
||||
return s, nil
|
||||
}
|
||||
return modelconfig.Selection{}, modelconfig.ErrNotFound
|
||||
}
|
||||
|
||||
type fakeTemplates struct {
|
||||
vs map[string][]prompttemplate.Version
|
||||
}
|
||||
|
||||
func (f *fakeTemplates) Save(_ context.Context, key, body, by string) (prompttemplate.Version, error) {
|
||||
v := prompttemplate.Version{FunctionKey: key, Version: len(f.vs[key]) + 1, Body: body, CreatedBy: by}
|
||||
f.vs[key] = append(f.vs[key], v)
|
||||
return v, nil
|
||||
}
|
||||
func (f *fakeTemplates) Activate(_ context.Context, key string, version int, _ string) error {
|
||||
if version > len(f.vs[key]) {
|
||||
return prompttemplate.ErrNotFound
|
||||
}
|
||||
f.vs[key] = append(f.vs[key][:version:version], f.vs[key][version-1])
|
||||
return nil
|
||||
}
|
||||
func (f *fakeTemplates) Active(_ context.Context, key string) (prompttemplate.Version, error) {
|
||||
if len(f.vs[key]) == 0 {
|
||||
return prompttemplate.Version{}, prompttemplate.ErrNotFound
|
||||
}
|
||||
return f.vs[key][len(f.vs[key])-1], nil
|
||||
}
|
||||
func (f *fakeTemplates) History(_ context.Context, key string) ([]prompttemplate.Version, error) {
|
||||
return f.vs[key], nil
|
||||
}
|
||||
|
||||
type fixture struct {
|
||||
srv *httptest.Server
|
||||
prov *mock.Provider
|
||||
features *fakeFeatures
|
||||
corr *fakeCorrections
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T) *fixture {
|
||||
t.Helper()
|
||||
f := &fixture{prov: &mock.Provider{}, features: &fakeFeatures{off: map[string]bool{}}, corr: &fakeCorrections{}}
|
||||
s := &Server{
|
||||
Classifier: &classify.Classifier{Provider: f.prov, Model: "m"},
|
||||
Highlight: &highlight.Detector{Provider: f.prov, Model: "m"},
|
||||
Taxonomy: &fakeTaxonomy{codes: map[string][]string{}},
|
||||
Features: f.features,
|
||||
Thresholds: &fakeThresholds{v: map[string]float64{}},
|
||||
Corrections: f.corr,
|
||||
Embeddings: fakeEmbeddings{},
|
||||
Models: &fakeModels{sel: map[string]modelconfig.Selection{}},
|
||||
Templates: &fakeTemplates{vs: map[string][]prompttemplate.Version{}},
|
||||
ServiceToken: "tok",
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
s.RegisterRoutes(mux)
|
||||
f.srv = httptest.NewServer(mux)
|
||||
t.Cleanup(f.srv.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
func (f *fixture) reply(content string) {
|
||||
f.prov.ChatResponse = provider.ChatResponse{Message: provider.Message{Role: provider.RoleAssistant, Content: content}}
|
||||
}
|
||||
|
||||
func (f *fixture) do(t *testing.T, method, path, body string, token bool) (int, map[string]any) {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest(method, f.srv.URL+path, strings.NewReader(body))
|
||||
if token {
|
||||
req.Header.Set("X-Service-Token", "tok")
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var out map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||
return resp.StatusCode, out
|
||||
}
|
||||
|
||||
func TestAllRoutesRequireServiceToken(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
for _, r := range [][2]string{
|
||||
{"POST", "/classify"}, {"POST", "/doctype/detect"}, {"GET", "/tenants/a/doctypes"}, {"POST", "/tenants/a/doctypes"},
|
||||
{"POST", "/tenants/a/doctypes/defaults"}, {"POST", "/highlight"},
|
||||
{"GET", "/tenants/a/features/classify"}, {"PUT", "/tenants/a/features/classify"},
|
||||
{"GET", "/tenants/a/confidence/classify"}, {"PUT", "/tenants/a/confidence/classify"},
|
||||
{"GET", "/tenants/a/model-config/chat"}, {"PUT", "/tenants/a/model-config/chat"},
|
||||
{"POST", "/corrections"}, {"GET", "/tenants/a/corrections/classify/stats"},
|
||||
{"POST", "/embeddings/ingest"}, {"GET", "/embeddings/status"},
|
||||
{"POST", "/prompt-templates/k"}, {"GET", "/prompt-templates/k"}, {"GET", "/prompt-templates/k/history"}, {"PUT", "/prompt-templates/k/active"},
|
||||
{"POST", "/summary-quality/evaluate"},
|
||||
} {
|
||||
if code, _ := f.do(t, r[0], r[1], `{}`, false); code != http.StatusUnauthorized {
|
||||
t.Errorf("%s %s: status %d, want 401", r[0], r[1], code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassify_ReturnsCategoryAndThresholdVerdict(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.reply(`{"category":"rechnung","confidence":0.9,"reasoning":"x"}`)
|
||||
code, out := f.do(t, "PUT", "/tenants/acme/confidence/classify", `{"threshold":0.95}`, true)
|
||||
if code != http.StatusOK || out["threshold"] != 0.95 {
|
||||
t.Fatalf("threshold put: %d %v", code, out)
|
||||
}
|
||||
code, out = f.do(t, "POST", "/classify", `{"tenant_slug":"acme","categories":["rechnung","vertrag"],"text":"Rechnung Nr. 1"}`, true)
|
||||
if code != http.StatusOK || out["category"] != "rechnung" {
|
||||
t.Fatalf("classify: %d %v", code, out)
|
||||
}
|
||||
if out["meets_threshold"] != false || out["audited"] != false {
|
||||
t.Errorf("0.9 < 0.95 muss meets_threshold=false liefern, ohne Audit audited=false: %v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassify_DisabledForTenantIsForbidden(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.reply(`{"category":"rechnung","confidence":0.9}`)
|
||||
if code, _ := f.do(t, "PUT", "/tenants/acme/features/classify", `{"enabled":false}`, true); code != http.StatusOK {
|
||||
t.Fatalf("feature put: %d", code)
|
||||
}
|
||||
code, _ := f.do(t, "POST", "/classify", `{"tenant_slug":"acme","categories":["rechnung"],"text":"x"}`, true)
|
||||
if code != http.StatusForbidden {
|
||||
t.Fatalf("status %d, want 403", code)
|
||||
}
|
||||
if code, _ := f.do(t, "POST", "/classify", `{"tenant_slug":"other","categories":["rechnung"],"text":"x"}`, true); code != http.StatusOK {
|
||||
t.Fatalf("anderer mandant: status %d, want 200", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassify_RejectsIncompleteRequest(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
for _, body := range []string{`{}`, `{"tenant_slug":"a","text":"x"}`, `{"tenant_slug":"a","categories":["x"]}`, `nope`} {
|
||||
if code, _ := f.do(t, "POST", "/classify", body, true); code != http.StatusBadRequest {
|
||||
t.Errorf("%q: status %d, want 400", body, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestClassify_ProviderErrorIsBadGateway(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.prov.Err = errors.New("down")
|
||||
code, _ := f.do(t, "POST", "/classify", `{"tenant_slug":"a","categories":["x"],"text":"t"}`, true)
|
||||
if code != http.StatusBadGateway {
|
||||
t.Fatalf("status %d, want 502", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoctype_TaxonomyManagementAndEmptyTaxonomyConflict(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
if code, _ := f.do(t, "POST", "/doctype/detect", `{"tenant_slug":"acme","text":"x"}`, true); code != http.StatusConflict {
|
||||
t.Fatalf("leere taxonomie: status %d, want 409", code)
|
||||
}
|
||||
code, out := f.do(t, "POST", "/tenants/acme/doctypes/defaults", `{}`, true)
|
||||
if code != http.StatusOK || len(out["doc_types"].([]any)) != 2 {
|
||||
t.Fatalf("defaults: %d %v", code, out)
|
||||
}
|
||||
code, out = f.do(t, "POST", "/tenants/acme/doctypes", `{"code":"kuendigung","label":"Kuendigung"}`, true)
|
||||
if code != http.StatusOK || len(out["doc_types"].([]any)) != 3 {
|
||||
t.Fatalf("add: %d %v", code, out)
|
||||
}
|
||||
if code, _ := f.do(t, "POST", "/tenants/acme/doctypes", `{"code":"x"}`, true); code != http.StatusBadRequest {
|
||||
t.Fatalf("add ohne label: status %d, want 400", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHighlight_ReturnsPassagesOrEmptyList(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
f.reply(`[]`)
|
||||
code, out := f.do(t, "POST", "/highlight", `{"tenant_slug":"acme","text":"Frist endet am 1. Mai."}`, true)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status %d", code)
|
||||
}
|
||||
if ps, ok := out["passages"].([]any); !ok || len(ps) != 0 {
|
||||
t.Errorf("passages = %v, want leere liste", out["passages"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestConfidence_RejectsOutOfRange(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
if code, _ := f.do(t, "PUT", "/tenants/a/confidence/classify", `{"threshold":1.5}`, true); code != http.StatusBadRequest {
|
||||
t.Fatalf("status %d, want 400", code)
|
||||
}
|
||||
if code, _ := f.do(t, "PUT", "/tenants/a/confidence/classify", `{}`, true); code != http.StatusBadRequest {
|
||||
t.Fatalf("ohne threshold: status %d, want 400", code)
|
||||
}
|
||||
_, out := f.do(t, "GET", "/tenants/a/confidence/classify", ``, true)
|
||||
if out["threshold"] != confidence.DefaultThreshold {
|
||||
t.Errorf("default = %v", out["threshold"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelConfig_ValidationAndRoundTrip(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
if code, _ := f.do(t, "GET", "/tenants/a/model-config/chat", ``, true); code != http.StatusNotFound {
|
||||
t.Fatalf("ungesetzt: status %d, want 404", code)
|
||||
}
|
||||
if code, _ := f.do(t, "PUT", "/tenants/a/model-config/chat", `{"provider":"nope","model":"known"}`, true); code != http.StatusBadRequest {
|
||||
t.Fatalf("unbekannter provider: status %d, want 400", code)
|
||||
}
|
||||
if code, _ := f.do(t, "PUT", "/tenants/a/model-config/chat", `{"provider":"ollama","model":"weg"}`, true); code != http.StatusBadRequest {
|
||||
t.Fatalf("unbekanntes modell: status %d, want 400", code)
|
||||
}
|
||||
code, out := f.do(t, "PUT", "/tenants/a/model-config/chat", `{"provider":"ollama","model":"known"}`, true)
|
||||
if code != http.StatusOK || out["model"] != "known" {
|
||||
t.Fatalf("put: %d %v", code, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCorrections_RecordAndStats(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
body := `{"tenant_slug":"a","function":"classify","input_ref":"d1","suggested_category":"x","suggested_confidence":0.4,"corrected_category":"y","actor":"u1"}`
|
||||
if code, _ := f.do(t, "POST", "/corrections", body, true); code != http.StatusNoContent {
|
||||
t.Fatalf("record: status %d, want 204", code)
|
||||
}
|
||||
if code, _ := f.do(t, "POST", "/corrections", `{"tenant_slug":"a"}`, true); code != http.StatusBadRequest {
|
||||
t.Fatalf("unvollstaendig: status %d, want 400", code)
|
||||
}
|
||||
code, out := f.do(t, "GET", "/tenants/a/corrections/classify/stats", ``, true)
|
||||
if code != http.StatusOK || out["total_corrections"] != 1.0 {
|
||||
t.Fatalf("stats: %d %v", code, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmbeddings_IngestAndStatus(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
code, out := f.do(t, "POST", "/embeddings/ingest", `{"tenant_slug":"a","source_type":"doc","source_id":"1","text":"abc"}`, true)
|
||||
if code != http.StatusAccepted || out["chunks"] != 2.0 {
|
||||
t.Fatalf("ingest: %d %v", code, out)
|
||||
}
|
||||
if code, _ := f.do(t, "POST", "/embeddings/ingest", `{"tenant_slug":"a"}`, true); code != http.StatusBadRequest {
|
||||
t.Fatalf("unvollstaendig: status %d, want 400", code)
|
||||
}
|
||||
code, out = f.do(t, "GET", "/embeddings/status?tenant_slug=a&source_type=doc&source_id=1", ``, true)
|
||||
if code != http.StatusOK || len(out["chunks"].([]any)) != 1 {
|
||||
t.Fatalf("status: %d %v", code, out)
|
||||
}
|
||||
if code, _ := f.do(t, "GET", "/embeddings/status", ``, true); code != http.StatusBadRequest {
|
||||
t.Fatalf("ohne parameter: status %d, want 400", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptTemplates_VersionsAndRollback(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
if code, _ := f.do(t, "GET", "/prompt-templates/k", ``, true); code != http.StatusNotFound {
|
||||
t.Fatalf("ohne vorlage: status %d, want 404", code)
|
||||
}
|
||||
f.do(t, "POST", "/prompt-templates/k", `{"body":"eins","created_by":"u"}`, true)
|
||||
code, out := f.do(t, "POST", "/prompt-templates/k", `{"body":"zwei","created_by":"u"}`, true)
|
||||
if code != http.StatusCreated || out["version"] != 2.0 {
|
||||
t.Fatalf("save: %d %v", code, out)
|
||||
}
|
||||
if code, _ := f.do(t, "PUT", "/prompt-templates/k/active", `{"version":9,"activated_by":"u"}`, true); code != http.StatusNotFound {
|
||||
t.Fatalf("unbekannte version: status %d, want 404", code)
|
||||
}
|
||||
code, out = f.do(t, "PUT", "/prompt-templates/k/active", `{"version":1,"activated_by":"u"}`, true)
|
||||
if code != http.StatusOK || out["body"] != "eins" {
|
||||
t.Fatalf("rollback: %d %v", code, out)
|
||||
}
|
||||
if code, _ := f.do(t, "POST", "/prompt-templates/k", `{"body":"x"}`, true); code != http.StatusBadRequest {
|
||||
t.Fatalf("ohne created_by: status %d, want 400", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSummaryQuality_FlagsFabricatedSummary(t *testing.T) {
|
||||
f := newFixture(t)
|
||||
body := `{"samples":[
|
||||
{"type":"rechnung","source":"Die Rechnung ueber 100 Euro ist bis Freitag faellig","summary":"Rechnung ueber 100 Euro faellig bis Freitag"},
|
||||
{"type":"rechnung","source":"Die Rechnung ueber 100 Euro ist bis Freitag faellig","summary":"Vertrag wurde fristlos gekuendigt Schadenersatz Klage erhoben"}]}`
|
||||
code, out := f.do(t, "POST", "/summary-quality/evaluate", body, true)
|
||||
if code != http.StatusOK {
|
||||
t.Fatalf("status %d", code)
|
||||
}
|
||||
items := out["metrics"].([]any)
|
||||
if items[0].(map[string]any)["flagged"] != false || items[1].(map[string]any)["flagged"] != true {
|
||||
t.Errorf("flags = %v", items)
|
||||
}
|
||||
bt := out["by_type"].(map[string]any)["rechnung"].(map[string]any)
|
||||
if bt["count"] != 2.0 || bt["flagged_count"] != 1.0 {
|
||||
t.Errorf("by_type = %v", bt)
|
||||
}
|
||||
if code, _ := f.do(t, "POST", "/summary-quality/evaluate", `{"samples":[]}`, true); code != http.StatusBadRequest {
|
||||
t.Fatalf("leer: status %d, want 400", code)
|
||||
}
|
||||
}
|
||||
@@ -81,7 +81,7 @@ func TestPush_UnknownFieldKey_ReportedNotDropped(t *testing.T) {
|
||||
|
||||
c := New(srv.URL, "svc-token-123")
|
||||
result, err := c.Push(context.Background(), "doc-1", "ai", map[string]string{
|
||||
"rechnungsdatum": "2026-01-15",
|
||||
"rechnungsdatum": "2026-01-15",
|
||||
"nicht_existent_feld": "wert",
|
||||
})
|
||||
if err != nil {
|
||||
|
||||
+205
-11
@@ -1,27 +1,221 @@
|
||||
// Package migrate bindet die gemeinsame Migrationslogik aus common an den
|
||||
// modulspezifischen Fortschrittstabellennamen.
|
||||
// Package migrate implements versioned, revertible SQL migrations (no ORM) — a
|
||||
// standalone copy of the Core/DMS pattern, since AI is its own Go module
|
||||
// and cannot import other modules' internal/ packages.
|
||||
package migrate
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
cmigrate "gitea.perlbach24.de/scripte/nexarch/common/migrate"
|
||||
)
|
||||
|
||||
type Migration = cmigrate.Migration
|
||||
// Migration ist eine einzelne versionierte Migrationsdatei.
|
||||
type Migration struct {
|
||||
Version string // Dateiname ohne .up.sql/.down.sql, z.B. "0001_documents"
|
||||
UpSQL string
|
||||
DownSQL string
|
||||
}
|
||||
|
||||
// Load liest alle *.up.sql/*.down.sql-Paare aus dir, sortiert nach
|
||||
// Dateiname (Akzeptanzkriterium: Migrationen sind versioniert).
|
||||
func Load(dir string) ([]Migration, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("migrationsverzeichnis %q lesen: %w", dir, err)
|
||||
}
|
||||
var versions []string
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(e.Name(), ".up.sql") {
|
||||
continue
|
||||
}
|
||||
versions = append(versions, strings.TrimSuffix(e.Name(), ".up.sql"))
|
||||
}
|
||||
sort.Strings(versions)
|
||||
|
||||
migrations := make([]Migration, 0, len(versions))
|
||||
for _, v := range versions {
|
||||
up, err := os.ReadFile(filepath.Join(dir, v+".up.sql"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("migration %q: up.sql lesen: %w", v, err)
|
||||
}
|
||||
down, err := os.ReadFile(filepath.Join(dir, v+".down.sql"))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("migration %q: down.sql lesen (jede Migration braucht ein Rollback): %w", v, err)
|
||||
}
|
||||
migrations = append(migrations, Migration{Version: v, UpSQL: string(up), DownSQL: string(down)})
|
||||
}
|
||||
return migrations, nil
|
||||
}
|
||||
|
||||
// TrackingTable ist modulspezifisch benannt, damit sich Module in
|
||||
// derselben Datenbank/demselben Schema nicht gegenseitig den
|
||||
// Migrationsfortschritt ueberschreiben (FDN-13).
|
||||
const TrackingTable = "connect_schema_migrations"
|
||||
|
||||
var migrator = cmigrate.MustNew(TrackingTable)
|
||||
const legacyTrackingTable = "schema_migrations"
|
||||
|
||||
func Load(dir string) ([]Migration, error) { return cmigrate.Load(dir) }
|
||||
// ensureTrackingTable legt TrackingTable an. Beim erstmaligen Anlegen wird
|
||||
// ein vorhandener Bestand aus der frueheren, unqualifizierten
|
||||
// schema_migrations uebernommen (nur Versionen, die dieses Modul kennt, und
|
||||
// nur wenn dessen Basisversion dort steht — eine schema_migrations eines
|
||||
// anderen Moduls oder eines anderen Schemas wird weder gelesen noch
|
||||
// veraendert). Die alte Tabelle
|
||||
// bleibt unangetastet.
|
||||
func ensureTrackingTable(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) error {
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s anlegen: %w", TrackingTable, err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
|
||||
func Up(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) ([]string, error) {
|
||||
return migrator.Up(ctx, pool, migrations)
|
||||
// Serialisiert gleichzeitige Erstanlage/Uebernahme (mehrere App-Instanzen).
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtext('connect_schema_migrations'))`); err != nil {
|
||||
return fmt.Errorf("%s anlegen: %w", TrackingTable, err)
|
||||
}
|
||||
|
||||
var existed bool
|
||||
if err := tx.QueryRow(ctx, `SELECT to_regclass(current_schema() || '.' || $1::text) IS NOT NULL`, TrackingTable).Scan(&existed); err != nil {
|
||||
return fmt.Errorf("%s anlegen: %w", TrackingTable, err)
|
||||
}
|
||||
if existed {
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
if _, err := tx.Exec(ctx, `
|
||||
CREATE TABLE `+TrackingTable+` (
|
||||
version TEXT PRIMARY KEY,
|
||||
applied_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
)
|
||||
`); err != nil {
|
||||
return fmt.Errorf("%s anlegen: %w", TrackingTable, err)
|
||||
}
|
||||
|
||||
if len(migrations) > 0 {
|
||||
var legacy bool
|
||||
if err := tx.QueryRow(ctx, `SELECT to_regclass(current_schema() || '.' || $1::text) IS NOT NULL`, legacyTrackingTable).Scan(&legacy); err != nil {
|
||||
return fmt.Errorf("%s pruefen: %w", legacyTrackingTable, err)
|
||||
}
|
||||
if legacy {
|
||||
versions := make([]string, len(migrations))
|
||||
for i, m := range migrations {
|
||||
versions[i] = m.Version
|
||||
}
|
||||
var hasBase bool
|
||||
if err := tx.QueryRow(ctx,
|
||||
`SELECT EXISTS (SELECT 1 FROM `+legacyTrackingTable+` WHERE version = $1)`, versions[0],
|
||||
).Scan(&hasBase); err != nil {
|
||||
return fmt.Errorf("%s lesen: %w", legacyTrackingTable, err)
|
||||
}
|
||||
if hasBase {
|
||||
if _, err := tx.Exec(ctx, `
|
||||
INSERT INTO `+TrackingTable+` (version, applied_at)
|
||||
SELECT version, applied_at FROM `+legacyTrackingTable+` WHERE version = ANY($1)
|
||||
`, versions); err != nil {
|
||||
return fmt.Errorf("bestand aus %s uebernehmen: %w", legacyTrackingTable, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func DownOne(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) (string, error) {
|
||||
return migrator.DownOne(ctx, pool, migrations)
|
||||
func appliedVersions(ctx context.Context, pool *pgxpool.Pool) (map[string]bool, error) {
|
||||
rows, err := pool.Query(ctx, `SELECT version FROM `+TrackingTable)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("angewendete migrationen lesen: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
applied := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var v string
|
||||
if err := rows.Scan(&v); err != nil {
|
||||
return nil, fmt.Errorf("migrationsversion lesen: %w", err)
|
||||
}
|
||||
applied[v] = true
|
||||
}
|
||||
return applied, rows.Err()
|
||||
}
|
||||
|
||||
// Up wendet alle noch nicht angewendeten Migrationen in Reihenfolge an
|
||||
// (Akzeptanzkriterium 2: vorwaerts ausfuehrbar) — bereits angewendete
|
||||
// werden uebersprungen, damit Up auf einer leeren UND auf einer bestehenden
|
||||
// DB funktioniert (Pruefung 1).
|
||||
func Up(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) (applied []string, err error) {
|
||||
if err := ensureTrackingTable(ctx, pool, migrations); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
already, err := appliedVersions(ctx, pool)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
for _, m := range migrations {
|
||||
if already[m.Version] {
|
||||
continue
|
||||
}
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return applied, fmt.Errorf("transaktion fuer %q starten: %w", m.Version, err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, m.UpSQL); err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
return applied, fmt.Errorf("migration %q anwenden: %w", m.Version, err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO `+TrackingTable+` (version) VALUES ($1)`, m.Version); err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
return applied, fmt.Errorf("migration %q als angewendet markieren: %w", m.Version, err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return applied, fmt.Errorf("migration %q committen: %w", m.Version, err)
|
||||
}
|
||||
applied = append(applied, m.Version)
|
||||
}
|
||||
return applied, nil
|
||||
}
|
||||
|
||||
// DownOne macht die zuletzt angewendete Migration rueckgaengig
|
||||
// (Akzeptanzkriterium 2: rueckwaerts ausfuehrbar) und liefert deren Version,
|
||||
// oder "" falls keine Migration angewendet war.
|
||||
func DownOne(ctx context.Context, pool *pgxpool.Pool, migrations []Migration) (version string, err error) {
|
||||
if err := ensureTrackingTable(ctx, pool, migrations); err != nil {
|
||||
return "", err
|
||||
}
|
||||
already, err := appliedVersions(ctx, pool)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
var last *Migration
|
||||
for i := len(migrations) - 1; i >= 0; i-- {
|
||||
if already[migrations[i].Version] {
|
||||
last = &migrations[i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if last == nil {
|
||||
return "", nil
|
||||
}
|
||||
|
||||
tx, err := pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("transaktion fuer rollback von %q starten: %w", last.Version, err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, last.DownSQL); err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
return "", fmt.Errorf("migration %q zurueckrollen: %w", last.Version, err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM `+TrackingTable+` WHERE version = $1`, last.Version); err != nil {
|
||||
_ = tx.Rollback(ctx)
|
||||
return "", fmt.Errorf("migration %q aus %s entfernen: %w", last.Version, TrackingTable, err)
|
||||
}
|
||||
if err := tx.Commit(ctx); err != nil {
|
||||
return "", fmt.Errorf("rollback von %q committen: %w", last.Version, err)
|
||||
}
|
||||
return last.Version, nil
|
||||
}
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/modelconfig"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
|
||||
)
|
||||
|
||||
type fakeLister struct {
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
|
||||
)
|
||||
|
||||
// fakeVerifier simuliert Core TEN-01 fuer die Registry-Tests, ohne einen
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/moduletype"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
|
||||
)
|
||||
|
||||
type fakeVerifier struct{ validTenants map[string]bool }
|
||||
|
||||
@@ -1,133 +0,0 @@
|
||||
package originmark
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"unicode"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/decisionaudit"
|
||||
)
|
||||
|
||||
const MaxMarkerLen = 64
|
||||
|
||||
var ErrInvalidMarker = errors.New("originmark: markierung muss 1 bis 64 druckbare zeichen ohne steuerzeichen haben")
|
||||
|
||||
type Store struct {
|
||||
Pool *pgxpool.Pool
|
||||
}
|
||||
|
||||
func validate(marker string) (string, error) {
|
||||
marker = strings.TrimSpace(marker)
|
||||
if marker == "" || len([]rune(marker)) > MaxMarkerLen {
|
||||
return "", ErrInvalidMarker
|
||||
}
|
||||
for _, r := range marker {
|
||||
if unicode.IsControl(r) {
|
||||
return "", ErrInvalidMarker
|
||||
}
|
||||
}
|
||||
return marker, nil
|
||||
}
|
||||
|
||||
func (s *Store) Set(ctx context.Context, tenantSlug, marker string) error {
|
||||
tenantSlug = strings.TrimSpace(tenantSlug)
|
||||
if tenantSlug == "" {
|
||||
return fmt.Errorf("originmark: tenantSlug darf nicht leer sein")
|
||||
}
|
||||
marker, err := validate(marker)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
_, err = s.Pool.Exec(ctx, `
|
||||
INSERT INTO ai_origin_markers (tenant_slug, marker, updated_at)
|
||||
VALUES ($1, $2, now())
|
||||
ON CONFLICT (tenant_slug) DO UPDATE SET marker = EXCLUDED.marker, updated_at = now()
|
||||
`, tenantSlug, marker)
|
||||
if err != nil {
|
||||
return fmt.Errorf("originmark: speichern: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Store) Clear(ctx context.Context, tenantSlug string) error {
|
||||
if _, err := s.Pool.Exec(ctx, `DELETE FROM ai_origin_markers WHERE tenant_slug = $1`, tenantSlug); err != nil {
|
||||
return fmt.Errorf("originmark: loeschen: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Get liefert "" ohne Konfiguration.
|
||||
func (s *Store) Get(ctx context.Context, tenantSlug string) (string, error) {
|
||||
var marker string
|
||||
err := s.Pool.QueryRow(ctx, `SELECT marker FROM ai_origin_markers WHERE tenant_slug = $1`, tenantSlug).Scan(&marker)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return "", nil
|
||||
}
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("originmark: lesen: %w", err)
|
||||
}
|
||||
return marker, nil
|
||||
}
|
||||
|
||||
type Source string
|
||||
|
||||
const (
|
||||
SourceAI Source = "ai"
|
||||
SourceManual Source = "manual"
|
||||
)
|
||||
|
||||
type Adoption struct {
|
||||
Source Source
|
||||
TenantSlug string
|
||||
Actor string
|
||||
Function string
|
||||
Model string
|
||||
PromptVersion int
|
||||
InputRef string
|
||||
Kind string
|
||||
Value string
|
||||
}
|
||||
|
||||
type MarkerReader interface {
|
||||
Get(ctx context.Context, tenantSlug string) (string, error)
|
||||
}
|
||||
|
||||
type AuditRecorder interface {
|
||||
Record(ctx context.Context, d decisionaudit.Decision) error
|
||||
}
|
||||
|
||||
type Service struct {
|
||||
Markers MarkerReader
|
||||
Audit AuditRecorder
|
||||
}
|
||||
|
||||
// Apply liefert die Herkunftsmarkierung, die der Aufrufer an die uebernommene
|
||||
// Zuordnung haengen soll; "" fuer manuelle Zuordnungen oder ohne Konfiguration.
|
||||
// Eine KI-Uebernahme wird immer im Audit festgehalten, mit Markierung falls gesetzt.
|
||||
func (s *Service) Apply(ctx context.Context, a Adoption) (string, error) {
|
||||
if a.Source != SourceAI {
|
||||
return "", nil
|
||||
}
|
||||
marker, err := s.Markers.Get(ctx, a.TenantSlug)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
result := map[string]any{"kind": a.Kind, "value": a.Value, "origin_marker": marker}
|
||||
if err := s.Audit.Record(ctx, decisionaudit.Decision{
|
||||
TenantSlug: a.TenantSlug,
|
||||
Actor: a.Actor,
|
||||
Function: a.Function,
|
||||
Model: a.Model,
|
||||
PromptVersion: a.PromptVersion,
|
||||
InputRef: a.InputRef,
|
||||
Result: result,
|
||||
}); err != nil {
|
||||
return "", fmt.Errorf("originmark: audit: %w", err)
|
||||
}
|
||||
return marker, nil
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
package originmark
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/decisionaudit"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
|
||||
)
|
||||
|
||||
type fakeMarkers map[string]string
|
||||
|
||||
func (f fakeMarkers) Get(_ context.Context, tenant string) (string, error) { return f[tenant], nil }
|
||||
|
||||
type fakeAudit struct {
|
||||
got []decisionaudit.Decision
|
||||
err error
|
||||
}
|
||||
|
||||
func (f *fakeAudit) Record(_ context.Context, d decisionaudit.Decision) error {
|
||||
f.got = append(f.got, d)
|
||||
return f.err
|
||||
}
|
||||
|
||||
func adoption(src Source, tenant string) Adoption {
|
||||
return Adoption{Source: src, TenantSlug: tenant, Actor: "u1", Function: "tagsuggest", Model: "m", PromptVersion: 1, InputRef: "doc-1", Kind: "tag", Value: "rechnung"}
|
||||
}
|
||||
|
||||
func TestApply_AIAdoptionGetsMarkerAndAudit(t *testing.T) {
|
||||
audit := &fakeAudit{}
|
||||
s := &Service{Markers: fakeMarkers{"acme": "ai-processed"}, Audit: audit}
|
||||
got, err := s.Apply(context.Background(), adoption(SourceAI, "acme"))
|
||||
if err != nil || got != "ai-processed" {
|
||||
t.Fatalf("got=%q err=%v", got, err)
|
||||
}
|
||||
if len(audit.got) != 1 || audit.got[0].Result["origin_marker"] != "ai-processed" || audit.got[0].Result["value"] != "rechnung" {
|
||||
t.Fatalf("audit: %+v", audit.got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_ManualNeverMarkedNorAudited(t *testing.T) {
|
||||
audit := &fakeAudit{}
|
||||
s := &Service{Markers: fakeMarkers{"acme": "ai-processed"}, Audit: audit}
|
||||
got, err := s.Apply(context.Background(), adoption(SourceManual, "acme"))
|
||||
if err != nil || got != "" || len(audit.got) != 0 {
|
||||
t.Fatalf("got=%q err=%v audit=%d", got, err, len(audit.got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_NoConfigNoMarkerButAudited(t *testing.T) {
|
||||
audit := &fakeAudit{}
|
||||
s := &Service{Markers: fakeMarkers{}, Audit: audit}
|
||||
got, err := s.Apply(context.Background(), adoption(SourceAI, "acme"))
|
||||
if err != nil || got != "" || len(audit.got) != 1 || audit.got[0].Result["origin_marker"] != "" {
|
||||
t.Fatalf("got=%q err=%v audit=%+v", got, err, audit.got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_TenantIsolation(t *testing.T) {
|
||||
s := &Service{Markers: fakeMarkers{"acme": "ai-processed"}, Audit: &fakeAudit{}}
|
||||
got, _ := s.Apply(context.Background(), adoption(SourceAI, "other"))
|
||||
if got != "" {
|
||||
t.Fatalf("marker eines anderen mandanten: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApply_AuditFailureReturnsError(t *testing.T) {
|
||||
s := &Service{Markers: fakeMarkers{"acme": "x"}, Audit: &fakeAudit{err: errors.New("down")}}
|
||||
if got, err := s.Apply(context.Background(), adoption(SourceAI, "acme")); err == nil || got != "" {
|
||||
t.Fatalf("got=%q err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidate(t *testing.T) {
|
||||
for _, bad := range []string{"", " ", "a\nb", strings.Repeat("x", MaxMarkerLen+1)} {
|
||||
if _, err := validate(bad); !errors.Is(err, ErrInvalidMarker) {
|
||||
t.Errorf("%q: err=%v", bad, err)
|
||||
}
|
||||
}
|
||||
if got, err := validate(" ai-processed "); err != nil || got != "ai-processed" {
|
||||
t.Errorf("got=%q err=%v", got, err)
|
||||
}
|
||||
}
|
||||
|
||||
func newStore(t *testing.T) *Store {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_TENANT_DSN")
|
||||
if dsn == "" {
|
||||
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen")
|
||||
}
|
||||
pool := testdb.Pool(t, dsn)
|
||||
dir, err := filepath.Abs("../../migrations")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
migrations, err := migrate.Load(dir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := migrate.Up(context.Background(), pool, migrations); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return &Store{Pool: pool}
|
||||
}
|
||||
|
||||
func TestStore_PerTenantSetGetClear(t *testing.T) {
|
||||
s := newStore(t)
|
||||
ctx := context.Background()
|
||||
if got, err := s.Get(ctx, "acme"); err != nil || got != "" {
|
||||
t.Fatalf("ohne konfiguration: %q %v", got, err)
|
||||
}
|
||||
if err := s.Set(ctx, "acme", " ai-processed "); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := s.Set(ctx, "other", "ki"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := s.Get(ctx, "acme"); got != "ai-processed" {
|
||||
t.Fatalf("acme: %q", got)
|
||||
}
|
||||
if got, _ := s.Get(ctx, "other"); got != "ki" {
|
||||
t.Fatalf("other: %q", got)
|
||||
}
|
||||
if err := s.Clear(ctx, "acme"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, _ := s.Get(ctx, "acme"); got != "" {
|
||||
t.Fatalf("nach clear: %q", got)
|
||||
}
|
||||
if got, _ := s.Get(ctx, "other"); got != "ki" {
|
||||
t.Fatalf("other beruehrt: %q", got)
|
||||
}
|
||||
if err := s.Set(ctx, "acme", "\t"); !errors.Is(err, ErrInvalidMarker) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ import (
|
||||
// sensitivePathPatterns beschreibt die Bereiche aus
|
||||
// TESTSTRATEGIE-AI.md Abschnitt 3.
|
||||
var sensitivePathPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(^|/)ai/internal/svcauth/`),
|
||||
regexp.MustCompile(`(^|/)common/svcauth/`),
|
||||
regexp.MustCompile(`(^|/)ai/internal/tenantfeature/`),
|
||||
regexp.MustCompile(`(^|/)ai/internal/semanticsearch/`),
|
||||
regexp.MustCompile(`(^|/)ai/internal/semanticsearchapi/`),
|
||||
|
||||
@@ -1,24 +1,24 @@
|
||||
// Negativtest des Gates selbst (QA-01 Prüfung 1): ein Diff mit
|
||||
// geänderter ai/internal/tenantfeature/tenantfeature.go ohne begleitende Testdatei
|
||||
// geänderter ai/internal/svcauth/svcauth.go ohne begleitende Testdatei
|
||||
// muss als Verstoß erkannt werden.
|
||||
package pflichttestgate
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCheckDiff_FlagsSensitiveChangeWithoutTest(t *testing.T) {
|
||||
violations := CheckDiff([]string{"ai/internal/tenantfeature/tenantfeature.go"})
|
||||
violations := CheckDiff([]string{"ai/internal/svcauth/svcauth.go"})
|
||||
if len(violations) != 1 {
|
||||
t.Fatalf("erwartet genau 1 verstoß, habe %d: %+v", len(violations), violations)
|
||||
}
|
||||
if violations[0].Package != "ai/internal/tenantfeature" {
|
||||
if violations[0].Package != "ai/internal/svcauth" {
|
||||
t.Fatalf("falsches package gemeldet: %+v", violations[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckDiff_PassesWhenTestFileAccompanies(t *testing.T) {
|
||||
violations := CheckDiff([]string{
|
||||
"ai/internal/tenantfeature/tenantfeature.go",
|
||||
"ai/internal/tenantfeature/tenantfeature_test.go",
|
||||
"ai/internal/svcauth/svcauth.go",
|
||||
"ai/internal/svcauth/svcauth_test.go",
|
||||
})
|
||||
if len(violations) != 0 {
|
||||
t.Fatalf("erwartet keine verstöße, habe: %+v", violations)
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
|
||||
)
|
||||
|
||||
func newStore(t *testing.T) *Store {
|
||||
|
||||
@@ -2,17 +2,12 @@ package provider_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/gemini"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/mock"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/ollama"
|
||||
)
|
||||
@@ -89,22 +84,6 @@ func TestContract_Ollama(t *testing.T) {
|
||||
runContract(t, p, model)
|
||||
}
|
||||
|
||||
func TestContract_Gemini(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if strings.HasSuffix(r.URL.Path, ":embedContent") {
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"embedding": map[string]any{"values": []float64{0.1, 0.2}}})
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"candidates": []map[string]any{{"content": map[string]any{"parts": []map[string]string{{"text": "OK"}}}}}})
|
||||
}))
|
||||
defer srv.Close()
|
||||
p, err := gemini.New(srv.URL, "k")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runContract(t, p, "gemini-test")
|
||||
}
|
||||
|
||||
// TestInterchangeable_SameCallerCode belegt AK2: ein Aufrufer, der gegen das
|
||||
// Interface programmiert, funktioniert unveraendert mit jeder Implementierung.
|
||||
func TestInterchangeable_SameCallerCode(t *testing.T) {
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"os"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultBaseURL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
EnvAPIKey = "NEXARCH_AI_GEMINI_API_KEY"
|
||||
EnvBaseURL = "NEXARCH_AI_GEMINI_BASE_URL"
|
||||
)
|
||||
|
||||
var ErrDisabled = fmt.Errorf("gemini-adapter: %s nicht gesetzt, adapter bleibt deaktiviert", EnvAPIKey)
|
||||
|
||||
type Provider struct {
|
||||
BaseURL string
|
||||
APIKey string
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
var _ provider.Provider = (*Provider)(nil)
|
||||
|
||||
func New(baseURL, apiKey string) (*Provider, error) {
|
||||
if apiKey == "" {
|
||||
return nil, ErrDisabled
|
||||
}
|
||||
if baseURL == "" {
|
||||
baseURL = DefaultBaseURL
|
||||
}
|
||||
return &Provider{
|
||||
BaseURL: strings.TrimRight(baseURL, "/"),
|
||||
APIKey: apiKey,
|
||||
Client: &http.Client{Timeout: 120 * time.Second},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func NewFromEnv() (*Provider, error) {
|
||||
return New(os.Getenv(EnvBaseURL), os.Getenv(EnvAPIKey))
|
||||
}
|
||||
|
||||
func newErr(code provider.Code, msg string, err error) error {
|
||||
return &provider.Error{Provider: "gemini", Code: code, Message: msg, Err: err}
|
||||
}
|
||||
|
||||
func (p *Provider) do(ctx context.Context, model, method string, body, out any) error {
|
||||
buf, err := json.Marshal(body)
|
||||
if err != nil {
|
||||
return newErr(provider.CodeInvalidRequest, "anfrage kodieren", err)
|
||||
}
|
||||
endpoint := fmt.Sprintf("%s/models/%s:%s", p.BaseURL, url.PathEscape(strings.TrimPrefix(model, "models/")), method)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(buf))
|
||||
if err != nil {
|
||||
return newErr(provider.CodeInvalidRequest, "anfrage aufbauen", err)
|
||||
}
|
||||
req.Header.Set("x-goog-api-key", p.APIKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := p.Client.Do(req)
|
||||
if err != nil {
|
||||
if ctx.Err() != nil {
|
||||
return newErr(provider.CodeTimeout, "anfrage abgebrochen", err)
|
||||
}
|
||||
return newErr(provider.CodeUnavailable, "dienst nicht erreichbar", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
data, err := io.ReadAll(resp.Body)
|
||||
if err != nil {
|
||||
return newErr(provider.CodeUpstream, "antwort lesen", err)
|
||||
}
|
||||
switch {
|
||||
case resp.StatusCode == http.StatusNotFound:
|
||||
return newErr(provider.CodeModelNotFound, "modell nicht gefunden", fmt.Errorf("http %d", resp.StatusCode))
|
||||
case resp.StatusCode == http.StatusTooManyRequests:
|
||||
return newErr(provider.CodeRateLimited, "rate limit", fmt.Errorf("http %d", resp.StatusCode))
|
||||
case resp.StatusCode >= 400:
|
||||
return newErr(provider.CodeUpstream, "fehlerhafte antwort", fmt.Errorf("http %d: %s", resp.StatusCode, string(data)))
|
||||
}
|
||||
if err := json.Unmarshal(data, out); err != nil {
|
||||
return newErr(provider.CodeUpstream, "antwort dekodieren", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
type part struct {
|
||||
Text string `json:"text"`
|
||||
}
|
||||
|
||||
type content struct {
|
||||
Role string `json:"role,omitempty"`
|
||||
Parts []part `json:"parts"`
|
||||
}
|
||||
|
||||
type generationConfig struct {
|
||||
MaxOutputTokens int `json:"maxOutputTokens,omitempty"`
|
||||
Temperature float64 `json:"temperature"`
|
||||
}
|
||||
|
||||
type generateRequest struct {
|
||||
SystemInstruction *content `json:"systemInstruction,omitempty"`
|
||||
Contents []content `json:"contents"`
|
||||
GenerationConfig generationConfig `json:"generationConfig"`
|
||||
}
|
||||
|
||||
type generateResponse struct {
|
||||
Candidates []struct {
|
||||
Content content `json:"content"`
|
||||
FinishReason string `json:"finishReason"`
|
||||
} `json:"candidates"`
|
||||
UsageMetadata struct {
|
||||
PromptTokenCount int `json:"promptTokenCount"`
|
||||
CandidatesTokenCount int `json:"candidatesTokenCount"`
|
||||
TotalTokenCount int `json:"totalTokenCount"`
|
||||
} `json:"usageMetadata"`
|
||||
ModelVersion string `json:"modelVersion"`
|
||||
}
|
||||
|
||||
func (p *Provider) Chat(ctx context.Context, req provider.ChatRequest) (provider.ChatResponse, error) {
|
||||
body := generateRequest{GenerationConfig: generationConfig{MaxOutputTokens: req.MaxTokens, Temperature: req.Temperature}}
|
||||
var system []part
|
||||
for _, m := range req.Messages {
|
||||
switch m.Role {
|
||||
case provider.RoleSystem:
|
||||
system = append(system, part{Text: m.Content})
|
||||
case provider.RoleAssistant:
|
||||
body.Contents = append(body.Contents, content{Role: "model", Parts: []part{{Text: m.Content}}})
|
||||
default:
|
||||
body.Contents = append(body.Contents, content{Role: "user", Parts: []part{{Text: m.Content}}})
|
||||
}
|
||||
}
|
||||
if len(system) > 0 {
|
||||
body.SystemInstruction = &content{Parts: system}
|
||||
}
|
||||
|
||||
var out generateResponse
|
||||
if err := p.do(ctx, req.Model, "generateContent", body, &out); err != nil {
|
||||
return provider.ChatResponse{}, err
|
||||
}
|
||||
if len(out.Candidates) == 0 {
|
||||
return provider.ChatResponse{}, newErr(provider.CodeUpstream, "keine antwort in candidates", nil)
|
||||
}
|
||||
c := out.Candidates[0]
|
||||
var text strings.Builder
|
||||
for _, pt := range c.Content.Parts {
|
||||
text.WriteString(pt.Text)
|
||||
}
|
||||
return provider.ChatResponse{
|
||||
Model: out.ModelVersion,
|
||||
Message: provider.Message{Role: provider.RoleAssistant, Content: text.String()},
|
||||
FinishReason: c.FinishReason,
|
||||
Usage: provider.Usage{
|
||||
PromptTokens: out.UsageMetadata.PromptTokenCount,
|
||||
CompletionTokens: out.UsageMetadata.CandidatesTokenCount,
|
||||
TotalTokens: out.UsageMetadata.TotalTokenCount,
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (p *Provider) Complete(ctx context.Context, req provider.CompletionRequest) (provider.CompletionResponse, error) {
|
||||
resp, err := p.Chat(ctx, provider.ChatRequest{
|
||||
Model: req.Model,
|
||||
Messages: []provider.Message{{Role: provider.RoleUser, Content: req.Prompt}},
|
||||
MaxTokens: req.MaxTokens,
|
||||
Temperature: req.Temperature,
|
||||
})
|
||||
if err != nil {
|
||||
return provider.CompletionResponse{}, err
|
||||
}
|
||||
return provider.CompletionResponse{Model: resp.Model, Text: resp.Message.Content, FinishReason: resp.FinishReason, Usage: resp.Usage}, nil
|
||||
}
|
||||
|
||||
type embedRequest struct {
|
||||
Content content `json:"content"`
|
||||
}
|
||||
|
||||
type embedResponse struct {
|
||||
Embedding struct {
|
||||
Values []float64 `json:"values"`
|
||||
} `json:"embedding"`
|
||||
}
|
||||
|
||||
func (p *Provider) Embed(ctx context.Context, req provider.EmbeddingRequest) (provider.EmbeddingResponse, error) {
|
||||
var out embedResponse
|
||||
body := embedRequest{Content: content{Parts: []part{{Text: req.Input}}}}
|
||||
if err := p.do(ctx, req.Model, "embedContent", body, &out); err != nil {
|
||||
return provider.EmbeddingResponse{}, err
|
||||
}
|
||||
if len(out.Embedding.Values) == 0 {
|
||||
return provider.EmbeddingResponse{}, newErr(provider.CodeUpstream, "keine einbettung in der antwort", nil)
|
||||
}
|
||||
return provider.EmbeddingResponse{Model: req.Model, Embedding: out.Embedding.Values}, nil
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
package gemini
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/openai"
|
||||
)
|
||||
|
||||
func fakeServer(t *testing.T, status int) (*httptest.Server, *map[string]any) {
|
||||
t.Helper()
|
||||
var last map[string]any
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if got := r.Header.Get("x-goog-api-key"); got != "key-1" {
|
||||
t.Errorf("x-goog-api-key = %q", got)
|
||||
}
|
||||
if r.URL.Query().Get("key") != "" {
|
||||
t.Error("schluessel darf nicht in der url stehen")
|
||||
}
|
||||
_ = json.NewDecoder(r.Body).Decode(&last)
|
||||
if status != http.StatusOK {
|
||||
w.WriteHeader(status)
|
||||
_, _ = w.Write([]byte(`{"error":{"message":"x"}}`))
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case strings.HasSuffix(r.URL.Path, ":generateContent"):
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{
|
||||
"candidates": []map[string]any{{
|
||||
"content": map[string]any{"role": "model", "parts": []map[string]string{{"text": "ha"}, {"text": "llo"}}},
|
||||
"finishReason": "STOP",
|
||||
}},
|
||||
"usageMetadata": map[string]int{"promptTokenCount": 3, "candidatesTokenCount": 2, "totalTokenCount": 5},
|
||||
"modelVersion": "gemini-test",
|
||||
})
|
||||
case strings.HasSuffix(r.URL.Path, ":embedContent"):
|
||||
_ = json.NewEncoder(w).Encode(map[string]any{"embedding": map[string]any{"values": []float64{0.1, 0.2}}})
|
||||
default:
|
||||
t.Errorf("unerwarteter pfad %s", r.URL.Path)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv, &last
|
||||
}
|
||||
|
||||
func TestChat_MapsRolesUsageAndParts(t *testing.T) {
|
||||
srv, last := fakeServer(t, http.StatusOK)
|
||||
p, _ := New(srv.URL, "key-1")
|
||||
resp, err := p.Chat(context.Background(), provider.ChatRequest{
|
||||
Model: "models/gemini-test",
|
||||
Messages: []provider.Message{
|
||||
{Role: provider.RoleSystem, Content: "sys"},
|
||||
{Role: provider.RoleUser, Content: "u"},
|
||||
{Role: provider.RoleAssistant, Content: "a"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if resp.Message.Content != "hallo" || resp.Usage.TotalTokens != 5 || resp.FinishReason != "STOP" {
|
||||
t.Fatalf("unerwartet: %+v", resp)
|
||||
}
|
||||
if (*last)["systemInstruction"] == nil {
|
||||
t.Fatal("systemInstruction fehlt")
|
||||
}
|
||||
contents := (*last)["contents"].([]any)
|
||||
if len(contents) != 2 || contents[1].(map[string]any)["role"] != "model" {
|
||||
t.Fatalf("rollen falsch: %v", contents)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompleteAndEmbed(t *testing.T) {
|
||||
srv, _ := fakeServer(t, http.StatusOK)
|
||||
p, _ := New(srv.URL, "key-1")
|
||||
c, err := p.Complete(context.Background(), provider.CompletionRequest{Model: "m", Prompt: "x"})
|
||||
if err != nil || c.Text != "hallo" {
|
||||
t.Fatalf("complete: %+v %v", c, err)
|
||||
}
|
||||
e, err := p.Embed(context.Background(), provider.EmbeddingRequest{Model: "m", Input: "x"})
|
||||
if err != nil || len(e.Embedding) != 2 {
|
||||
t.Fatalf("embed: %+v %v", e, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUniformErrors(t *testing.T) {
|
||||
for status, code := range map[int]provider.Code{404: provider.CodeModelNotFound, 429: provider.CodeRateLimited, 500: provider.CodeUpstream} {
|
||||
srv, _ := fakeServer(t, status)
|
||||
p, _ := New(srv.URL, "key-1")
|
||||
_, err := p.Chat(context.Background(), provider.ChatRequest{Model: "m", Messages: []provider.Message{{Role: provider.RoleUser, Content: "x"}}})
|
||||
if !provider.IsCode(err, code) {
|
||||
t.Errorf("status %d: err=%v, want %s", status, err, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnreachableAndCancelled(t *testing.T) {
|
||||
p, _ := New("http://127.0.0.1:1", "key-1")
|
||||
_, err := p.Embed(context.Background(), provider.EmbeddingRequest{Model: "m", Input: "x"})
|
||||
if !provider.IsCode(err, provider.CodeUnavailable) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
cancel()
|
||||
_, err = p.Embed(ctx, provider.EmbeddingRequest{Model: "m", Input: "x"})
|
||||
if !provider.IsCode(err, provider.CodeTimeout) {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDisabledWithoutKey(t *testing.T) {
|
||||
t.Setenv(EnvAPIKey, "")
|
||||
if _, err := NewFromEnv(); err != ErrDisabled {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestKeyNotInErrors(t *testing.T) {
|
||||
srv, _ := fakeServer(t, http.StatusInternalServerError)
|
||||
p, _ := New(srv.URL, "key-1")
|
||||
_, err := p.Chat(context.Background(), provider.ChatRequest{Model: "m", Messages: []provider.Message{{Role: provider.RoleUser, Content: "x"}}})
|
||||
if err == nil || strings.Contains(err.Error(), "key-1") {
|
||||
t.Fatalf("err=%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOptIn_GuardedProviderBlocksWithoutCallingServer(t *testing.T) {
|
||||
called := false
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }))
|
||||
defer srv.Close()
|
||||
inner, _ := New(srv.URL, "key-1")
|
||||
g := &openai.GuardedProvider{Inner: inner, Gate: openai.NewEnvTenantGate("acme")}
|
||||
req := provider.ChatRequest{Model: "m", Messages: []provider.Message{{Role: provider.RoleUser, Content: "x"}}}
|
||||
|
||||
if _, err := g.Chat(openai.WithTenant(context.Background(), "other"), req); err == nil {
|
||||
t.Fatal("freigabe fehlt, fehler erwartet")
|
||||
}
|
||||
if _, err := g.Chat(context.Background(), req); err == nil {
|
||||
t.Fatal("ohne mandant, fehler erwartet")
|
||||
}
|
||||
if called {
|
||||
t.Fatal("server wurde trotz fehlender freigabe aufgerufen")
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,10 +1,10 @@
|
||||
// Package classification ist das Prüfgate für QA-03: misst Klassifizierungs- und
|
||||
// Package qa03gate ist das Prüfgate für QA-03: misst Klassifizierungs- und
|
||||
// Extraktionsgenauigkeit am Referenzkorpus (QA-01, ai/internal/refcorpus)
|
||||
// und belegt die Wirkung von Korrektur-Feedback auf künftige Vorschläge.
|
||||
// Reines Prüfgate — ruft nur bestehende, bereits gemergte Pakete auf
|
||||
// (classify CLS-02, extract EXT-05-Grundlage, correction/historysuggest
|
||||
// CLS-05), ohne deren Code zu ändern.
|
||||
package classification
|
||||
package qa03gate
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,4 +1,4 @@
|
||||
// Package searchquality ist das Prüfgate für QA-04: es misst Suchqualität,
|
||||
// Package qa04gate ist das Prüfgate für QA-04: es misst Suchqualität,
|
||||
// RAG-Antwortgüte und Quellenverweis-Nachvollziehbarkeit auf einem festen,
|
||||
// synthetischen Testkorpus (SEM-01..06 bereits gemergt) und ändert keines der
|
||||
// geprüften Pakete. Der Korpus stammt aus refcorpus (QA-01), die "Einbettung"
|
||||
@@ -7,7 +7,7 @@
|
||||
// einem externen Modell abhängt. Reine Termfrequenz ohne IDF-Gewichtung wurde
|
||||
// probiert und verworfen: seltene, fachlich entscheidende Wörter (z.B.
|
||||
// "Gesamtbetrag") gingen gegen häufige, generische Wörter unter.
|
||||
package searchquality
|
||||
package qa04gate
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -231,3 +231,4 @@ func (p *embedProvider) Chat(ctx context.Context, req provider.ChatRequest) (pro
|
||||
func (p *embedProvider) Complete(ctx context.Context, req provider.CompletionRequest) (provider.CompletionResponse, error) {
|
||||
return provider.CompletionResponse{}, errors.New("qa04gate: Complete wird vom Prüfgate nicht genutzt")
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package searchquality
|
||||
package qa04gate
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -1,4 +1,4 @@
|
||||
package searchquality
|
||||
package qa04gate
|
||||
|
||||
// testQuestion ist eine feste Testfrage des QA-04-Prüfgates gegen den
|
||||
// refcorpus-Testkorpus (QA-01). erwarteteSourceID ist leer, wenn die Frage
|
||||
+4
-4
@@ -1,9 +1,9 @@
|
||||
// Package governance ist das Prüfgate für QA-05 (Datenschutz- &
|
||||
// Package qa05gate ist das Prüfgate für QA-05 (Datenschutz- &
|
||||
// Governance-Prüfung). Es bringt keine neue Fachlogik ein, sondern führt
|
||||
// GOV-02/GOV-03/GOV-05 end-to-end gegen eine echte Postgres-Wegwerf-DB bzw.
|
||||
// einen Mock-Core zusammen, um die drei QA-05-Akzeptanzkriterien in der
|
||||
// Praxis (nicht nur auf Store-Ebene) nachzuweisen.
|
||||
package governance
|
||||
package qa05gate
|
||||
|
||||
import (
|
||||
"context"
|
||||
@@ -23,8 +23,8 @@ import (
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/openai"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/residency"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/tenantfeature"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/auditclient"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
|
||||
)
|
||||
|
||||
// realPool liefert einen frischen, migrierten Pool auf der Wegwerf-DB aus
|
||||
@@ -36,7 +36,7 @@ func realPool(t *testing.T) *pgxpool.Pool {
|
||||
t.Skip("TEST_TENANT_DSN nicht gesetzt, Integrationstest übersprungen")
|
||||
}
|
||||
pool := testdb.Pool(t, dsn)
|
||||
migrationsDir, err := filepath.Abs("../../../migrations")
|
||||
migrationsDir, err := filepath.Abs("../../migrations")
|
||||
if err != nil {
|
||||
t.Fatalf("migrationsverzeichnis: %v", err)
|
||||
}
|
||||
@@ -14,7 +14,7 @@ import (
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
|
||||
)
|
||||
|
||||
func newPool(t *testing.T) *pgxpool.Pool {
|
||||
|
||||
@@ -16,9 +16,9 @@ import (
|
||||
// eine Zeitueberschreitung real simulieren laesst (respektiert ctx wie ein
|
||||
// echter HTTP-Client es tun wuerde).
|
||||
type slowProvider struct {
|
||||
delay time.Duration
|
||||
calls atomic.Int64
|
||||
cancelled atomic.Int64
|
||||
delay time.Duration
|
||||
calls atomic.Int64
|
||||
cancelled atomic.Int64
|
||||
}
|
||||
|
||||
func (s *slowProvider) Chat(ctx context.Context, req provider.ChatRequest) (provider.ChatResponse, error) {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
|
||||
)
|
||||
|
||||
func newPool(t *testing.T) *pgxpool.Pool {
|
||||
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/openai"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/residency"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
|
||||
)
|
||||
|
||||
func setup(t *testing.T) *residency.Store {
|
||||
|
||||
@@ -13,7 +13,7 @@ import (
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/residency"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/residencyapi"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
|
||||
)
|
||||
|
||||
const testToken = "test-service-token"
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/historysuggest"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
|
||||
)
|
||||
|
||||
func newStore(t *testing.T) *Store {
|
||||
|
||||
@@ -11,7 +11,7 @@ import (
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/routingfeedback"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
|
||||
)
|
||||
|
||||
const testToken = "test-service-token"
|
||||
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/semanticsearch"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/vectorindex"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
|
||||
)
|
||||
|
||||
func newRealPool(t *testing.T) *pgxpool.Pool {
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
// Package semanticsearchapi ist die HTTP-Bindung der semantischen Suche
|
||||
// (SEM-03). Aufrufer ist das anfragende Modul (DMS/Mail), nicht der
|
||||
// Endnutzer direkt — Service-Token-Pflicht wie moduleadapter (Workflow
|
||||
// ENG-02), fail closed. Zusaetzlich (Issue #369) Rechtepruefung
|
||||
// ai.search.read gegen Core fuer den Mandanten aus dem Anfrage-Koerper.
|
||||
// ENG-02), fail closed.
|
||||
package semanticsearchapi
|
||||
|
||||
import (
|
||||
@@ -11,30 +10,18 @@ import (
|
||||
"net/http"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/semanticsearch"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/policyclient"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/svcauth"
|
||||
)
|
||||
|
||||
const permissionSearchRead = "ai.search.read"
|
||||
|
||||
type Server struct {
|
||||
svc *semanticsearch.Service
|
||||
serviceToken string
|
||||
policy *policyclient.Client
|
||||
}
|
||||
|
||||
func NewServer(svc *semanticsearch.Service, serviceToken string) *Server {
|
||||
return &Server{svc: svc, serviceToken: serviceToken}
|
||||
}
|
||||
|
||||
// WithPolicy aktiviert die Rechtepruefung gegen Core (POST /authorize).
|
||||
// Rolle ist fest "user", weil der Modul-zu-Modul-Aufruf keine Nutzerrolle
|
||||
// transportiert (Uebergang, gleiches Muster wie connect/internal/statusapi).
|
||||
func (s *Server) WithPolicy(client *policyclient.Client) *Server {
|
||||
s.policy = client
|
||||
return s
|
||||
}
|
||||
|
||||
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /search", s.requireAuth(s.handleSearch))
|
||||
}
|
||||
@@ -50,22 +37,6 @@ func (s *Server) requireAuth(next http.HandlerFunc) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) permitted(w http.ResponseWriter, r *http.Request, tenantSlug, permission string) bool {
|
||||
if s.policy == nil {
|
||||
return true
|
||||
}
|
||||
allowed, err := s.policy.Allowed(r.Context(), tenantSlug, "user", permission)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "berechtigungsdienst nicht erreichbar")
|
||||
return false
|
||||
}
|
||||
if !allowed {
|
||||
writeError(w, http.StatusForbidden, "keine berechtigung")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type refRequest struct {
|
||||
SourceType string `json:"source_type"`
|
||||
SourceID string `json:"source_id"`
|
||||
@@ -88,9 +59,6 @@ func (s *Server) handleSearch(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper")
|
||||
return
|
||||
}
|
||||
if !s.permitted(w, r, req.TenantSlug, permissionSearchRead) {
|
||||
return
|
||||
}
|
||||
|
||||
refs := make([]semanticsearch.Ref, 0, len(req.AllowedRefs))
|
||||
for _, ref := range req.AllowedRefs {
|
||||
|
||||
@@ -1,100 +0,0 @@
|
||||
package semanticsearchapi_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/policyclient"
|
||||
)
|
||||
|
||||
// Steht fuer Core's POST /authorize-Vertrag ({"allowed": bool}); prueft
|
||||
// die echte Kette requireAuth -> permitted -> policyclient -> 403/200.
|
||||
func newFakePolicyAPI(t *testing.T, allow bool, gotBody *map[string]string) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/authorize" || r.Header.Get("X-Service-Token") == "" {
|
||||
t.Fatalf("unerwartete anfrage: %s %s", r.Method, r.URL.Path)
|
||||
}
|
||||
if gotBody != nil {
|
||||
_ = json.NewDecoder(r.Body).Decode(gotBody)
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(map[string]bool{"allowed": allow})
|
||||
}))
|
||||
}
|
||||
|
||||
func searchRequest(t *testing.T) *http.Request {
|
||||
t.Helper()
|
||||
body, _ := json.Marshal(map[string]any{
|
||||
"tenant_slug": "acme",
|
||||
"query": "test",
|
||||
"top_k": 5,
|
||||
"allowed_refs": []map[string]string{{"source_type": "dms_document", "source_id": "doc-1"}},
|
||||
})
|
||||
req := httptest.NewRequest(http.MethodPost, "/search", bytes.NewReader(body))
|
||||
req.Header.Set("X-Service-Token", "geheimes-token")
|
||||
return req
|
||||
}
|
||||
|
||||
func TestHandleSearch_PolicyDenies_Rejected(t *testing.T) {
|
||||
var got map[string]string
|
||||
policyAPI := newFakePolicyAPI(t, false, &got)
|
||||
defer policyAPI.Close()
|
||||
mux := http.NewServeMux()
|
||||
newTestServer().WithPolicy(policyclient.New(policyAPI.URL, "test-token")).RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, searchRequest(t))
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Fatalf("erwartet 403 ohne berechtigung, bekam %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
if got["permission"] != "ai.search.read" || got["tenant"] != "acme" {
|
||||
t.Fatalf("falsche policy-anfrage: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSearch_PolicyAllows_Passes(t *testing.T) {
|
||||
policyAPI := newFakePolicyAPI(t, true, nil)
|
||||
defer policyAPI.Close()
|
||||
mux := http.NewServeMux()
|
||||
newTestServer().WithPolicy(policyclient.New(policyAPI.URL, "test-token")).RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, searchRequest(t))
|
||||
if rec.Code != http.StatusOK {
|
||||
t.Fatalf("erwartet 200 mit berechtigung, bekam %d: %s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSearch_PolicyUnavailable_FailsClosed(t *testing.T) {
|
||||
policyAPI := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer policyAPI.Close()
|
||||
mux := http.NewServeMux()
|
||||
newTestServer().WithPolicy(policyclient.New(policyAPI.URL, "test-token")).RegisterRoutes(mux)
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, searchRequest(t))
|
||||
if rec.Code != http.StatusServiceUnavailable {
|
||||
t.Fatalf("erwartet 503 bei ausfall des berechtigungsdienstes, bekam %d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleSearch_PolicyCheckedOnlyAfterServiceToken(t *testing.T) {
|
||||
policyAPI := newFakePolicyAPI(t, true, nil)
|
||||
defer policyAPI.Close()
|
||||
mux := http.NewServeMux()
|
||||
newTestServer().WithPolicy(policyclient.New(policyAPI.URL, "test-token")).RegisterRoutes(mux)
|
||||
|
||||
req := searchRequest(t)
|
||||
req.Header.Set("X-Service-Token", "falsch")
|
||||
rec := httptest.NewRecorder()
|
||||
mux.ServeHTTP(rec, req)
|
||||
if rec.Code != http.StatusUnauthorized {
|
||||
t.Fatalf("erwartet 401 bei falschem service-token, bekam %d", rec.Code)
|
||||
}
|
||||
}
|
||||
@@ -1,293 +0,0 @@
|
||||
package suggestapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/correspondentsuggest"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/doclang"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/originmark"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/tagsuggest"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/titlesuggest"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/svcauth"
|
||||
)
|
||||
|
||||
const maxBodyBytes = 1 << 20
|
||||
|
||||
type MarkerStore interface {
|
||||
Set(ctx context.Context, tenantSlug, marker string) error
|
||||
Get(ctx context.Context, tenantSlug string) (string, error)
|
||||
Clear(ctx context.Context, tenantSlug string) error
|
||||
}
|
||||
|
||||
type LanguageStore interface {
|
||||
Set(ctx context.Context, tenantSlug, code string) error
|
||||
Get(ctx context.Context, tenantSlug string) (string, error)
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
Provider provider.Provider
|
||||
Model string
|
||||
Resolver *doclang.Resolver
|
||||
Languages LanguageStore
|
||||
Markers MarkerStore
|
||||
Adopt *originmark.Service
|
||||
ServiceToken string
|
||||
}
|
||||
|
||||
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("POST /suggest/title", s.auth(s.handleTitle))
|
||||
mux.HandleFunc("POST /suggest/correspondent", s.auth(s.handleCorrespondent))
|
||||
mux.HandleFunc("POST /suggest/tags", s.auth(s.handleTags))
|
||||
mux.HandleFunc("POST /suggest/adopt", s.auth(s.handleAdopt))
|
||||
mux.HandleFunc("GET /tenants/{tenant}/origin-marker", s.auth(s.handleMarkerGet))
|
||||
mux.HandleFunc("PUT /tenants/{tenant}/origin-marker", s.auth(s.handleMarkerPut))
|
||||
mux.HandleFunc("DELETE /tenants/{tenant}/origin-marker", s.auth(s.handleMarkerDelete))
|
||||
mux.HandleFunc("GET /tenants/{tenant}/language", s.auth(s.handleLanguageGet))
|
||||
mux.HandleFunc("PUT /tenants/{tenant}/language", s.auth(s.handleLanguagePut))
|
||||
}
|
||||
|
||||
func (s *Server) auth(next http.HandlerFunc) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
if !svcauth.Equal(r.Header.Get("X-Service-Token"), s.ServiceToken) {
|
||||
writeError(w, http.StatusUnauthorized, "service-token fehlt oder ist ungueltig")
|
||||
return
|
||||
}
|
||||
next(w, r)
|
||||
}
|
||||
}
|
||||
|
||||
type suggestRequest struct {
|
||||
TenantSlug string `json:"tenant_slug"`
|
||||
Module string `json:"module"`
|
||||
Text string `json:"text"`
|
||||
DocumentLanguage string `json:"document_language"`
|
||||
Existing []string `json:"existing"`
|
||||
}
|
||||
|
||||
type languages struct {
|
||||
Document string `json:"document_language,omitempty"`
|
||||
Output string `json:"output_language,omitempty"`
|
||||
}
|
||||
|
||||
// prepare dekodiert und prueft die Anfrage und loest die Sprachen auf; bei Fehler ist die Antwort geschrieben.
|
||||
func (s *Server) prepare(w http.ResponseWriter, r *http.Request) (suggestRequest, doclang.Resolved, bool) {
|
||||
var req suggestRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodyBytes)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper")
|
||||
return req, doclang.Resolved{}, false
|
||||
}
|
||||
if strings.TrimSpace(req.TenantSlug) == "" || strings.TrimSpace(req.Text) == "" {
|
||||
writeError(w, http.StatusBadRequest, "tenant_slug und text sind erforderlich")
|
||||
return req, doclang.Resolved{}, false
|
||||
}
|
||||
res, err := s.Resolver.Resolve(r.Context(), req.TenantSlug, req.DocumentLanguage, req.Text)
|
||||
if errors.Is(err, doclang.ErrUnknownLanguage) {
|
||||
writeError(w, http.StatusBadRequest, "document_language ist nicht registriert")
|
||||
return req, res, false
|
||||
}
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "sprache ermitteln fehlgeschlagen")
|
||||
return req, res, false
|
||||
}
|
||||
return req, res, true
|
||||
}
|
||||
|
||||
func langsOf(res doclang.Resolved) languages {
|
||||
return languages{Document: res.Document.Code, Output: res.Output.Code}
|
||||
}
|
||||
|
||||
type titleResponse struct {
|
||||
languages
|
||||
Titles []titlesuggest.Suggestion `json:"titles"`
|
||||
}
|
||||
|
||||
func (s *Server) handleTitle(w http.ResponseWriter, r *http.Request) {
|
||||
req, res, ok := s.prepare(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sug := &titlesuggest.Suggester{Provider: s.Provider, Model: s.Model}
|
||||
titles, err := sug.Suggest(r.Context(), req.Module, res.Output.Name, req.Text)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "titelvorschlag fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
if titles == nil {
|
||||
titles = []titlesuggest.Suggestion{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, titleResponse{languages: langsOf(res), Titles: titles})
|
||||
}
|
||||
|
||||
type correspondentResponse struct {
|
||||
languages
|
||||
Suggestion *correspondentsuggest.Suggestion `json:"suggestion"`
|
||||
}
|
||||
|
||||
func (s *Server) handleCorrespondent(w http.ResponseWriter, r *http.Request) {
|
||||
req, res, ok := s.prepare(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sug := &correspondentsuggest.Suggester{Provider: s.Provider, Model: s.Model}
|
||||
got, found, err := sug.Suggest(r.Context(), req.Existing, req.Module, req.Text)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "korrespondentenvorschlag fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
resp := correspondentResponse{languages: langsOf(res)}
|
||||
if found {
|
||||
resp.Suggestion = &got
|
||||
}
|
||||
writeJSON(w, http.StatusOK, resp)
|
||||
}
|
||||
|
||||
type tagsResponse struct {
|
||||
languages
|
||||
Tags []tagsuggest.Suggestion `json:"tags"`
|
||||
}
|
||||
|
||||
func (s *Server) handleTags(w http.ResponseWriter, r *http.Request) {
|
||||
req, res, ok := s.prepare(w, r)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
sug := &tagsuggest.Suggester{Provider: s.Provider, Model: s.Model, Language: res.Output.Name}
|
||||
tags, err := sug.Suggest(r.Context(), req.Existing, req.Module, req.Text)
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "tagvorschlag fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
if tags == nil {
|
||||
tags = []tagsuggest.Suggestion{}
|
||||
}
|
||||
writeJSON(w, http.StatusOK, tagsResponse{languages: langsOf(res), Tags: tags})
|
||||
}
|
||||
|
||||
type adoptRequest struct {
|
||||
Source string `json:"source"`
|
||||
TenantSlug string `json:"tenant_slug"`
|
||||
Actor string `json:"actor"`
|
||||
Function string `json:"function"`
|
||||
Model string `json:"model"`
|
||||
PromptVersion int `json:"prompt_version"`
|
||||
InputRef string `json:"input_ref"`
|
||||
Kind string `json:"kind"`
|
||||
Value string `json:"value"`
|
||||
}
|
||||
|
||||
func (s *Server) handleAdopt(w http.ResponseWriter, r *http.Request) {
|
||||
if s.Adopt == nil {
|
||||
writeError(w, http.StatusServiceUnavailable, "uebernahme-protokollierung nicht konfiguriert")
|
||||
return
|
||||
}
|
||||
var req adoptRequest
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodyBytes)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper")
|
||||
return
|
||||
}
|
||||
src := originmark.Source(req.Source)
|
||||
if src != originmark.SourceAI && src != originmark.SourceManual {
|
||||
writeError(w, http.StatusBadRequest, "source muss ai oder manual sein")
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.TenantSlug) == "" || strings.TrimSpace(req.Kind) == "" || strings.TrimSpace(req.Value) == "" {
|
||||
writeError(w, http.StatusBadRequest, "tenant_slug, kind und value sind erforderlich")
|
||||
return
|
||||
}
|
||||
if src == originmark.SourceAI && (req.Function == "" || req.Model == "" || req.InputRef == "") {
|
||||
writeError(w, http.StatusBadRequest, "function, model und input_ref sind bei source=ai erforderlich")
|
||||
return
|
||||
}
|
||||
marker, err := s.Adopt.Apply(r.Context(), originmark.Adoption{
|
||||
Source: src, TenantSlug: req.TenantSlug, Actor: req.Actor, Function: req.Function, Model: req.Model,
|
||||
PromptVersion: req.PromptVersion, InputRef: req.InputRef, Kind: req.Kind, Value: req.Value,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusBadGateway, "uebernahme protokollieren fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"origin_marker": marker})
|
||||
}
|
||||
|
||||
func (s *Server) handleMarkerGet(w http.ResponseWriter, r *http.Request) {
|
||||
marker, err := s.Markers.Get(r.Context(), r.PathValue("tenant"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "markierung lesen fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"marker": marker})
|
||||
}
|
||||
|
||||
func (s *Server) handleMarkerPut(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Marker string `json:"marker"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodyBytes)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper")
|
||||
return
|
||||
}
|
||||
if err := s.Markers.Set(r.Context(), r.PathValue("tenant"), req.Marker); err != nil {
|
||||
if errors.Is(err, originmark.ErrInvalidMarker) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "markierung speichern fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
s.handleMarkerGet(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) handleMarkerDelete(w http.ResponseWriter, r *http.Request) {
|
||||
if err := s.Markers.Clear(r.Context(), r.PathValue("tenant")); err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "markierung loeschen fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
s.handleMarkerGet(w, r)
|
||||
}
|
||||
|
||||
func (s *Server) handleLanguageGet(w http.ResponseWriter, r *http.Request) {
|
||||
code, err := s.Languages.Get(r.Context(), r.PathValue("tenant"))
|
||||
if err != nil {
|
||||
writeError(w, http.StatusInternalServerError, "sprache lesen fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
writeJSON(w, http.StatusOK, map[string]string{"language": code})
|
||||
}
|
||||
|
||||
func (s *Server) handleLanguagePut(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Language string `json:"language"`
|
||||
}
|
||||
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, maxBodyBytes)).Decode(&req); err != nil {
|
||||
writeError(w, http.StatusBadRequest, "ungueltiger anfrage-koerper")
|
||||
return
|
||||
}
|
||||
if _, ok := s.Resolver.Registry.Lookup(req.Language); !ok {
|
||||
writeError(w, http.StatusBadRequest, "sprache nicht registriert")
|
||||
return
|
||||
}
|
||||
if err := s.Languages.Set(r.Context(), r.PathValue("tenant"), req.Language); err != nil {
|
||||
if errors.Is(err, doclang.ErrInvalidCode) {
|
||||
writeError(w, http.StatusBadRequest, err.Error())
|
||||
return
|
||||
}
|
||||
writeError(w, http.StatusInternalServerError, "sprache speichern fehlgeschlagen")
|
||||
return
|
||||
}
|
||||
s.handleLanguageGet(w, r)
|
||||
}
|
||||
|
||||
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})
|
||||
}
|
||||
@@ -1,241 +0,0 @@
|
||||
package suggestapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/decisionaudit"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/doclang"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/originmark"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider/mock"
|
||||
)
|
||||
|
||||
type memStore struct{ m map[string]string }
|
||||
|
||||
func newMem() *memStore { return &memStore{m: map[string]string{}} }
|
||||
|
||||
func (s *memStore) Set(_ context.Context, tenant, v string) error {
|
||||
if strings.TrimSpace(v) == "" {
|
||||
return originmark.ErrInvalidMarker
|
||||
}
|
||||
s.m[tenant] = v
|
||||
return nil
|
||||
}
|
||||
func (s *memStore) Get(_ context.Context, tenant string) (string, error) { return s.m[tenant], nil }
|
||||
func (s *memStore) Clear(_ context.Context, tenant string) error { delete(s.m, tenant); return nil }
|
||||
|
||||
type recAudit struct{ got []decisionaudit.Decision }
|
||||
|
||||
func (r *recAudit) Record(_ context.Context, d decisionaudit.Decision) error {
|
||||
r.got = append(r.got, d)
|
||||
return nil
|
||||
}
|
||||
|
||||
type fixture struct {
|
||||
srv *httptest.Server
|
||||
markers *memStore
|
||||
langs *memStore
|
||||
audit *recAudit
|
||||
prov *mock.Provider
|
||||
}
|
||||
|
||||
func newFixture(t *testing.T, withAdopt bool) *fixture {
|
||||
t.Helper()
|
||||
f := &fixture{markers: newMem(), langs: newMem(), audit: &recAudit{}, prov: &mock.Provider{}}
|
||||
s := &Server{
|
||||
Provider: f.prov,
|
||||
Model: "m",
|
||||
Resolver: &doclang.Resolver{Registry: doclang.DefaultRegistry(), Tenants: tenantLang{f.langs}},
|
||||
Languages: f.langs,
|
||||
Markers: f.markers,
|
||||
ServiceToken: "tok",
|
||||
}
|
||||
if withAdopt {
|
||||
s.Adopt = &originmark.Service{Markers: f.markers, Audit: f.audit}
|
||||
}
|
||||
mux := http.NewServeMux()
|
||||
s.RegisterRoutes(mux)
|
||||
f.srv = httptest.NewServer(mux)
|
||||
t.Cleanup(f.srv.Close)
|
||||
return f
|
||||
}
|
||||
|
||||
type tenantLang struct{ s *memStore }
|
||||
|
||||
func (t tenantLang) Get(ctx context.Context, tenant string) (string, error) {
|
||||
return t.s.Get(ctx, tenant)
|
||||
}
|
||||
|
||||
func (f *fixture) reply(content string) {
|
||||
f.prov.ChatResponse = provider.ChatResponse{Message: provider.Message{Role: provider.RoleAssistant, Content: content}}
|
||||
}
|
||||
|
||||
func (f *fixture) do(t *testing.T, method, path, body string, token bool) (int, map[string]any) {
|
||||
t.Helper()
|
||||
req, _ := http.NewRequest(method, f.srv.URL+path, strings.NewReader(body))
|
||||
if token {
|
||||
req.Header.Set("X-Service-Token", "tok")
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
var out map[string]any
|
||||
_ = json.NewDecoder(resp.Body).Decode(&out)
|
||||
return resp.StatusCode, out
|
||||
}
|
||||
|
||||
const enDoc = "Dear Sirs, the invoice is not paid and will be sent to you by the reminder that we have prepared for your account."
|
||||
|
||||
func TestAllRoutesRequireServiceToken(t *testing.T) {
|
||||
f := newFixture(t, true)
|
||||
for _, r := range [][2]string{
|
||||
{"POST", "/suggest/title"}, {"POST", "/suggest/correspondent"}, {"POST", "/suggest/tags"}, {"POST", "/suggest/adopt"},
|
||||
{"GET", "/tenants/acme/origin-marker"}, {"PUT", "/tenants/acme/origin-marker"}, {"DELETE", "/tenants/acme/origin-marker"},
|
||||
{"GET", "/tenants/acme/language"}, {"PUT", "/tenants/acme/language"},
|
||||
} {
|
||||
if code, _ := f.do(t, r[0], r[1], `{}`, false); code != http.StatusUnauthorized {
|
||||
t.Errorf("%s %s: status %d, want 401", r[0], r[1], code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTitle_ReturnsTitlesAndResolvedLanguages(t *testing.T) {
|
||||
f := newFixture(t, false)
|
||||
f.langs.m["acme"] = "de"
|
||||
f.reply(`[{"title":"Zahlungserinnerung","confidence":0.9}]`)
|
||||
code, out := f.do(t, "POST", "/suggest/title", `{"tenant_slug":"acme","module":"dms","text":"`+enDoc+`"}`, true)
|
||||
if code != 200 || out["document_language"] != "en" || out["output_language"] != "de" {
|
||||
t.Fatalf("%d %v", code, out)
|
||||
}
|
||||
titles := out["titles"].([]any)
|
||||
if len(titles) != 1 || titles[0].(map[string]any)["title"] != "Zahlungserinnerung" {
|
||||
t.Fatalf("%v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTitle_EmptyListNotNull(t *testing.T) {
|
||||
f := newFixture(t, false)
|
||||
f.reply("kein json")
|
||||
_, out := f.do(t, "POST", "/suggest/title", `{"tenant_slug":"acme","text":"x"}`, true)
|
||||
if titles, ok := out["titles"].([]any); !ok || len(titles) != 0 {
|
||||
t.Fatalf("%v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCorrespondent_FoundAndNone(t *testing.T) {
|
||||
f := newFixture(t, false)
|
||||
f.reply(`{"name":"muster gmbh","confidence":0.8}`)
|
||||
_, out := f.do(t, "POST", "/suggest/correspondent", `{"tenant_slug":"acme","text":"Brief","existing":["Muster GmbH"]}`, true)
|
||||
s := out["suggestion"].(map[string]any)
|
||||
if s["name"] != "Muster GmbH" || s["existing"] != true {
|
||||
t.Fatalf("%v", out)
|
||||
}
|
||||
f.reply(`{"name":"","confidence":0}`)
|
||||
_, out = f.do(t, "POST", "/suggest/correspondent", `{"tenant_slug":"acme","text":"Brief"}`, true)
|
||||
if out["suggestion"] != nil {
|
||||
t.Fatalf("%v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTags_NormalizesToExisting(t *testing.T) {
|
||||
f := newFixture(t, false)
|
||||
f.reply(`[{"tag":"rechnung","confidence":0.9}]`)
|
||||
_, out := f.do(t, "POST", "/suggest/tags", `{"tenant_slug":"acme","text":"x","existing":["Rechnung"]}`, true)
|
||||
tags := out["tags"].([]any)
|
||||
if len(tags) != 1 || tags[0].(map[string]any)["tag"] != "Rechnung" {
|
||||
t.Fatalf("%v", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggest_BadRequests(t *testing.T) {
|
||||
f := newFixture(t, false)
|
||||
for name, body := range map[string]string{
|
||||
"kein json": `{`,
|
||||
"ohne tenant": `{"text":"x"}`,
|
||||
"ohne text": `{"tenant_slug":"acme","text":" "}`,
|
||||
"unbekannt lang": `{"tenant_slug":"acme","text":"x","document_language":"xx"}`,
|
||||
} {
|
||||
if code, _ := f.do(t, "POST", "/suggest/title", body, true); code != http.StatusBadRequest {
|
||||
t.Errorf("%s: status %d, want 400", name, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggest_ProviderErrorIs502(t *testing.T) {
|
||||
f := newFixture(t, false)
|
||||
f.prov.Err = &provider.Error{Provider: "mock", Code: provider.CodeUnavailable, Message: "down"}
|
||||
for _, p := range []string{"title", "correspondent", "tags"} {
|
||||
if code, _ := f.do(t, "POST", "/suggest/"+p, `{"tenant_slug":"acme","text":"x"}`, true); code != http.StatusBadGateway {
|
||||
t.Errorf("%s: status %d, want 502", p, code)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdopt_AIGetsMarkerManualDoesNot(t *testing.T) {
|
||||
f := newFixture(t, true)
|
||||
f.markers.m["acme"] = "ai-processed"
|
||||
ai := `{"source":"ai","tenant_slug":"acme","actor":"u1","function":"tagsuggest","model":"m","prompt_version":1,"input_ref":"doc-1","kind":"tag","value":"rechnung"}`
|
||||
code, out := f.do(t, "POST", "/suggest/adopt", ai, true)
|
||||
if code != 200 || out["origin_marker"] != "ai-processed" || len(f.audit.got) != 1 {
|
||||
t.Fatalf("%d %v audit=%d", code, out, len(f.audit.got))
|
||||
}
|
||||
manual := `{"source":"manual","tenant_slug":"acme","kind":"tag","value":"rechnung"}`
|
||||
code, out = f.do(t, "POST", "/suggest/adopt", manual, true)
|
||||
if code != 200 || out["origin_marker"] != "" || len(f.audit.got) != 1 {
|
||||
t.Fatalf("%d %v audit=%d", code, out, len(f.audit.got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAdopt_ValidationAndUnconfigured(t *testing.T) {
|
||||
f := newFixture(t, true)
|
||||
for name, body := range map[string]string{
|
||||
"source falsch": `{"source":"x","tenant_slug":"a","kind":"tag","value":"v"}`,
|
||||
"ai ohne input": `{"source":"ai","tenant_slug":"a","kind":"tag","value":"v","function":"f","model":"m"}`,
|
||||
"ohne value": `{"source":"manual","tenant_slug":"a","kind":"tag"}`,
|
||||
"kein json": `{`,
|
||||
} {
|
||||
if code, _ := f.do(t, "POST", "/suggest/adopt", body, true); code != http.StatusBadRequest {
|
||||
t.Errorf("%s: status %d, want 400", name, code)
|
||||
}
|
||||
}
|
||||
g := newFixture(t, false)
|
||||
if code, _ := g.do(t, "POST", "/suggest/adopt", `{"source":"manual","tenant_slug":"a","kind":"tag","value":"v"}`, true); code != http.StatusServiceUnavailable {
|
||||
t.Errorf("status %d, want 503", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestOriginMarkerConfig(t *testing.T) {
|
||||
f := newFixture(t, false)
|
||||
if code, out := f.do(t, "PUT", "/tenants/acme/origin-marker", `{"marker":"ki"}`, true); code != 200 || out["marker"] != "ki" {
|
||||
t.Fatalf("%d %v", code, out)
|
||||
}
|
||||
if _, out := f.do(t, "GET", "/tenants/other/origin-marker", "", true); out["marker"] != "" {
|
||||
t.Fatalf("mandantentrennung: %v", out)
|
||||
}
|
||||
if code, _ := f.do(t, "PUT", "/tenants/acme/origin-marker", `{"marker":" "}`, true); code != http.StatusBadRequest {
|
||||
t.Fatalf("status %d, want 400", code)
|
||||
}
|
||||
if code, out := f.do(t, "DELETE", "/tenants/acme/origin-marker", "", true); code != 200 || out["marker"] != "" {
|
||||
t.Fatalf("%d %v", code, out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLanguageConfig(t *testing.T) {
|
||||
f := newFixture(t, false)
|
||||
if code, _ := f.do(t, "PUT", "/tenants/acme/language", `{"language":"fr"}`, true); code != http.StatusBadRequest {
|
||||
t.Fatalf("unregistrierte sprache: status %d, want 400", code)
|
||||
}
|
||||
if code, out := f.do(t, "PUT", "/tenants/acme/language", `{"language":"en"}`, true); code != 200 || out["language"] != "en" {
|
||||
t.Fatalf("%d %v", code, out)
|
||||
}
|
||||
if _, out := f.do(t, "GET", "/tenants/acme/language", "", true); out["language"] != "en" {
|
||||
t.Fatalf("%v", out)
|
||||
}
|
||||
}
|
||||
@@ -125,10 +125,10 @@ func wordSet(s string) map[string]bool {
|
||||
// TypeMetrics fasst die Bewertungen aller Stichproben eines
|
||||
// Zusammenfassungstyps zusammen (AK1).
|
||||
type TypeMetrics struct {
|
||||
Type string
|
||||
Count int
|
||||
Type string
|
||||
Count int
|
||||
AverageGroundedness float64
|
||||
FlaggedCount int
|
||||
FlaggedCount int
|
||||
}
|
||||
|
||||
// AggregateByType berechnet je Zusammenfassungstyp die Kennzahl über alle
|
||||
|
||||
@@ -26,8 +26,6 @@ type Suggestion struct {
|
||||
type Suggester struct {
|
||||
Provider provider.Provider
|
||||
Model string
|
||||
// Language ist der Anzeigename der Ausgabesprache; leer = keine Vorgabe.
|
||||
Language string
|
||||
}
|
||||
|
||||
type modelSuggestion struct {
|
||||
@@ -49,7 +47,7 @@ func (s *Suggester) Suggest(ctx context.Context, existingTags []string, moduleCo
|
||||
return nil, errors.New("tagsuggest: kein provider konfiguriert")
|
||||
}
|
||||
|
||||
prompt := buildPrompt(existingTags, moduleContext, s.Language, documentText)
|
||||
prompt := buildPrompt(existingTags, moduleContext, documentText)
|
||||
resp, err := s.Provider.Chat(ctx, provider.ChatRequest{
|
||||
Model: s.Model,
|
||||
Messages: []provider.Message{
|
||||
@@ -65,7 +63,7 @@ func (s *Suggester) Suggest(ctx context.Context, existingTags []string, moduleCo
|
||||
return parseAndDedup(existingTags, resp.Message.Content), nil
|
||||
}
|
||||
|
||||
func buildPrompt(existingTags []string, moduleContext, language, documentText string) string {
|
||||
func buildPrompt(existingTags []string, moduleContext, documentText string) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("Schlage passende Schlagworte (Tags) für das folgende Dokument vor.\n")
|
||||
if moduleContext != "" {
|
||||
@@ -76,9 +74,6 @@ func buildPrompt(existingTags []string, moduleContext, language, documentText st
|
||||
b.WriteString(strings.Join(existingTags, ", "))
|
||||
b.WriteString("\n")
|
||||
}
|
||||
if language != "" {
|
||||
b.WriteString(fmt.Sprintf("Neue Tags schreibst du in der Sprache %q. Bestehende Tags dürfen auch in einer anderen Sprache vorliegen als das Dokument; ein bedeutungsgleicher bestehender Tag hat immer Vorrang vor einem neuen Tag.\n", language))
|
||||
}
|
||||
b.WriteString("Antworte NUR mit einem JSON-Array in diesem Format: ")
|
||||
b.WriteString(`[{"tag": string, "confidence": number zwischen 0 und 1, "reasoning": string}, ...].`)
|
||||
b.WriteString(" Schlage höchstens 8 Tags vor, keine Dopplungen.\n\n")
|
||||
|
||||
@@ -2,7 +2,6 @@ package tagsuggest
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
|
||||
@@ -28,6 +27,7 @@ func (s *stubProvider) Embed(ctx context.Context, req provider.EmbeddingRequest)
|
||||
return provider.EmbeddingResponse{}, nil
|
||||
}
|
||||
|
||||
|
||||
// AK1: Konfidenz wird je Vorschlag mitgeliefert.
|
||||
func TestSuggest_ReturnsConfidencePerSuggestion(t *testing.T) {
|
||||
p := &stubProvider{response: `[{"tag":"rechnung","confidence":0.9,"reasoning":"enthaelt rechnungsnummer"},{"tag":"steuer","confidence":0.4,"reasoning":"erwaehnt umsatzsteuer"}]`}
|
||||
@@ -126,40 +126,3 @@ func TestSuggest_NoProvider_ReturnsError(t *testing.T) {
|
||||
t.Fatal("erwartet Fehler ohne konfigurierten Provider")
|
||||
}
|
||||
}
|
||||
|
||||
type capturingProvider struct {
|
||||
stubProvider
|
||||
prompt string
|
||||
}
|
||||
|
||||
func (c *capturingProvider) Chat(ctx context.Context, req provider.ChatRequest) (provider.ChatResponse, error) {
|
||||
c.prompt = req.Messages[len(req.Messages)-1].Content
|
||||
return c.stubProvider.Chat(ctx, req)
|
||||
}
|
||||
|
||||
// CLS-08: Ausgabesprache wird in den Prompt aufgenommen, bestehende Tags in anderer Sprache behalten Vorrang.
|
||||
func TestSuggest_LanguageInPromptAndTaxonomyKept(t *testing.T) {
|
||||
p := &capturingProvider{stubProvider: stubProvider{response: `[{"tag":"rechnung","confidence":0.9}]`}}
|
||||
s := &Suggester{Provider: p, Model: "test", Language: "Deutsch"}
|
||||
|
||||
got, err := s.Suggest(context.Background(), []string{"Rechnung"}, "dms", "Invoice for consulting services")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(got) != 1 || got[0].Tag != "Rechnung" {
|
||||
t.Fatalf("taxonomie nicht respektiert: %+v", got)
|
||||
}
|
||||
if !strings.Contains(p.prompt, `"Deutsch"`) || !strings.Contains(p.prompt, "Vorrang") {
|
||||
t.Fatalf("sprachvorgabe fehlt im prompt: %s", p.prompt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSuggest_NoLanguageNoInstruction(t *testing.T) {
|
||||
p := &capturingProvider{stubProvider: stubProvider{response: `[]`}}
|
||||
if _, err := (&Suggester{Provider: p}).Suggest(context.Background(), nil, "", "x"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(p.prompt, "Sprache") {
|
||||
t.Fatalf("unerwartete sprachvorgabe: %s", p.prompt)
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import (
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
|
||||
)
|
||||
|
||||
func newStore(t *testing.T) *Store {
|
||||
|
||||
@@ -7,7 +7,6 @@ package testdb
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
@@ -17,21 +16,6 @@ import (
|
||||
|
||||
var seq atomic.Int64
|
||||
|
||||
// AdminDSN liefert TEST_ADMIN_DSN. Fehlt er, wird uebersprungen; mit
|
||||
// REQUIRE_DB=1 (CI) zaehlt das Fehlen als Fehler.
|
||||
func AdminDSN(t testing.TB) string {
|
||||
t.Helper()
|
||||
dsn := os.Getenv("TEST_ADMIN_DSN")
|
||||
if dsn != "" {
|
||||
return dsn
|
||||
}
|
||||
if os.Getenv("REQUIRE_DB") == "1" {
|
||||
t.Fatal("testdb: TEST_ADMIN_DSN nicht gesetzt, aber REQUIRE_DB=1")
|
||||
}
|
||||
t.Skip("testdb: TEST_ADMIN_DSN nicht gesetzt, Integrationstest uebersprungen")
|
||||
return ""
|
||||
}
|
||||
|
||||
// Pool legt ein Schema an und liefert einen darauf ausgerichteten Pool;
|
||||
// beides wird per t.Cleanup wieder entfernt.
|
||||
func Pool(t testing.TB, dsn string) *pgxpool.Pool {
|
||||
@@ -30,9 +30,9 @@ type Thread struct {
|
||||
|
||||
// Result ist die Zusammenfassung eines Threads.
|
||||
type Result struct {
|
||||
Summary string
|
||||
MessageCount int
|
||||
LastMessageID string
|
||||
Summary string
|
||||
MessageCount int
|
||||
LastMessageID string
|
||||
}
|
||||
|
||||
// Options steuert die Zusammenfassung.
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
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
|
||||
}
|
||||
@@ -1,59 +0,0 @@
|
||||
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))
|
||||
}
|
||||
}
|
||||
@@ -1,61 +0,0 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,8 @@ import (
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/migrate"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/testdb"
|
||||
"gitea.perlbach24.de/scripte/nexarch/ai/internal/vectorindex"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/testdb"
|
||||
)
|
||||
|
||||
func newPool(t *testing.T) *pgxpool.Pool {
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
DROP TABLE ai_origin_markers;
|
||||
@@ -1,5 +0,0 @@
|
||||
CREATE TABLE ai_origin_markers (
|
||||
tenant_slug TEXT PRIMARY KEY,
|
||||
marker TEXT NOT NULL CHECK (marker <> ''),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -1 +0,0 @@
|
||||
DROP TABLE ai_tenant_languages;
|
||||
@@ -1,5 +0,0 @@
|
||||
CREATE TABLE ai_tenant_languages (
|
||||
tenant_slug TEXT PRIMARY KEY,
|
||||
language TEXT NOT NULL CHECK (language ~ '^[a-z]{2,3}$'),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
@@ -15,8 +15,8 @@ import (
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/legalholdapi"
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/moduleadapter"
|
||||
"gitea.perlbach24.de/scripte/nexarch/archive/internal/tenantregistryclient"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/httpharden"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/tenantregistryclient"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
@@ -30,7 +30,7 @@ Verbindlich für jeden Pull Request, der Dateien in einem der folgenden Bereiche
|
||||
- **WORM-Durchsetzung** (`archive/internal/wormstore/`) — die Storage-Object-Lock-Garantie selbst.
|
||||
- **Zugriffsprüfung** (`archive/internal/rbacclient/`, `archive/internal/retentionapi/`) — RBAC-Anbindung
|
||||
und die APIs, die sie durchsetzen.
|
||||
- **Tenant-/Schlüssel-Bezug** (`archive/internal/kekclient/`,
|
||||
- **Tenant-/Schlüssel-Bezug** (`archive/internal/tenantregistryclient/`, `archive/internal/kekclient/`,
|
||||
`archive/internal/backupcrypto/`) — unabhängige Tenant-Validierung und Verschlüsselungspfade.
|
||||
|
||||
Regel: **jede geänderte `.go`-Datei in einem dieser Bereiche muss von einer geänderten oder neuen
|
||||
|
||||
+5
-4
@@ -4,10 +4,7 @@ go 1.25.0
|
||||
|
||||
toolchain go1.25.13
|
||||
|
||||
require (
|
||||
gitea.perlbach24.de/scripte/nexarch/common v0.1.0
|
||||
github.com/jackc/pgx/v5 v5.9.2
|
||||
)
|
||||
require github.com/jackc/pgx/v5 v5.9.2
|
||||
|
||||
require (
|
||||
github.com/jackc/pgpassfile v1.0.0 // indirect
|
||||
@@ -16,3 +13,7 @@ require (
|
||||
golang.org/x/sync v0.22.0 // indirect
|
||||
golang.org/x/text v0.41.0 // indirect
|
||||
)
|
||||
|
||||
require gitea.perlbach24.de/scripte/nexarch/common v0.0.0
|
||||
|
||||
replace gitea.perlbach24.de/scripte/nexarch/common => ../common
|
||||
@@ -1,5 +1,3 @@
|
||||
gitea.perlbach24.de/scripte/nexarch/common v0.1.0 h1:biNzFr2LOMZC7b1leY3smQtrMmSMp6IW80OvR4LSjLY=
|
||||
gitea.perlbach24.de/scripte/nexarch/common v0.1.0/go.mod h1:CiEImG++QMt1xZqSMeDJhPiB7sfWTLZOwjS6OY/Iguc=
|
||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
|
||||
@@ -1,51 +0,0 @@
|
||||
package archtest
|
||||
|
||||
import (
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// Architekturtest: im Produktivcode dieses Moduls gibt es keine Aufrufe ueber
|
||||
// den Standard-Client ohne Zeitlimit.
|
||||
func TestNoUnboundedDefaultClientInProductionCode(t *testing.T) {
|
||||
root, err := filepath.Abs(filepath.Join("..", ".."))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
forbidden := regexp.MustCompile(`http\.DefaultClient|http\.(Get|Post|Head|PostForm)\(`)
|
||||
err = filepath.WalkDir(root, func(path string, d fs.DirEntry, err error) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if d.IsDir() {
|
||||
if n := d.Name(); n == "node_modules" || n == "testdata" || n == ".git" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if !strings.HasSuffix(path, ".go") || strings.HasSuffix(path, "_test.go") {
|
||||
return nil
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for i, line := range strings.Split(string(data), "\n") {
|
||||
if t := strings.TrimSpace(line); strings.HasPrefix(t, "//") {
|
||||
continue
|
||||
}
|
||||
if forbidden.MatchString(line) {
|
||||
rel, _ := filepath.Rel(root, path)
|
||||
t.Errorf("%s:%d nutzt den Standard-Client ohne Zeitlimit: %s", rel, i+1, strings.TrimSpace(line))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ func moduleRoot(t *testing.T) string {
|
||||
// Antwort bzw. der rohe ListenAndServe-Aufruf genau die erlaubte
|
||||
// Implementierung der Regel selbst ist (API-20/API-21), kein Verstoss.
|
||||
// Das Archive-Modul hat keine weiteren, physisch verschachtelten Go-Module.
|
||||
var exempt = []string{}
|
||||
var exempt = []string{"internal/httpharden"}
|
||||
|
||||
// TestNoErrorTextLeaksIntoHTTPResponses ist Akzeptanzkriterium 1: kein
|
||||
// err.Error() direkt in einer HTTP-Antwort, ausser ueber apierror.
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
var sensitivePathPatterns = []*regexp.Regexp{
|
||||
regexp.MustCompile(`(^|/)archive/internal/wormstore/`),
|
||||
regexp.MustCompile(`(^|/)archive/internal/rbacclient/`),
|
||||
regexp.MustCompile(`(^|/)archive/internal/tenantregistryclient/`),
|
||||
regexp.MustCompile(`(^|/)archive/internal/retentionapi/`),
|
||||
regexp.MustCompile(`(^|/)archive/internal/kekclient/`),
|
||||
regexp.MustCompile(`(^|/)archive/internal/backupcrypto/`),
|
||||
|
||||
@@ -60,6 +60,7 @@ func TestCheckDiff_CoversAllSensitiveAreas(t *testing.T) {
|
||||
cases := []string{
|
||||
"archive/internal/wormstore/wormstore.go",
|
||||
"archive/internal/rbacclient/rbacclient.go",
|
||||
"archive/internal/tenantregistryclient/tenantregistryclient.go",
|
||||
"archive/internal/retentionapi/handler.go",
|
||||
"archive/internal/kekclient/kekclient.go",
|
||||
"archive/internal/backupcrypto/reposecret.go",
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// Package retentionverify implementiert VAL-03: Stichprobenvergleich von
|
||||
// retain_until (Quelle aus TAR-01 vs. nach der Migration im generischen
|
||||
// Retention-Objektmodell) je Tenant, nach dem Berichtsmuster aus VAL-01
|
||||
// (internal/migration/verify): Abweichung ist entweder erklaert oder
|
||||
// (internal/migrationverify): Abweichung ist entweder erklaert oder
|
||||
// blockierender Befund, nie reine Notiz.
|
||||
package retentionverify
|
||||
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
// Package tenantregistryclient ist ein schlanker HTTP-Client fuer Core
|
||||
// TEN-01 (Tenant-Registry). Archive ist ein physisch getrenntes Go-Modul
|
||||
// und kann Core TEN-01 daher nicht direkt importieren - nur ueber dessen
|
||||
// HTTP-Schnittstelle abfragen. Gleiches Muster wie archive/internal/
|
||||
// rbacclient (RET-08) und archive/internal/notifyclient (RET-07).
|
||||
package tenantregistryclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/httpclient"
|
||||
"net/http"
|
||||
"net/url"
|
||||
)
|
||||
|
||||
// Client fragt Core TEN-01 nach der Gueltigkeit eines Tenant-Kontexts.
|
||||
type Client struct {
|
||||
BaseURL string
|
||||
ServiceToken string
|
||||
HTTPClient *http.Client
|
||||
}
|
||||
|
||||
func New(baseURL, serviceToken string) *Client {
|
||||
return &Client{BaseURL: baseURL, ServiceToken: serviceToken}
|
||||
}
|
||||
|
||||
// VerifyTenant prueft, ob tenantSlug in der Core-Tenant-Registry als
|
||||
// existierender Tenant gefuehrt wird - unabhaengig davon, was das
|
||||
// aufrufende Modul (DMS/Mail) selbst behauptet. Ein Transport-/
|
||||
// Serverfehler wird als Fehler zurueckgegeben, NICHT stillschweigend als
|
||||
// gueltig behandelt - der Aufrufer muss fail-closed (ablehnen) reagieren.
|
||||
func (c *Client) VerifyTenant(ctx context.Context, tenantSlug string) (bool, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.BaseURL+"/tenants/"+url.PathEscape(tenantSlug), nil)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("tenantregistryclient: request bauen: %w", err)
|
||||
}
|
||||
req.Header.Set("X-Service-Token", c.ServiceToken)
|
||||
|
||||
resp, err := c.httpClient().Do(req)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("tenantregistryclient: aufruf fehlgeschlagen: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
switch resp.StatusCode {
|
||||
case http.StatusOK:
|
||||
return true, nil
|
||||
case http.StatusNotFound:
|
||||
return false, nil
|
||||
default:
|
||||
return false, fmt.Errorf("tenantregistryclient: unerwarteter status %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) httpClient() *http.Client {
|
||||
if c.HTTPClient != nil {
|
||||
return c.HTTPClient
|
||||
}
|
||||
return httpclient.Default()
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
package tenantregistryclient
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestVerifyTenant_ExistingTenant(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/tenants/mandant-a" {
|
||||
t.Errorf("pfad = %q, want /tenants/mandant-a", r.URL.Path)
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := New(server.URL, "test-token")
|
||||
ok, err := c.VerifyTenant(context.Background(), "mandant-a")
|
||||
if err != nil {
|
||||
t.Fatalf("verifytenant: %v", err)
|
||||
}
|
||||
if !ok {
|
||||
t.Fatal("erwartet gueltigen tenant, habe false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyTenant_UnknownTenant(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := New(server.URL, "test-token")
|
||||
ok, err := c.VerifyTenant(context.Background(), "unbekannt")
|
||||
if err != nil {
|
||||
t.Fatalf("verifytenant: %v", err)
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("erwartet ungueltigen tenant, habe true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyTenant_ServerErrorIsFailClosed(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
c := New(server.URL, "test-token")
|
||||
ok, err := c.VerifyTenant(context.Background(), "mandant-a")
|
||||
if err == nil {
|
||||
t.Fatal("erwartet fehler bei 500-antwort der tenant-registry")
|
||||
}
|
||||
if ok {
|
||||
t.Fatal("erwartet false bei fehler (fail-closed)")
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
const exitPermanentDBError = 2
|
||||
|
||||
type pinger interface {
|
||||
Ping(ctx context.Context) error
|
||||
}
|
||||
|
||||
func isPermanentDBError(err error) bool {
|
||||
var pgErr *pgconn.PgError
|
||||
if !errors.As(err, &pgErr) {
|
||||
return false
|
||||
}
|
||||
switch pgErr.Code {
|
||||
case "28000", "28P01", "3D000":
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func waitForDB(ctx context.Context, p pinger, timeout time.Duration, sleep func(time.Duration)) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
delay := time.Second
|
||||
for {
|
||||
err := p.Ping(ctx)
|
||||
if err == nil {
|
||||
return nil
|
||||
}
|
||||
if isPermanentDBError(err) {
|
||||
return fmt.Errorf("datenbank lehnt Anmeldung oder Datenbank ab (dauerhaft): %w", err)
|
||||
}
|
||||
if time.Now().Add(delay).After(deadline) {
|
||||
return fmt.Errorf("datenbank nach %s nicht erreichbar: %w", timeout, err)
|
||||
}
|
||||
sleep(delay)
|
||||
if delay < 30*time.Second {
|
||||
delay *= 2
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgconn"
|
||||
)
|
||||
|
||||
type fakePinger struct {
|
||||
errs []error
|
||||
calls int
|
||||
}
|
||||
|
||||
func (f *fakePinger) Ping(context.Context) error {
|
||||
i := f.calls
|
||||
f.calls++
|
||||
if i < len(f.errs) {
|
||||
return f.errs[i]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestWaitForDB_PermanentAuthErrorStopsImmediately(t *testing.T) {
|
||||
p := &fakePinger{errs: []error{&pgconn.PgError{Code: "28P01"}, nil}}
|
||||
sleeps := 0
|
||||
err := waitForDB(context.Background(), p, time.Minute, func(time.Duration) { sleeps++ })
|
||||
if err == nil || !isPermanentDBError(err) {
|
||||
t.Fatalf("erwartet dauerhaften fehler, war %v", err)
|
||||
}
|
||||
if p.calls != 1 || sleeps != 0 {
|
||||
t.Fatalf("kein retry erwartet, calls=%d sleeps=%d", p.calls, sleeps)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForDB_TransientErrorRetriesWithBackoff(t *testing.T) {
|
||||
p := &fakePinger{errs: []error{errors.New("connection refused"), errors.New("connection refused")}}
|
||||
var delays []time.Duration
|
||||
if err := waitForDB(context.Background(), p, time.Minute, func(d time.Duration) { delays = append(delays, d) }); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if p.calls != 3 || len(delays) != 2 || delays[1] <= delays[0] {
|
||||
t.Fatalf("calls=%d delays=%v", p.calls, delays)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWaitForDB_GivesUpAfterTimeout(t *testing.T) {
|
||||
p := &fakePinger{errs: []error{errors.New("x"), errors.New("x"), errors.New("x"), errors.New("x")}}
|
||||
err := waitForDB(context.Background(), p, 0, func(time.Duration) {})
|
||||
if err == nil || isPermanentDBError(err) {
|
||||
t.Fatalf("erwartet transienten timeout-fehler, war %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPermanentDBError(t *testing.T) {
|
||||
for code, want := range map[string]bool{"28P01": true, "28000": true, "3D000": true, "57P03": false} {
|
||||
if got := isPermanentDBError(&pgconn.PgError{Code: code}); got != want {
|
||||
t.Errorf("%s: got %v", code, got)
|
||||
}
|
||||
}
|
||||
if isPermanentDBError(errors.New("connection refused")) {
|
||||
t.Error("verbindungsfehler ist transient")
|
||||
}
|
||||
}
|
||||
@@ -82,17 +82,6 @@ func main() {
|
||||
}
|
||||
defer pool.Close()
|
||||
|
||||
dbWait := time.Duration(envInt("NEXARCH_ACCOUNT_DB_STARTUP_TIMEOUT_SECONDS", 120, 0, 3600)) * time.Second
|
||||
if err := waitForDB(ctx, pool, dbWait, time.Sleep); err != nil {
|
||||
log.Printf("account-api: %v — DSN in NEXARCH_ACCOUNT_TENANT_DSN (Rolle, Passwort, Datenbank) pruefen", err)
|
||||
if isPermanentDBError(err) {
|
||||
pool.Close()
|
||||
os.Exit(exitPermanentDBError)
|
||||
}
|
||||
pool.Close()
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
totpCipher, err := newTOTPCipher()
|
||||
if err != nil {
|
||||
log.Fatalf("totp-verschluesselung: %v", err)
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// cor01-tenant-migrate fuehrt COR-01 aus: Uebernahme der freigegebenen
|
||||
// tenant_migration_map (MAP-02) in Core TEN-01. Einmalig wiederholbar
|
||||
// gegen dieselbe Quelle (siehe internal/migration/tenant).
|
||||
// gegen dieselbe Quelle (siehe internal/migrationtenant).
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"os"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/migration/tenant"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/migrationtenant"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||
)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// cor02-user-migrate fuehrt COR-02 aus: Uebernahme der freigegebenen
|
||||
// user_migration_map in Core IAM-01, bcrypt-Hashes 1:1 ohne Rehash.
|
||||
// Einmalig wiederholbar gegen dieselbe Quelle (siehe internal/migration/user).
|
||||
// Einmalig wiederholbar gegen dieselbe Quelle (siehe internal/migrationuser).
|
||||
package main
|
||||
|
||||
import (
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
"os"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/db"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/migration/user"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/migrationuser"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||
)
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
// Migrationsschritt.
|
||||
//
|
||||
// Benutzer-Quellzahl kommt aus der freigegebenen user_migration_map
|
||||
// (Registry-DB, siehe internal/migration/user). Dokument-/Nachrichten-
|
||||
// (Registry-DB, siehe internal/migrationuser). Dokument-/Nachrichten-
|
||||
// Quellzahlen kommen aus archivdms/archivmail und liegen in diesem
|
||||
// Repository nicht als Go-Code vor (nur als Source-Interface in
|
||||
// dms/internal/migrationdocument bzw. mail/internal/messagemigration,
|
||||
@@ -22,7 +22,7 @@ import (
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/migration/verify"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/migrationverify"
|
||||
"gitea.perlbach24.de/scripte/nexarch/internal/tenant"
|
||||
)
|
||||
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
2
|
||||
@@ -1,26 +0,0 @@
|
||||
# common
|
||||
|
||||
Geteiltes Go-Modul für Pakete, die in mehreren NEXARCH-Modulen identisch gebraucht werden (`internal/` ist modulübergreifend nicht sichtbar).
|
||||
|
||||
Pakete: `pflichttestgate` (parametrisierte Testpflicht-Logik), `apierror`, `auditclient`, `bearerauth`, `httpclient`, `httpharden`, `loadtest`, `migrate`, `moduleauth`, `policyclient`, `svcauth`, `tenantregistryclient`, `testdb`.
|
||||
|
||||
## Einbinden
|
||||
|
||||
```
|
||||
go env -w GOPRIVATE=gitea.perlbach24.de
|
||||
go get gitea.perlbach24.de/scripte/nexarch/common@latest
|
||||
```
|
||||
|
||||
Build-Orte brauchen Lesezugriff auf Gitea: Read-only-Token in `~/.netrc` (`machine gitea.perlbach24.de login <user> password <token>`).
|
||||
|
||||
## Versionen
|
||||
|
||||
Tags `common/v0.X.Y` setzt `.gitea/workflows/common-tag.yml` nach jedem Merge in `main`, der `common/` ändert. Module aktualisieren einzeln (`go get ...common@v0.X.Y`). Lokal mit mehreren Modulen gleichzeitig arbeiten: nicht eingechecktes `go.work`.
|
||||
|
||||
## Regeln
|
||||
|
||||
- Nur Code, der in mindestens zwei Modulen byte-gleich (ohne Kommentare) gebraucht wird; fachlich verschiedene Pakete bleiben modul-lokal.
|
||||
- Änderungen abwärtskompatibel halten, solange Module nicht gleichzeitig umgestellt werden.
|
||||
|
||||
- Jeder PR an `common/` nennt die betroffenen Module (Auswirkungsliste). Breaking Changes nur mit neuer Minor-Version (`v0.2.0`) und Umstellungs-PRs je Modul; die aktuelle Minor steht in `common/MINOR`, der Tag-Workflow setzt bei einer neuen Minor `v0.<minor>.0`.
|
||||
- Sicherheitskritische Pakete (`svcauth`, `tenantregistryclient`, `auditclient`, `policyclient`, `apierror`, `httpharden`) unterliegen dem Pflichttest-Gate (`cmd/pflichttestgate`, Workflow `common-pflichttest-gate.yml`): Änderung ohne begleitende `_test.go` im selben Package schlägt fehl.
|
||||
@@ -1,71 +0,0 @@
|
||||
// Package bearerauth setzt die Identitaet der Modul-APIs aus einem
|
||||
// verifizierten Core-Modul-Token. Die Handler lesen weiter X-User-Id und
|
||||
// X-Tenant-Slug; die Middleware ueberschreibt beide aus den Claims, sodass ein
|
||||
// Client sie nicht frei setzen kann. X-User-Role kommt nur aus dem Token.
|
||||
package bearerauth
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/moduleauth"
|
||||
)
|
||||
|
||||
const (
|
||||
headerUser = "X-User-Id"
|
||||
headerTenant = "X-Tenant-Slug"
|
||||
headerRole = "X-User-Role"
|
||||
)
|
||||
|
||||
// Middleware verlangt fuer Anfragen mit Authorization: Bearer ein gueltiges
|
||||
// Token. Ohne Bearer werden X-User-Id und X-Tenant-Slug entfernt, ausser
|
||||
// allowHeaderAuth ist gesetzt (Uebergang, bis alle Clients Bearer senden).
|
||||
func Middleware(v *moduleauth.Verifier, allowHeaderAuth bool, next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
r.Header.Del(headerRole)
|
||||
token, ok := bearer(r)
|
||||
if !ok {
|
||||
if !allowHeaderAuth {
|
||||
r.Header.Del(headerUser)
|
||||
r.Header.Del(headerTenant)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
return
|
||||
}
|
||||
if v == nil {
|
||||
writeError(w, http.StatusUnauthorized, "nicht angemeldet")
|
||||
return
|
||||
}
|
||||
claims, err := v.Verify(r.Context(), token)
|
||||
if err != nil {
|
||||
if errors.Is(err, moduleauth.ErrInvalidToken) {
|
||||
writeError(w, http.StatusUnauthorized, "nicht angemeldet")
|
||||
} else {
|
||||
writeError(w, http.StatusServiceUnavailable, "identitaetsdienst nicht erreichbar")
|
||||
}
|
||||
return
|
||||
}
|
||||
r.Header.Set(headerUser, claims.Subject)
|
||||
r.Header.Set(headerTenant, claims.Tenant)
|
||||
if claims.Role != "" {
|
||||
r.Header.Set(headerRole, claims.Role)
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func bearer(r *http.Request) (string, bool) {
|
||||
h := r.Header.Get("Authorization")
|
||||
if len(h) > 7 && strings.EqualFold(h[:7], "bearer ") {
|
||||
return strings.TrimSpace(h[7:]), true
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
func writeError(w http.ResponseWriter, status int, message string) {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(status)
|
||||
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
package bearerauth
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"gitea.perlbach24.de/scripte/nexarch/common/moduleauth"
|
||||
)
|
||||
|
||||
func echo() http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(r.Header.Get("X-User-Id") + "|" + r.Header.Get("X-Tenant-Slug") + "|" + r.Header.Get("X-User-Role")))
|
||||
})
|
||||
}
|
||||
|
||||
func do(h http.Handler, hdr map[string]string) *httptest.ResponseRecorder {
|
||||
req := httptest.NewRequest(http.MethodGet, "/x", nil)
|
||||
for k, v := range hdr {
|
||||
req.Header.Set(k, v)
|
||||
}
|
||||
rec := httptest.NewRecorder()
|
||||
h.ServeHTTP(rec, req)
|
||||
return rec
|
||||
}
|
||||
|
||||
func TestBearerOverridesClientSuppliedHeaders(t *testing.T) {
|
||||
v, iss := moduleauth.NewTestVerifierAndIssuer()
|
||||
h := Middleware(v, false, echo())
|
||||
rec := do(h, map[string]string{
|
||||
"Authorization": "Bearer " + iss.Token("u1", "acme", "user", "", ""),
|
||||
"X-User-Id": "fremd", "X-Tenant-Slug": "fremd",
|
||||
})
|
||||
if rec.Code != 200 || rec.Body.String() != "u1|acme|user" {
|
||||
t.Fatalf("code=%d body=%q", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidBearerIs401(t *testing.T) {
|
||||
v, _ := moduleauth.NewTestVerifierAndIssuer()
|
||||
_, other := moduleauth.NewTestVerifierAndIssuer()
|
||||
h := Middleware(v, true, echo())
|
||||
if rec := do(h, map[string]string{"Authorization": "Bearer " + other.Token("u1", "acme", "user", "", ""), "X-User-Id": "evil"}); rec.Code != 401 {
|
||||
t.Fatalf("code=%d", rec.Code)
|
||||
}
|
||||
if rec := do(Middleware(nil, true, echo()), map[string]string{"Authorization": "Bearer x.y.z"}); rec.Code != 401 {
|
||||
t.Fatalf("ohne Verifier code=%d", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithoutBearerHeadersStrippedUnlessAllowed(t *testing.T) {
|
||||
v, _ := moduleauth.NewTestVerifierAndIssuer()
|
||||
hdr := map[string]string{"X-User-Id": "u9", "X-Tenant-Slug": "t9"}
|
||||
if rec := do(Middleware(v, false, echo()), hdr); rec.Body.String() != "||" {
|
||||
t.Fatalf("Header muessen entfernt werden, bekam %q", rec.Body.String())
|
||||
}
|
||||
if rec := do(Middleware(v, true, echo()), hdr); rec.Body.String() != "u9|t9|" {
|
||||
t.Fatalf("Uebergangsmodus muss Header durchlassen, bekam %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRoleOnlyFromToken(t *testing.T) {
|
||||
v, iss := moduleauth.NewTestVerifierAndIssuer()
|
||||
h := Middleware(v, true, echo())
|
||||
rec := do(h, map[string]string{
|
||||
"Authorization": "Bearer " + iss.Token("u1", "acme", "tenant_admin", "", ""),
|
||||
"X-User-Role": "tenant_admin_fremd",
|
||||
})
|
||||
if rec.Body.String() != "u1|acme|tenant_admin" {
|
||||
t.Fatalf("Rolle muss aus dem Token kommen, bekam %q", rec.Body.String())
|
||||
}
|
||||
if rec := do(h, map[string]string{"X-User-Role": "tenant_admin"}); rec.Body.String() != "||" {
|
||||
t.Fatalf("Rolle ohne Token muss entfernt werden, bekam %q", rec.Body.String())
|
||||
}
|
||||
}
|
||||
Loaded 100 of 463 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user