update see calss
This commit is contained in:
@@ -0,0 +1,163 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user