ai-app: Mandanten-Modellwahl und Vorlagen im Inferenzpfad #450
No files matched your search
+16
-9
@@ -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 != "" {
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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.
|
||||
@@ -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).
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user