164 lines
5.4 KiB
Go
164 lines
5.4 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,
|
||
|
|
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)
|
||
|
|
}
|
||
|
|
}
|