update see calss

This commit is contained in:
2026-08-23 22:03:57 +02:00
parent f3d4fb56ed
commit 8983cfc8a1
32 changed files with 1884 additions and 22 deletions
+163
View File
@@ -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")
}
}
+6 -3
View File
@@ -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
+5 -1
View File
@@ -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).