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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
package aiprovider
|
||||
|
||||
import (
|
||||
"context"
|
||||
"testing"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/aiaudit"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
type optionsStub struct{ withOptions, plain int }
|
||||
|
||||
func (s *optionsStub) Complete(context.Context, string, string) (processing.Completion, error) {
|
||||
s.plain++
|
||||
return processing.Completion{Text: "{}"}, nil
|
||||
}
|
||||
|
||||
func (s *optionsStub) CompleteWithOptions(context.Context, string, string, processing.CompleteOptions) (processing.Completion, error) {
|
||||
s.withOptions++
|
||||
return processing.Completion{Text: "{}"}, nil
|
||||
}
|
||||
|
||||
// Regression: processing.CompleteOnce prefers CompleterWithOptions and only falls
|
||||
// back to Complete. A wrapper that does not forward the options interface silently
|
||||
// drops MaxTokens / Temperature / ReasoningEffort for every enhance call.
|
||||
func TestAuditingCompleter_forwardsCompleteWithOptions(t *testing.T) {
|
||||
t.Parallel()
|
||||
inner := &optionsStub{}
|
||||
svc := &Service{Audit: &aiaudit.Recorder{}}
|
||||
wrapped := svc.WrapAudit(inner, uuid.New(), RoleProcessing, "internal")
|
||||
|
||||
if _, ok := wrapped.(processing.CompleterWithOptions); !ok {
|
||||
t.Fatal("wrapped completer must still satisfy CompleterWithOptions")
|
||||
}
|
||||
if _, err := processing.CompleteOnce(context.Background(), wrapped, "sys", "user",
|
||||
processing.CompleteOptions{MaxTokens: 1234}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if inner.withOptions != 1 || inner.plain != 0 {
|
||||
t.Fatalf("options path not forwarded: withOptions=%d plain=%d", inner.withOptions, inner.plain)
|
||||
}
|
||||
}
|
||||
|
||||
// A plain Completer must not gain the options interface just by being wrapped —
|
||||
// that would send options to a client that cannot honour them.
|
||||
func TestAuditingCompleter_plainCompleterStaysPlain(t *testing.T) {
|
||||
t.Parallel()
|
||||
inner := plainStub{}
|
||||
svc := &Service{Audit: &aiaudit.Recorder{}}
|
||||
wrapped := svc.WrapAudit(inner, uuid.New(), RoleProcessing, "internal")
|
||||
if _, ok := wrapped.(processing.CompleterWithOptions); ok {
|
||||
t.Fatal("plain completer must not advertise CompleterWithOptions")
|
||||
}
|
||||
}
|
||||
|
||||
type plainStub struct{}
|
||||
|
||||
func (plainStub) Complete(context.Context, string, string) (processing.Completion, error) {
|
||||
return processing.Completion{Text: "{}"}, nil
|
||||
}
|
||||
|
||||
func TestWrapAudit_noopWhenCaptureOff(t *testing.T) {
|
||||
t.Parallel()
|
||||
inner := plainStub{}
|
||||
var svc *Service
|
||||
if got := svc.WrapAudit(inner, uuid.Nil, RoleProcessing, ""); got != processing.Completer(inner) {
|
||||
t.Fatal("nil service must return the completer untouched")
|
||||
}
|
||||
svc = &Service{}
|
||||
if got := svc.WrapAudit(inner, uuid.Nil, RoleProcessing, ""); got != processing.Completer(inner) {
|
||||
t.Fatal("capture off must return the completer untouched")
|
||||
}
|
||||
}
|
||||
@@ -54,7 +54,8 @@ func (s *Service) ResolveCompleterForRole(ctx context.Context, companyID uuid.UU
|
||||
return nil, ModeInternalLabel, false, err
|
||||
}
|
||||
if ok && strings.TrimSpace(ep.APIKey) != "" && strings.TrimSpace(ep.Model) != "" {
|
||||
return s.completerFromEndpoint(ep)
|
||||
c, mode, byok, err := s.completerFromEndpoint(ep)
|
||||
return s.wrapAudit(c, companyID, role, mode), mode, byok, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,9 +65,11 @@ func (s *Service) ResolveCompleterForRole(ctx context.Context, companyID uuid.UU
|
||||
if s != nil && s.Pool != nil {
|
||||
return s.ResolveCompleter(ctx, companyID)
|
||||
}
|
||||
return s.resolvePlatformRoleCompleter(ctx, RoleProcessing)
|
||||
c, mode, byok, err := s.resolvePlatformRoleCompleter(ctx, RoleProcessing)
|
||||
return s.wrapAudit(c, companyID, RoleProcessing, mode), mode, byok, err
|
||||
case RoleDocsAPI, RoleSupport:
|
||||
return s.resolvePlatformRoleCompleter(ctx, role)
|
||||
c, mode, byok, err := s.resolvePlatformRoleCompleter(ctx, role)
|
||||
return s.wrapAudit(c, companyID, role, mode), mode, byok, err
|
||||
default:
|
||||
// Vectorization uses embeddings clients — not chat Completer.
|
||||
return nil, ModeInternalLabel, false, nil
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/aiaudit"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/platformsettings"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/processing"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/security"
|
||||
@@ -40,6 +41,9 @@ type Service struct {
|
||||
// When nil or a role is unset, ResolveCompleterForRole falls back to Resolve.
|
||||
Roles RoleEndpointSource
|
||||
HTTPClient *http.Client
|
||||
// Audit optionally captures every resolved Completer's prompts/responses for
|
||||
// the admin AI inspector. Nil disables capture (see audit.go).
|
||||
Audit *aiaudit.Recorder
|
||||
}
|
||||
|
||||
func NewService(pool *pgxpool.Pool, env EnvConfig) *Service {
|
||||
@@ -339,7 +343,7 @@ func (s *Service) ResolveCompleter(ctx context.Context, companyID uuid.UUID) (pr
|
||||
if err != nil {
|
||||
return nil, ModeInternalLabel, false, err
|
||||
}
|
||||
return r.Completer, r.ModeLabel, r.UsingBYOK, nil
|
||||
return s.wrapAudit(r.Completer, companyID, RoleProcessing, r.ModeLabel), r.ModeLabel, r.UsingBYOK, nil
|
||||
}
|
||||
|
||||
// TestPlatformRole probes admin platform AI role credentials (not company BYOK).
|
||||
|
||||
Reference in New Issue
Block a user