Files
2026-08-23 22:52:13 +02:00

165 lines
5.6 KiB
Go

package aiprovider
import (
"context"
"strings"
"time"
"github.com/descrybe/descrybe-v2/apps/api/internal/aiaudit"
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
"github.com/google/uuid"
)
// Every Completer this service hands out is wrapped here, so the admin AI
// inspector sees processing, categorize, SEO meta, campaign and support calls from
// one capture point instead of each caller remembering to log.
//
// Callers attach per-call detail (job, product, role) with aiaudit.WithCall; the
// wrapper fills in what the resolver already knows (company, provider mode, model).
// Audit enables prompt/response capture. Nil disables it and the wrapper is skipped
// entirely, so a service built without a recorder behaves exactly as before.
func (s *Service) WithAudit(rec *aiaudit.Recorder) *Service {
if s == nil {
return s
}
s.Audit = rec
return s
}
type auditingCompleter struct {
inner processing.Completer
rec *aiaudit.Recorder
companyID uuid.UUID
role string
providerMode string
}
// Enabled forwards the wrapped client's gate so CompleterEnabled keeps working
// (processing checks for the EnableChecker interface, not a concrete type).
func (a *auditingCompleter) Enabled() bool {
if c, ok := a.inner.(processing.EnableChecker); ok {
return c.Enabled()
}
return a.inner != nil
}
// ProviderModeLabel forwards the wrapped client's analytics label.
func (a *auditingCompleter) ProviderModeLabel() string {
type labeler interface{ ProviderModeLabel() string }
if c, ok := a.inner.(labeler); ok {
return c.ProviderModeLabel()
}
return a.providerMode
}
func (a *auditingCompleter) Complete(ctx context.Context, system, user string) (processing.Completion, error) {
return a.record(ctx, system, user, func() (processing.Completion, error) {
return a.inner.Complete(ctx, system, user)
})
}
// auditingCompleterWithOptions is used when the wrapped client honours
// CompleteOptions. processing.CompleteOnce prefers that interface and only falls
// back to Complete, so a wrapper that did not forward it would silently drop
// MaxTokens / Temperature / ReasoningEffort from every enhance call.
type auditingCompleterWithOptions struct {
*auditingCompleter
inner processing.CompleterWithOptions
}
func (a *auditingCompleterWithOptions) CompleteWithOptions(ctx context.Context, system, user string, opts processing.CompleteOptions) (processing.Completion, error) {
return a.record(ctx, system, user, func() (processing.Completion, error) {
return a.inner.CompleteWithOptions(ctx, system, user, opts)
})
}
func (a *auditingCompleter) record(ctx context.Context, system, user string, call func() (processing.Completion, error)) (processing.Completion, error) {
started := time.Now()
comp, err := call()
// The call site knows the role better than the resolver does: one processing
// Completer serves both the enhance and categorize steps.
role := a.role
if cc := aiaudit.FromContext(ctx); strings.TrimSpace(cc.Role) != "" {
role = cc.Role
}
rec := aiaudit.Call{
CompanyID: a.companyID,
Role: role,
ProviderMode: a.providerMode,
Model: comp.Model,
System: system,
User: user,
Response: comp.Text,
FinishReason: finishReason(comp),
PromptTokens: comp.PromptTokens,
CachedPromptTokens: comp.CachedPromptTokens,
OutputTokens: comp.OutputTokens,
TotalTokens: comp.TotalTokens,
Duration: time.Since(started),
}
if err != nil {
// The provider error text is already redacted by processing's OpenAI client.
rec.Error = err.Error()
}
a.rec.Record(ctx, rec)
return comp, err
}
func finishReason(comp processing.Completion) string {
m, ok := comp.Raw.(map[string]any)
if !ok || m == nil {
return ""
}
fr, _ := m["finish_reason"].(string)
return strings.TrimSpace(fr)
}
// WrapAudit decorates an externally supplied Completer with the same capture the
// resolvers apply. Local tools that pin a provider endpoint (cmd/formula-e2e) use
// it so their runs show up in the admin inspector too.
func (s *Service) WrapAudit(c processing.Completer, companyID uuid.UUID, role, providerMode string) processing.Completer {
return s.wrapAudit(c, companyID, role, providerMode)
}
// wrapAudit decorates a resolved Completer with capture. It is idempotent (a
// re-wrap keeps the original) and a no-op when capture is off or c is nil, so the
// public resolvers can all call it on their way out.
func (s *Service) wrapAudit(c processing.Completer, companyID uuid.UUID, role, providerMode string) processing.Completer {
if s == nil || s.Audit == nil || c == nil {
return c
}
switch c.(type) {
case *auditingCompleter, *auditingCompleterWithOptions:
return c
}
base := &auditingCompleter{
inner: c,
rec: s.Audit,
companyID: companyID,
role: auditRole(role),
providerMode: providerMode,
}
// Only advertise the options interface when the wrapped client actually has it,
// so a plain Completer is not handed options it cannot honour.
if withOpts, ok := c.(processing.CompleterWithOptions); ok {
return &auditingCompleterWithOptions{auditingCompleter: base, inner: withOpts}
}
return base
}
// auditRole maps an aiprovider role onto the aiaudit vocabulary shown in the admin
// filter. Unknown roles are recorded rather than dropped.
func auditRole(role string) string {
switch strings.TrimSpace(role) {
case "", RoleProcessing:
return aiaudit.RoleProcessing
case RoleSupport:
return aiaudit.RoleSupport
case RoleDocsAPI:
return aiaudit.RoleOther
default:
return strings.TrimSpace(role)
}
}