update see calss
This commit is contained in:
@@ -0,0 +1,213 @@
|
||||
// Package aiaudit records the exact prompt and response of every LLM call so
|
||||
// platform admins can inspect them at /admin/ai-calls.
|
||||
//
|
||||
// It is deliberately a leaf package: it imports neither processing nor aiprovider,
|
||||
// so processing can attach per-call context (job, product, role) without an import
|
||||
// cycle while aiprovider does the actual Completer wrapping.
|
||||
//
|
||||
// Rows are a short-lived debugging buffer, not an audit trail — CleanupExpired
|
||||
// prunes them after RetentionDays. Never build billing or reporting on them.
|
||||
package aiaudit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
// Roles mirror aiprovider role ids plus "probe" for admin connectivity tests.
|
||||
const (
|
||||
RoleProcessing = "processing"
|
||||
RoleCategorize = "categorize"
|
||||
RoleSEOMeta = "seo_meta"
|
||||
RoleCampaign = "campaign"
|
||||
RoleSupport = "support"
|
||||
RoleProbe = "probe"
|
||||
RoleOther = "other"
|
||||
)
|
||||
|
||||
// RetentionDays bounds how long captured prompts live. Prompts embed the tenant's
|
||||
// product copy and are large, so the window is intentionally short.
|
||||
const RetentionDays = 7
|
||||
|
||||
// maxBodyRunes caps each stored body. A category-formula prompt is a few KB; this
|
||||
// leaves room for outliers while stopping a runaway reply from bloating the table.
|
||||
const maxBodyRunes = 60000
|
||||
|
||||
// Call is one recorded LLM exchange.
|
||||
type Call struct {
|
||||
CompanyID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
JobID uuid.UUID
|
||||
RawProductID uuid.UUID
|
||||
Role string
|
||||
ProviderMode string
|
||||
Model string
|
||||
System string
|
||||
User string
|
||||
Response string
|
||||
Error string
|
||||
FinishReason string
|
||||
PromptTokens int
|
||||
OutputTokens int
|
||||
TotalTokens int
|
||||
Duration time.Duration
|
||||
}
|
||||
|
||||
// CallContext is the per-call metadata processing/campaigns attach to ctx before
|
||||
// invoking a Completer. The recorder merges it into every Call it writes.
|
||||
type CallContext struct {
|
||||
CompanyID uuid.UUID
|
||||
UserID uuid.UUID
|
||||
JobID uuid.UUID
|
||||
RawProductID uuid.UUID
|
||||
Role string
|
||||
}
|
||||
|
||||
type ctxKey struct{}
|
||||
|
||||
// WithCall returns ctx carrying call metadata for the recorder. Fields left zero
|
||||
// on cc do not clear values already present, so an outer scope can set company/user
|
||||
// once per job and an inner scope add the product.
|
||||
func WithCall(ctx context.Context, cc CallContext) context.Context {
|
||||
cur := FromContext(ctx)
|
||||
if cc.CompanyID != uuid.Nil {
|
||||
cur.CompanyID = cc.CompanyID
|
||||
}
|
||||
if cc.UserID != uuid.Nil {
|
||||
cur.UserID = cc.UserID
|
||||
}
|
||||
if cc.JobID != uuid.Nil {
|
||||
cur.JobID = cc.JobID
|
||||
}
|
||||
if cc.RawProductID != uuid.Nil {
|
||||
cur.RawProductID = cc.RawProductID
|
||||
}
|
||||
if strings.TrimSpace(cc.Role) != "" {
|
||||
cur.Role = cc.Role
|
||||
}
|
||||
return context.WithValue(ctx, ctxKey{}, cur)
|
||||
}
|
||||
|
||||
// FromContext reads call metadata attached by WithCall (zero value when absent).
|
||||
func FromContext(ctx context.Context) CallContext {
|
||||
if ctx == nil {
|
||||
return CallContext{}
|
||||
}
|
||||
cc, _ := ctx.Value(ctxKey{}).(CallContext)
|
||||
return cc
|
||||
}
|
||||
|
||||
// Recorder writes captured calls. A nil Recorder is a no-op, so callers never need
|
||||
// to branch on whether capture is wired.
|
||||
type Recorder struct {
|
||||
pool *pgxpool.Pool
|
||||
|
||||
mu sync.Mutex
|
||||
failed bool // stop log-spamming once writes are known to fail
|
||||
}
|
||||
|
||||
// NewRecorder returns a Recorder writing to pool. nil pool yields a no-op recorder.
|
||||
func NewRecorder(pool *pgxpool.Pool) *Recorder {
|
||||
if pool == nil {
|
||||
return nil
|
||||
}
|
||||
return &Recorder{pool: pool}
|
||||
}
|
||||
|
||||
const insertCallSQL = `
|
||||
INSERT INTO ai_call_logs (
|
||||
company_id, user_id, job_id, raw_product_id, role, provider_mode, model,
|
||||
system_prompt, user_prompt, response_text, error, finish_reason,
|
||||
prompt_tokens, completion_tokens, total_tokens, duration_ms)
|
||||
VALUES (
|
||||
$1, $2, $3, $4, $5, $6, $7,
|
||||
$8, $9, $10, $11, $12,
|
||||
$13, $14, $15, $16)`
|
||||
|
||||
// Record persists one call, merging any CallContext on ctx. Capture must never
|
||||
// break the request it is observing: every failure is swallowed after one log line.
|
||||
//
|
||||
// The write uses context.WithoutCancel so a cancelled/timed-out job still records
|
||||
// the call that was in flight — those are exactly the ones worth inspecting.
|
||||
func (r *Recorder) Record(ctx context.Context, c Call) {
|
||||
if r == nil || r.pool == nil {
|
||||
return
|
||||
}
|
||||
cc := FromContext(ctx)
|
||||
if c.CompanyID == uuid.Nil {
|
||||
c.CompanyID = cc.CompanyID
|
||||
}
|
||||
if c.UserID == uuid.Nil {
|
||||
c.UserID = cc.UserID
|
||||
}
|
||||
if c.JobID == uuid.Nil {
|
||||
c.JobID = cc.JobID
|
||||
}
|
||||
if c.RawProductID == uuid.Nil {
|
||||
c.RawProductID = cc.RawProductID
|
||||
}
|
||||
if strings.TrimSpace(c.Role) == "" {
|
||||
c.Role = cc.Role
|
||||
}
|
||||
if strings.TrimSpace(c.Role) == "" {
|
||||
c.Role = RoleOther
|
||||
}
|
||||
|
||||
writeCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 5*time.Second)
|
||||
defer cancel()
|
||||
_, err := r.pool.Exec(writeCtx, insertCallSQL,
|
||||
nullUUID(c.CompanyID), nullUUID(c.UserID), nullUUID(c.JobID), nullUUID(c.RawProductID),
|
||||
c.Role, c.ProviderMode, c.Model,
|
||||
clamp(c.System), clamp(c.User), clamp(c.Response), clamp(c.Error), c.FinishReason,
|
||||
c.PromptTokens, c.OutputTokens, c.TotalTokens, int(c.Duration.Milliseconds()),
|
||||
)
|
||||
if err != nil {
|
||||
r.noteFailure(err)
|
||||
}
|
||||
}
|
||||
|
||||
func (r *Recorder) noteFailure(err error) {
|
||||
r.mu.Lock()
|
||||
first := !r.failed
|
||||
r.failed = true
|
||||
r.mu.Unlock()
|
||||
if first {
|
||||
log.Printf("aiaudit: capture disabled after write error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func nullUUID(id uuid.UUID) any {
|
||||
if id == uuid.Nil {
|
||||
return nil
|
||||
}
|
||||
return id
|
||||
}
|
||||
|
||||
func clamp(s string) string {
|
||||
rs := []rune(s)
|
||||
if len(rs) <= maxBodyRunes {
|
||||
return s
|
||||
}
|
||||
return string(rs[:maxBodyRunes]) + "\n…[truncated by aiaudit]"
|
||||
}
|
||||
|
||||
// CleanupExpired deletes captured calls older than RetentionDays and returns how
|
||||
// many rows went. Called from the worker's retention tick.
|
||||
func CleanupExpired(ctx context.Context, pool *pgxpool.Pool) (int64, error) {
|
||||
if pool == nil {
|
||||
return 0, nil
|
||||
}
|
||||
tag, err := pool.Exec(ctx, `
|
||||
DELETE FROM ai_call_logs
|
||||
WHERE created_at < now() - ($1 || ' days')::interval`, RetentionDays)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return tag.RowsAffected(), nil
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package aiaudit
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/google/uuid"
|
||||
)
|
||||
|
||||
// Call metadata is attached in layers: the job sets company/user/job once, then
|
||||
// each product narrows it. A later WithCall must not wipe what an outer scope set.
|
||||
func TestWithCall_mergesWithoutClearing(t *testing.T) {
|
||||
t.Parallel()
|
||||
company, user, job, product := uuid.New(), uuid.New(), uuid.New(), uuid.New()
|
||||
|
||||
ctx := WithCall(context.Background(), CallContext{
|
||||
CompanyID: company, UserID: user, JobID: job, Role: RoleProcessing,
|
||||
})
|
||||
ctx = WithCall(ctx, CallContext{RawProductID: product})
|
||||
|
||||
got := FromContext(ctx)
|
||||
if got.CompanyID != company || got.UserID != user || got.JobID != job {
|
||||
t.Fatalf("outer scope lost: %+v", got)
|
||||
}
|
||||
if got.RawProductID != product {
|
||||
t.Fatalf("product not attached: %+v", got)
|
||||
}
|
||||
if got.Role != RoleProcessing {
|
||||
t.Fatalf("role lost: %q", got.Role)
|
||||
}
|
||||
|
||||
// A narrower scope may override the role (categorize inside a processing job).
|
||||
ctx = WithCall(ctx, CallContext{Role: RoleCategorize})
|
||||
if got := FromContext(ctx); got.Role != RoleCategorize || got.JobID != job {
|
||||
t.Fatalf("role override broke context: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromContext_zeroValueWhenAbsent(t *testing.T) {
|
||||
t.Parallel()
|
||||
if got := FromContext(context.Background()); got != (CallContext{}) {
|
||||
t.Fatalf("expected zero CallContext, got %+v", got)
|
||||
}
|
||||
//nolint:staticcheck // explicitly asserting the nil-ctx guard
|
||||
if got := FromContext(nil); got != (CallContext{}) {
|
||||
t.Fatalf("nil ctx must be safe, got %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Capture must never be able to take down the call it observes.
|
||||
func TestRecorder_nilIsNoOp(t *testing.T) {
|
||||
t.Parallel()
|
||||
var r *Recorder
|
||||
r.Record(context.Background(), Call{System: "x"}) // must not panic
|
||||
if got := NewRecorder(nil); got != nil {
|
||||
t.Fatal("nil pool must yield a nil (no-op) recorder")
|
||||
}
|
||||
}
|
||||
|
||||
// A runaway reply must not write an unbounded row.
|
||||
func TestClamp_boundsBody(t *testing.T) {
|
||||
t.Parallel()
|
||||
short := strings.Repeat("a", 10)
|
||||
if clamp(short) != short {
|
||||
t.Fatal("short bodies must pass through unchanged")
|
||||
}
|
||||
long := strings.Repeat("b", maxBodyRunes+500)
|
||||
got := clamp(long)
|
||||
if len([]rune(got)) <= maxBodyRunes {
|
||||
t.Fatalf("clamped body should keep the cap plus a marker, got %d runes", len([]rune(got)))
|
||||
}
|
||||
if !strings.HasSuffix(got, "[truncated by aiaudit]") {
|
||||
t.Fatal("clamped body must say it was truncated")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user