Initial commit of Descrybe v2 without local scratch artifacts.
Drop one-shot tmp/axe scripts and agent i18n scratch so the Gitea tree is deployable.
This commit is contained in:
@@ -0,0 +1,180 @@
|
||||
package aiprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Role identifiers — keep in sync with platformsettings.AIRole* / processing.AIRole*.
|
||||
//
|
||||
// RoleSupport is a FUTURE config slot (platformsettings.ai_roles["support"]).
|
||||
// Resolving a Completer when the slot is configured is allowed for future
|
||||
// draft-assist UIs, but support.TryAutoReplyLLM must remain the only gate for
|
||||
// ticket auto-replies — and that stub currently refuses. Guided /docs Ask is
|
||||
// rule-based and must never use RoleSupport or RoleDocsAPI.
|
||||
const (
|
||||
RoleProcessing = processing.AIRoleProcessing
|
||||
RoleVectorization = processing.AIRoleVectorization
|
||||
RoleDocsAPI = processing.AIRoleDocsAPI
|
||||
RoleSupport = processing.AIRoleSupport
|
||||
)
|
||||
|
||||
// RoleEndpoint is a resolved OpenAI-compatible chat endpoint for one role.
|
||||
// Secrets are plaintext only in-process — never log or return to clients.
|
||||
type RoleEndpoint struct {
|
||||
APIKey string
|
||||
BaseURL string
|
||||
Model string
|
||||
UsingBYOK bool
|
||||
ModeLabel string
|
||||
}
|
||||
|
||||
// RoleEndpointSource looks up admin-configured role bindings (platform / company).
|
||||
// ok=false means the role is unset — callers must fall back.
|
||||
type RoleEndpointSource interface {
|
||||
LookupRole(ctx context.Context, companyID uuid.UUID, role string) (ep RoleEndpoint, ok bool, err error)
|
||||
}
|
||||
|
||||
// ResolveCompleterForRole prefers an injected RoleEndpointSource binding when set;
|
||||
// otherwise uses company BYOK then platformsettings.ResolveAIConfig for the role
|
||||
// (processing falls back to legacy openai JSON + OPENAI_* env when unset).
|
||||
func (s *Service) ResolveCompleterForRole(ctx context.Context, companyID uuid.UUID, role string) (processing.Completer, string, bool, error) {
|
||||
role = strings.TrimSpace(role)
|
||||
if role == "" {
|
||||
role = RoleProcessing
|
||||
}
|
||||
|
||||
if s != nil && s.Roles != nil {
|
||||
ep, ok, err := s.Roles.LookupRole(ctx, companyID, role)
|
||||
if err != nil {
|
||||
return nil, ModeInternalLabel, false, err
|
||||
}
|
||||
if ok && strings.TrimSpace(ep.APIKey) != "" && strings.TrimSpace(ep.Model) != "" {
|
||||
return s.completerFromEndpoint(ep)
|
||||
}
|
||||
}
|
||||
|
||||
switch role {
|
||||
case RoleProcessing:
|
||||
// Full Resolve needs Pool for company BYOK; without Pool use platform/env only.
|
||||
if s != nil && s.Pool != nil {
|
||||
return s.ResolveCompleter(ctx, companyID)
|
||||
}
|
||||
return s.resolvePlatformRoleCompleter(ctx, RoleProcessing)
|
||||
case RoleDocsAPI, RoleSupport:
|
||||
return s.resolvePlatformRoleCompleter(ctx, role)
|
||||
default:
|
||||
// Vectorization uses embeddings clients — not chat Completer.
|
||||
return nil, ModeInternalLabel, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
// ResolveEmbedderForRole returns an OpenAI-compatible Embedder for the
|
||||
// vectorization role (platformsettings.AIRoleVectorization) with env fallback.
|
||||
// Non-vectorization roles return (nil, nil). Unset config returns (nil, nil).
|
||||
func (s *Service) ResolveEmbedderForRole(ctx context.Context, companyID uuid.UUID, role string) (processing.Embedder, error) {
|
||||
role = strings.TrimSpace(role)
|
||||
if role == "" {
|
||||
role = RoleVectorization
|
||||
}
|
||||
if role != RoleVectorization {
|
||||
return nil, nil
|
||||
}
|
||||
if s != nil && s.Roles != nil {
|
||||
ep, ok, err := s.Roles.LookupRole(ctx, companyID, role)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if ok && strings.TrimSpace(ep.APIKey) != "" {
|
||||
model := strings.TrimSpace(ep.Model)
|
||||
if model == "" {
|
||||
model = "text-embedding-3-small"
|
||||
}
|
||||
rpm, retries := 0, 3
|
||||
if s != nil {
|
||||
rpm = s.Env.ProcessingRPM
|
||||
retries = s.Env.ProcessingMaxRetries
|
||||
}
|
||||
client := processing.NewOpenAIClient(ep.APIKey, ep.BaseURL, model, rpm, retries)
|
||||
if s.HTTPClient != nil {
|
||||
client.HTTPClient = s.HTTPClient
|
||||
}
|
||||
return client, nil
|
||||
}
|
||||
}
|
||||
if s != nil && s.Platform != nil {
|
||||
return s.Platform.ResolveEmbedder(ctx)
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (s *Service) resolvePlatformRoleCompleter(ctx context.Context, role string) (processing.Completer, string, bool, error) {
|
||||
if s == nil {
|
||||
return nil, ModeInternalLabel, false, nil
|
||||
}
|
||||
if s.Platform != nil {
|
||||
cfg, err := s.Platform.ResolveAIConfig(ctx, role)
|
||||
if err != nil {
|
||||
return nil, ModeInternalLabel, false, err
|
||||
}
|
||||
if strings.TrimSpace(cfg.APIKey) != "" {
|
||||
if role != RoleProcessing && !cfg.Enabled {
|
||||
return nil, ModeInternalLabel, false, nil
|
||||
}
|
||||
model := strings.TrimSpace(cfg.Model)
|
||||
if model == "" && role == RoleProcessing {
|
||||
model = strings.TrimSpace(s.Env.OpenAIModel)
|
||||
}
|
||||
if model != "" {
|
||||
return s.completerFromEndpoint(RoleEndpoint{
|
||||
APIKey: cfg.APIKey,
|
||||
BaseURL: cfg.BaseURL,
|
||||
Model: model,
|
||||
UsingBYOK: false,
|
||||
ModeLabel: ModeInternalLabel,
|
||||
})
|
||||
}
|
||||
}
|
||||
return nil, ModeInternalLabel, false, nil
|
||||
}
|
||||
if role == RoleProcessing {
|
||||
key := strings.TrimSpace(s.Env.OpenAIAPIKey)
|
||||
model := strings.TrimSpace(s.Env.OpenAIModel)
|
||||
if key != "" && model != "" {
|
||||
return s.completerFromEndpoint(RoleEndpoint{
|
||||
APIKey: key,
|
||||
BaseURL: strings.TrimSpace(s.Env.OpenAIBaseURL),
|
||||
Model: model,
|
||||
UsingBYOK: false,
|
||||
ModeLabel: ModeInternalLabel,
|
||||
})
|
||||
}
|
||||
}
|
||||
return nil, ModeInternalLabel, false, nil
|
||||
}
|
||||
|
||||
func (s *Service) completerFromEndpoint(ep RoleEndpoint) (processing.Completer, string, bool, error) {
|
||||
rpm := 0
|
||||
retries := 0
|
||||
if s != nil {
|
||||
rpm = s.Env.ProcessingRPM
|
||||
retries = s.Env.ProcessingMaxRetries
|
||||
}
|
||||
client := processing.NewOpenAIClient(ep.APIKey, ep.BaseURL, ep.Model, rpm, retries)
|
||||
label := strings.TrimSpace(ep.ModeLabel)
|
||||
if label == "" {
|
||||
if ep.UsingBYOK {
|
||||
label = ModeCustom
|
||||
} else {
|
||||
label = ModeInternalLabel
|
||||
}
|
||||
}
|
||||
client.ModeLabel = label
|
||||
if s != nil && s.HTTPClient != nil {
|
||||
client.HTTPClient = s.HTTPClient
|
||||
}
|
||||
return client, label, ep.UsingBYOK, nil
|
||||
}
|
||||
Reference in New Issue
Block a user