ai-app: Mandanten-Modellwahl und Vorlagen im Inferenzpfad #450

Open
patrick wants to merge 1 commits from feat/ai-app-mandantenmodell into feat/ai-app-restpakete
pull from: feat/ai-app-mandantenmodell
7 changed files with 346 additions and 17 deletions

No files matched your search

+16 -9
View File
@@ -55,6 +55,7 @@ import (
"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/tenantprovider"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/threadsummarize"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/vectorindex"
"gitea.perlbach24.de/scripte/nexarch/common/auditclient"
@@ -67,6 +68,8 @@ const workerPollInterval = 2 * time.Second
const defaultListenAddr = "127.0.0.1:9600"
const defaultModel = "default"
const tenantProviderName = "ollama"
type config struct {
dsn string
serviceToken string
@@ -161,6 +164,14 @@ type wiring struct {
// build verdrahtet alle AI-API-Pakete (AK1) sowie Queue-Worker und
// Quota-Enforcement (AK2) auf einem gemeinsamen Pool/Provider.
func build(pool *pgxpool.Pool, cfg config, prov provider.Provider, workerID string) wiring {
listers := map[string]modelconfig.ModelLister{}
if l, ok := prov.(modelconfig.ModelLister); ok {
listers[tenantProviderName] = l
}
modelStore := modelconfig.NewStore(pool, listers)
templateStore := prompttemplate.New(pool)
tenantProv := &tenantprovider.Provider{Inner: prov, ProviderName: tenantProviderName, Models: modelStore, Templates: templateStore}
mux := http.NewServeMux()
mux.HandleFunc("GET /healthz", func(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusOK) })
@@ -221,7 +232,7 @@ func build(pool *pgxpool.Pool, cfg config, prov provider.Provider, workerID stri
langStore := &doclang.Store{Pool: pool}
markerStore := &originmark.Store{Pool: pool}
suggest := &suggestapi.Server{
Provider: prov,
Provider: tenantProv,
Model: cfg.model,
Resolver: &doclang.Resolver{Registry: registry, Tenants: langStore},
Languages: langStore,
@@ -246,22 +257,18 @@ func build(pool *pgxpool.Pool, cfg config, prov provider.Provider, workerID stri
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}
classifier := &classify.Classifier{Provider: tenantProv, Model: cfg.model}
features := &featureapi.Server{
Classifier: classifier,
Doctype: &doctype.Detector{Classifier: classifier, Taxonomy: taxonomy},
Highlight: &highlight.Detector{Provider: prov, Model: cfg.model},
Highlight: &highlight.Detector{Provider: tenantProv, 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),
Models: modelStore,
Templates: templateStore,
ServiceToken: cfg.serviceToken,
}
if cfg.coreBaseURL != "" {
+72
View File
@@ -9,6 +9,7 @@ import (
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
@@ -178,6 +179,77 @@ func TestBuild_FeatureAPIAgainstRealTables(t *testing.T) {
}
}
type recordingProvider struct {
mu sync.Mutex
last provider.ChatRequest
}
func (r *recordingProvider) Chat(_ context.Context, req provider.ChatRequest) (provider.ChatResponse, error) {
r.mu.Lock()
r.last = req
r.mu.Unlock()
return provider.ChatResponse{Message: provider.Message{Role: provider.RoleAssistant, Content: `[{"title":"Rechnung Mai","confidence":0.9}]`}}, nil
}
func (r *recordingProvider) Complete(context.Context, provider.CompletionRequest) (provider.CompletionResponse, error) {
return provider.CompletionResponse{}, nil
}
func (r *recordingProvider) Embed(context.Context, provider.EmbeddingRequest) (provider.EmbeddingResponse, error) {
return provider.EmbeddingResponse{}, nil
}
func (r *recordingProvider) ListModels(context.Context) ([]string, error) {
return []string{"big"}, nil
}
func TestBuild_TenantModelAndTemplateReachInference(t *testing.T) {
pool := newTestPool(t)
rec := &recordingProvider{}
w := build(pool, config{serviceToken: "tok", model: "standard"}, rec, "tenant-model-worker")
srv := httptest.NewServer(w.mux)
defer srv.Close()
call := func(method, path, body string) int {
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)
}
resp.Body.Close()
return resp.StatusCode
}
title := func(tenant string) provider.ChatRequest {
t.Helper()
if code := call("POST", "/suggest/title", `{"tenant_slug":"`+tenant+`","text":"Rechnung fuer Mai"}`); code != http.StatusOK {
t.Fatalf("title %s: status %d", tenant, code)
}
rec.mu.Lock()
defer rec.mu.Unlock()
return rec.last
}
if got := title("tm-a"); got.Model != "standard" {
t.Fatalf("ohne Wahl: model = %q, want standard", got.Model)
}
if code := call("PUT", "/tenants/tm-a/model-config/title", `{"provider":"ollama","model":"big"}`); code != http.StatusOK {
t.Fatalf("model-config put: %d", code)
}
if code := call("POST", "/prompt-templates/title", `{"body":"Vorlage-Marker","created_by":"u"}`); code != http.StatusCreated {
t.Fatalf("template save: %d", code)
}
got := title("tm-a")
if got.Model != "big" {
t.Errorf("mit Wahl: model = %q, want big", got.Model)
}
if !strings.HasPrefix(got.Messages[0].Content, "Vorlage-Marker") {
t.Errorf("system = %q, want Vorlage", got.Messages[0].Content)
}
if other := title("tm-b"); other.Model != "standard" {
t.Errorf("anderer Mandant: model = %q, want standard", other.Model)
}
}
func newTestPool(t *testing.T) *pgxpool.Pool {
t.Helper()
dsn := os.Getenv("TEST_TENANT_DSN")
+12 -1
View File
@@ -21,7 +21,18 @@ Alle Routen verlangen `X-Service-Token`. Verdrahtet in `ai/cmd/ai-app/main.go` (
- `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.
## Modellwahl und Vorlage im Inferenzpfad
`internal/tenantprovider` umschliesst den Provider. Fuer die Funktionen `title`, `correspondent`, `tags` (suggestapi) sowie `classify`, `doctype`, `highlight` (featureapi) gilt pro Aufruf:
- Modell: Eintrag aus `modelconfig` fuer Mandant und Funktion, sonst `NEXARCH_AI_APP_MODEL`. Nur Eintraege mit Provider `ollama` greifen.
- Systemprompt: die aktive `prompttemplate`-Version zum Funktionsnamen ersetzt die erste Systemnachricht. Die Schutzanweisung gegen Prompt-Injection (`promptguard.SystemGuidance`) wird immer angehaengt. Die Vorlage gilt fuer alle Mandanten, sie ist nicht mandantenbezogen.
- Lesefehler oder fehlender Eintrag: Standardmodell und Standardprompt, ein Lesefehler wird geloggt.
Der Nutzerprompt mit Dokumenttext und Ausgabeformat bleibt im Code. Eine Vorlage ersetzt nur die Rollenanweisung, nicht das geforderte JSON-Format.
## 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`.
- Summary-, Extract-, Docchat- und Suche-Routen: kein `tenant_slug` bzw. eigene Prompts, daher keine Modellwahl und keine Vorlage.
- Embeddings: Modellwechsel wuerde die Vektoren unvergleichbar machen, deshalb nicht angebunden.
- `notraining` ist ein reiner Architekturtest, `refcorpus` ein Testkorpus. Beide haben keine Laufzeitfunktion und bekommen keine Route.
+5 -4
View File
@@ -17,6 +17,7 @@ import (
"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/ai/internal/tenantprovider"
"gitea.perlbach24.de/scripte/nexarch/common/svcauth"
)
@@ -184,9 +185,9 @@ func (s *Server) handleClassify(w http.ResponseWriter, r *http.Request) {
)
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)
res, err = s.Classifier.ClassifyWithSchemaAudited(tenantprovider.WithCall(r.Context(), req.TenantSlug, FunctionClassify), schema, req.Text, dc, req.InputRef)
} else {
res, err = s.Classifier.ClassifyWithSchema(r.Context(), schema, req.Text)
res, err = s.Classifier.ClassifyWithSchema(tenantprovider.WithCall(r.Context(), req.TenantSlug, FunctionClassify), schema, req.Text)
}
if err != nil {
writeError(w, http.StatusBadGateway, "klassifizierung fehlgeschlagen")
@@ -232,7 +233,7 @@ func (s *Server) handleDoctypeDetect(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusConflict, "keine dokumenttyp-taxonomie fuer diesen mandanten")
return
}
res, err := s.Doctype.Detect(r.Context(), req.TenantSlug, req.Text)
res, err := s.Doctype.Detect(tenantprovider.WithCall(r.Context(), req.TenantSlug, FunctionDoctype), req.TenantSlug, req.Text)
if err != nil {
writeError(w, http.StatusBadGateway, "dokumenttyp-erkennung fehlgeschlagen")
return
@@ -299,7 +300,7 @@ func (s *Server) handleHighlight(w http.ResponseWriter, r *http.Request) {
if !s.enabled(w, r, req.TenantSlug, FunctionHighlight) {
return
}
passages, err := s.Highlight.DetectImportant(r.Context(), req.Text)
passages, err := s.Highlight.DetectImportant(tenantprovider.WithCall(r.Context(), req.TenantSlug, FunctionHighlight), req.Text)
resp := map[string]any{"passages": nonNil(passages)}
if err != nil {
// Teilergebnisse bleiben erhalten (highlight liefert sie neben dem Fehler).
+4 -3
View File
@@ -12,6 +12,7 @@ import (
"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/tenantprovider"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/titlesuggest"
"gitea.perlbach24.de/scripte/nexarch/common/svcauth"
)
@@ -112,7 +113,7 @@ func (s *Server) handleTitle(w http.ResponseWriter, r *http.Request) {
return
}
sug := &titlesuggest.Suggester{Provider: s.Provider, Model: s.Model}
titles, err := sug.Suggest(r.Context(), req.Module, res.Output.Name, req.Text)
titles, err := sug.Suggest(tenantprovider.WithCall(r.Context(), req.TenantSlug, "title"), req.Module, res.Output.Name, req.Text)
if err != nil {
writeError(w, http.StatusBadGateway, "titelvorschlag fehlgeschlagen")
return
@@ -134,7 +135,7 @@ func (s *Server) handleCorrespondent(w http.ResponseWriter, r *http.Request) {
return
}
sug := &correspondentsuggest.Suggester{Provider: s.Provider, Model: s.Model}
got, found, err := sug.Suggest(r.Context(), req.Existing, req.Module, req.Text)
got, found, err := sug.Suggest(tenantprovider.WithCall(r.Context(), req.TenantSlug, "correspondent"), req.Existing, req.Module, req.Text)
if err != nil {
writeError(w, http.StatusBadGateway, "korrespondentenvorschlag fehlgeschlagen")
return
@@ -157,7 +158,7 @@ func (s *Server) handleTags(w http.ResponseWriter, r *http.Request) {
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)
tags, err := sug.Suggest(tenantprovider.WithCall(r.Context(), req.TenantSlug, "tags"), req.Existing, req.Module, req.Text)
if err != nil {
writeError(w, http.StatusBadGateway, "tagvorschlag fehlgeschlagen")
return
@@ -0,0 +1,100 @@
package tenantprovider
import (
"context"
"errors"
"log"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/modelconfig"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/promptguard"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/prompttemplate"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
)
type Models interface {
Get(ctx context.Context, tenantSlug, function string) (modelconfig.Selection, error)
}
type Templates interface {
Active(ctx context.Context, functionKey string) (prompttemplate.Version, error)
}
type callKey struct{}
type call struct{ tenant, function string }
// WithCall markiert den Kontext mit Mandant und Funktion; nur dann greifen Modellwahl und Vorlage.
func WithCall(ctx context.Context, tenantSlug, function string) context.Context {
return context.WithValue(ctx, callKey{}, call{tenantSlug, function})
}
// Provider wendet pro Aufruf die Modellwahl (modelconfig) und die aktive Systemvorlage
// (prompttemplate) an. Ohne Eintrag oder bei Lesefehlern bleibt die Anfrage unveraendert.
type Provider struct {
Inner provider.Provider
// ProviderName ist der Name, unter dem Inner in modelconfig gefuehrt wird; andere Eintraege werden ignoriert.
ProviderName string
Models Models
Templates Templates
}
var _ provider.Provider = (*Provider)(nil)
func (p *Provider) model(ctx context.Context, c call, current string) string {
sel, err := p.Models.Get(ctx, c.tenant, c.function)
if err != nil {
if !errors.Is(err, modelconfig.ErrNotFound) {
log.Printf("tenantprovider: modellwahl %s/%s nicht lesbar, Standardmodell: %v", c.tenant, c.function, err)
}
return current
}
if sel.ProviderName != p.ProviderName || sel.Model == "" {
return current
}
return sel.Model
}
func (p *Provider) Chat(ctx context.Context, req provider.ChatRequest) (provider.ChatResponse, error) {
if c, ok := ctx.Value(callKey{}).(call); ok {
req.Model = p.model(ctx, c, req.Model)
req.Messages = p.withTemplate(ctx, c, req.Messages)
}
return p.Inner.Chat(ctx, req)
}
func (p *Provider) Complete(ctx context.Context, req provider.CompletionRequest) (provider.CompletionResponse, error) {
if c, ok := ctx.Value(callKey{}).(call); ok {
req.Model = p.model(ctx, c, req.Model)
}
return p.Inner.Complete(ctx, req)
}
func (p *Provider) Embed(ctx context.Context, req provider.EmbeddingRequest) (provider.EmbeddingResponse, error) {
return p.Inner.Embed(ctx, req)
}
// withTemplate ersetzt die erste Systemnachricht; die Schutzanweisung gegen Prompt-Injection bleibt immer erhalten.
func (p *Provider) withTemplate(ctx context.Context, c call, msgs []provider.Message) []provider.Message {
v, err := p.Templates.Active(ctx, c.function)
if err != nil {
if !errors.Is(err, prompttemplate.ErrNotFound) {
log.Printf("tenantprovider: vorlage %s nicht lesbar, Standardprompt: %v", c.function, err)
}
return msgs
}
sys := provider.Message{Role: provider.RoleSystem, Content: v.Body + " " + promptguard.SystemGuidance}
out := make([]provider.Message, 0, len(msgs)+1)
replaced := false
for _, m := range msgs {
if m.Role == provider.RoleSystem && !replaced {
out = append(out, sys)
replaced = true
continue
}
out = append(out, m)
}
if !replaced {
out = append([]provider.Message{sys}, out...)
}
return out
}
@@ -0,0 +1,137 @@
package tenantprovider
import (
"context"
"errors"
"strings"
"testing"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/modelconfig"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/promptguard"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/prompttemplate"
"gitea.perlbach24.de/scripte/nexarch/ai/internal/provider"
)
type spy struct {
chat provider.ChatRequest
comp provider.CompletionRequest
}
func (s *spy) Chat(_ context.Context, r provider.ChatRequest) (provider.ChatResponse, error) {
s.chat = r
return provider.ChatResponse{}, nil
}
func (s *spy) Complete(_ context.Context, r provider.CompletionRequest) (provider.CompletionResponse, error) {
s.comp = r
return provider.CompletionResponse{}, nil
}
func (s *spy) Embed(context.Context, provider.EmbeddingRequest) (provider.EmbeddingResponse, error) {
return provider.EmbeddingResponse{}, nil
}
type models map[string]modelconfig.Selection
func (m models) Get(_ context.Context, t, f string) (modelconfig.Selection, error) {
if s, ok := m[t+"/"+f]; ok {
return s, nil
}
return modelconfig.Selection{}, modelconfig.ErrNotFound
}
type templates map[string]string
func (m templates) Active(_ context.Context, k string) (prompttemplate.Version, error) {
if b, ok := m[k]; ok {
return prompttemplate.Version{FunctionKey: k, Version: 1, Body: b}, nil
}
return prompttemplate.Version{}, prompttemplate.ErrNotFound
}
type failing struct{}
func (failing) Get(context.Context, string, string) (modelconfig.Selection, error) {
return modelconfig.Selection{}, errors.New("db down")
}
func (failing) Active(context.Context, string) (prompttemplate.Version, error) {
return prompttemplate.Version{}, errors.New("db down")
}
var base = provider.ChatRequest{Model: "default", Messages: []provider.Message{
{Role: provider.RoleSystem, Content: "alt"}, {Role: provider.RoleUser, Content: "text"},
}}
func newP(inner provider.Provider, m Models, t Templates) *Provider {
return &Provider{Inner: inner, ProviderName: "ollama", Models: m, Templates: t}
}
func TestChat_AppliesTenantModelAndTemplate(t *testing.T) {
s := &spy{}
p := newP(s, models{"acme/title": {ProviderName: "ollama", Model: "big"}}, templates{"title": "neu"})
_, _ = p.Chat(WithCall(context.Background(), "acme", "title"), base)
if s.chat.Model != "big" {
t.Errorf("model = %q, want big", s.chat.Model)
}
if got := s.chat.Messages[0].Content; !strings.HasPrefix(got, "neu ") || !strings.HasSuffix(got, promptguard.SystemGuidance) {
t.Errorf("system = %q, want vorlage + schutzanweisung", got)
}
if len(s.chat.Messages) != 2 || s.chat.Messages[1].Content != "text" {
t.Errorf("nutzernachricht veraendert: %+v", s.chat.Messages)
}
}
func TestChat_OtherTenantKeepsDefaultModel(t *testing.T) {
s := &spy{}
p := newP(s, models{"acme/title": {ProviderName: "ollama", Model: "big"}}, templates{})
_, _ = p.Chat(WithCall(context.Background(), "globex", "title"), base)
if s.chat.Model != "default" {
t.Errorf("model = %q, want default", s.chat.Model)
}
if s.chat.Messages[0].Content != "alt" {
t.Errorf("system = %q, want unveraendert", s.chat.Messages[0].Content)
}
}
func TestChat_ForeignProviderSelectionIgnored(t *testing.T) {
s := &spy{}
p := newP(s, models{"acme/title": {ProviderName: "openai", Model: "gpt"}}, templates{})
_, _ = p.Chat(WithCall(context.Background(), "acme", "title"), base)
if s.chat.Model != "default" {
t.Errorf("model = %q, want default", s.chat.Model)
}
}
func TestChat_WithoutCallContextUntouched(t *testing.T) {
s := &spy{}
p := newP(s, models{"acme/title": {ProviderName: "ollama", Model: "big"}}, templates{"title": "neu"})
_, _ = p.Chat(context.Background(), base)
if s.chat.Model != "default" || s.chat.Messages[0].Content != "alt" {
t.Errorf("ohne call-kontext veraendert: %+v", s.chat)
}
}
func TestChat_ReadErrorsFallBackToDefaults(t *testing.T) {
s := &spy{}
p := newP(s, failing{}, failing{})
_, err := p.Chat(WithCall(context.Background(), "acme", "title"), base)
if err != nil || s.chat.Model != "default" || s.chat.Messages[0].Content != "alt" {
t.Errorf("err=%v req=%+v", err, s.chat)
}
}
func TestChat_TemplateWithoutSystemMessageIsPrepended(t *testing.T) {
s := &spy{}
p := newP(s, models{}, templates{"title": "neu"})
_, _ = p.Chat(WithCall(context.Background(), "a", "title"), provider.ChatRequest{Model: "m", Messages: []provider.Message{{Role: provider.RoleUser, Content: "x"}}})
if len(s.chat.Messages) != 2 || s.chat.Messages[0].Role != provider.RoleSystem {
t.Errorf("messages = %+v", s.chat.Messages)
}
}
func TestComplete_AppliesModelOnly(t *testing.T) {
s := &spy{}
p := newP(s, models{"acme/f": {ProviderName: "ollama", Model: "big"}}, templates{"f": "neu"})
_, _ = p.Complete(WithCall(context.Background(), "acme", "f"), provider.CompletionRequest{Model: "default", Prompt: "p"})
if s.comp.Model != "big" || s.comp.Prompt != "p" {
t.Errorf("comp = %+v", s.comp)
}
}