update see calss
This commit is contained in:
@@ -0,0 +1,270 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/aiaudit"
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/google/uuid"
|
||||
"github.com/jackc/pgx/v5"
|
||||
)
|
||||
|
||||
// Admin AI inspector: the exact prompt and response of every LLM call.
|
||||
//
|
||||
// Platform-admin only. Prompts embed tenant product copy, so the list endpoint
|
||||
// returns previews and the full bodies are fetched one row at a time.
|
||||
|
||||
const (
|
||||
adminAICallsTimeout = 20 * time.Second
|
||||
adminAICallsDefaultLimit = 50
|
||||
adminAICallsMaxLimit = 200
|
||||
// adminAICallPreviewRunes bounds the list-view snippet of each body.
|
||||
adminAICallPreviewRunes = 240
|
||||
)
|
||||
|
||||
// handleAdminListAICalls lists captured calls, newest first.
|
||||
// GET /api/admin/ai-calls?company_id=&user_id=&job_id=&role=&outcome=&q=&limit=&before=
|
||||
func (s *Server) handleAdminListAICalls(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), adminAICallsTimeout)
|
||||
defer cancel()
|
||||
|
||||
q := r.URL.Query()
|
||||
where := []string{"TRUE"}
|
||||
args := []any{}
|
||||
add := func(clause string, val any) {
|
||||
args = append(args, val)
|
||||
where = append(where, strings.ReplaceAll(clause, "?", "$"+strconv.Itoa(len(args))))
|
||||
}
|
||||
|
||||
for _, f := range []struct {
|
||||
param string
|
||||
clause string
|
||||
}{
|
||||
{"company_id", "l.company_id = ?"},
|
||||
{"user_id", "l.user_id = ?"},
|
||||
{"job_id", "l.job_id = ?"},
|
||||
{"raw_product_id", "l.raw_product_id = ?"},
|
||||
} {
|
||||
raw := strings.TrimSpace(q.Get(f.param))
|
||||
if raw == "" {
|
||||
continue
|
||||
}
|
||||
id, err := uuid.Parse(raw)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid "+f.param)
|
||||
return
|
||||
}
|
||||
add(f.clause, id)
|
||||
}
|
||||
|
||||
if role := strings.TrimSpace(q.Get("role")); role != "" && role != "all" {
|
||||
add("l.role = ?", role)
|
||||
}
|
||||
switch strings.TrimSpace(q.Get("outcome")) {
|
||||
case "", "all":
|
||||
case "failed":
|
||||
where = append(where, "l.error <> ''")
|
||||
case "ok":
|
||||
where = append(where, "l.error = ''")
|
||||
default:
|
||||
Error(w, http.StatusBadRequest, "invalid outcome filter (all|ok|failed)")
|
||||
return
|
||||
}
|
||||
// Free-text search across the captured bodies — the point of the inspector is
|
||||
// answering "which call mentioned this", so it must cover prompt and response.
|
||||
if needle := strings.TrimSpace(q.Get("q")); needle != "" {
|
||||
args = append(args, "%"+needle+"%")
|
||||
i := strconv.Itoa(len(args))
|
||||
where = append(where, "(l.system_prompt ILIKE $"+i+" OR l.user_prompt ILIKE $"+i+" OR l.response_text ILIKE $"+i+")")
|
||||
}
|
||||
if before := strings.TrimSpace(q.Get("before")); before != "" {
|
||||
ts, err := time.Parse(time.RFC3339Nano, before)
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid before cursor (RFC3339)")
|
||||
return
|
||||
}
|
||||
add("l.created_at < ?", ts)
|
||||
}
|
||||
|
||||
limit := adminAICallsDefaultLimit
|
||||
if raw := strings.TrimSpace(q.Get("limit")); raw != "" {
|
||||
if n, err := strconv.Atoi(raw); err == nil && n > 0 {
|
||||
limit = n
|
||||
}
|
||||
}
|
||||
if limit > adminAICallsMaxLimit {
|
||||
limit = adminAICallsMaxLimit
|
||||
}
|
||||
args = append(args, limit)
|
||||
|
||||
rows, err := s.Pool.Query(ctx, adminAICallsListSQL(strings.Join(where, " AND "), len(args)), args...)
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "list ai calls failed")
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
items := make([]map[string]any, 0, limit)
|
||||
var oldest time.Time
|
||||
for rows.Next() {
|
||||
var id uuid.UUID
|
||||
var companyID, companyName, userID, userEmail, jobID, rawProductID string
|
||||
var role, providerMode, model, callErr, finishReason string
|
||||
var promptTokens, completionTokens, totalTokens, durationMS int
|
||||
var createdAt time.Time
|
||||
var sysLen, userLen, respLen int
|
||||
var userPreview, respPreview string
|
||||
if err := rows.Scan(&id, &companyID, &companyName, &userID, &userEmail,
|
||||
&jobID, &rawProductID, &role, &providerMode, &model, &callErr, &finishReason,
|
||||
&promptTokens, &completionTokens, &totalTokens, &durationMS, &createdAt,
|
||||
&sysLen, &userLen, &respLen, &userPreview, &respPreview); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "scan ai calls failed")
|
||||
return
|
||||
}
|
||||
oldest = createdAt
|
||||
outcome := "ok"
|
||||
if callErr != "" {
|
||||
outcome = "failed"
|
||||
}
|
||||
items = append(items, map[string]any{
|
||||
"id": id,
|
||||
"company_id": companyID,
|
||||
"company_name": companyName,
|
||||
"user_id": userID,
|
||||
"user_email": userEmail,
|
||||
"job_id": jobID,
|
||||
"raw_product_id": rawProductID,
|
||||
"role": role,
|
||||
"provider_mode": providerMode,
|
||||
"model": model,
|
||||
"outcome": outcome,
|
||||
"error": callErr,
|
||||
"finish_reason": finishReason,
|
||||
"prompt_tokens": promptTokens,
|
||||
"completion_tokens": completionTokens,
|
||||
"total_tokens": totalTokens,
|
||||
"duration_ms": durationMS,
|
||||
"created_at": createdAt,
|
||||
"system_length": sysLen,
|
||||
"user_length": userLen,
|
||||
"response_length": respLen,
|
||||
"user_preview": userPreview,
|
||||
"response_preview": respPreview,
|
||||
})
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
Error(w, http.StatusInternalServerError, "read ai calls failed")
|
||||
return
|
||||
}
|
||||
|
||||
out := map[string]any{
|
||||
"items": items,
|
||||
"limit": limit,
|
||||
"retention_days": aiaudit.RetentionDays,
|
||||
}
|
||||
// Cursor for the next page: callers pass it back as ?before=.
|
||||
if len(items) == limit && !oldest.IsZero() {
|
||||
out["next_before"] = oldest.Format(time.RFC3339Nano)
|
||||
}
|
||||
JSON(w, http.StatusOK, out)
|
||||
}
|
||||
|
||||
// adminAICallsListSQL builds the list query. Bodies are returned as lengths plus
|
||||
// left(...) previews only: one unfiltered request must never stream a tenant's
|
||||
// whole prompt corpus. Full bodies come from handleAdminGetAICall, one row at a time.
|
||||
func adminAICallsListSQL(where string, limitArg int) string {
|
||||
preview := strconv.Itoa(adminAICallPreviewRunes)
|
||||
return `
|
||||
SELECT l.id, COALESCE(l.company_id::text, ''), COALESCE(c.name, ''),
|
||||
COALESCE(l.user_id::text, ''), COALESCE(u.email, ''),
|
||||
COALESCE(l.job_id::text, ''), COALESCE(l.raw_product_id::text, ''),
|
||||
l.role, l.provider_mode, l.model, l.error, l.finish_reason,
|
||||
l.prompt_tokens, l.completion_tokens, l.total_tokens, l.duration_ms, l.created_at,
|
||||
length(l.system_prompt), length(l.user_prompt), length(l.response_text),
|
||||
left(l.user_prompt, ` + preview + `),
|
||||
left(l.response_text, ` + preview + `)
|
||||
FROM ai_call_logs l
|
||||
LEFT JOIN companies c ON c.id = l.company_id
|
||||
LEFT JOIN users u ON u.id = l.user_id
|
||||
WHERE ` + where + `
|
||||
ORDER BY l.created_at DESC
|
||||
LIMIT $` + strconv.Itoa(limitArg)
|
||||
}
|
||||
|
||||
// handleAdminGetAICall returns one captured call including the full prompt bodies.
|
||||
// GET /api/admin/ai-calls/{id}
|
||||
func (s *Server) handleAdminGetAICall(w http.ResponseWriter, r *http.Request) {
|
||||
ctx, cancel := context.WithTimeout(r.Context(), adminAICallsTimeout)
|
||||
defer cancel()
|
||||
|
||||
id, err := uuid.Parse(strings.TrimSpace(chi.URLParam(r, "id")))
|
||||
if err != nil {
|
||||
Error(w, http.StatusBadRequest, "invalid id")
|
||||
return
|
||||
}
|
||||
|
||||
var companyID, companyName, userID, userEmail, jobID, rawProductID string
|
||||
var role, providerMode, model, callErr, finishReason string
|
||||
var systemPrompt, userPrompt, responseText string
|
||||
var promptTokens, completionTokens, totalTokens, durationMS int
|
||||
var createdAt time.Time
|
||||
err = s.Pool.QueryRow(ctx, `
|
||||
SELECT COALESCE(l.company_id::text, ''), COALESCE(c.name, ''),
|
||||
COALESCE(l.user_id::text, ''), COALESCE(u.email, ''),
|
||||
COALESCE(l.job_id::text, ''), COALESCE(l.raw_product_id::text, ''),
|
||||
l.role, l.provider_mode, l.model, l.error, l.finish_reason,
|
||||
l.system_prompt, l.user_prompt, l.response_text,
|
||||
l.prompt_tokens, l.completion_tokens, l.total_tokens, l.duration_ms, l.created_at
|
||||
FROM ai_call_logs l
|
||||
LEFT JOIN companies c ON c.id = l.company_id
|
||||
LEFT JOIN users u ON u.id = l.user_id
|
||||
WHERE l.id = $1`, id).
|
||||
Scan(&companyID, &companyName, &userID, &userEmail, &jobID, &rawProductID,
|
||||
&role, &providerMode, &model, &callErr, &finishReason,
|
||||
&systemPrompt, &userPrompt, &responseText,
|
||||
&promptTokens, &completionTokens, &totalTokens, &durationMS, &createdAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
// Expired rows are the common case here, so say so rather than a bare 404.
|
||||
Error(w, http.StatusNotFound, "call not found (captured calls are kept for "+
|
||||
strconv.Itoa(aiaudit.RetentionDays)+" days)")
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
Error(w, http.StatusInternalServerError, "load ai call failed")
|
||||
return
|
||||
}
|
||||
|
||||
outcome := "ok"
|
||||
if callErr != "" {
|
||||
outcome = "failed"
|
||||
}
|
||||
JSON(w, http.StatusOK, map[string]any{
|
||||
"id": id,
|
||||
"company_id": companyID,
|
||||
"company_name": companyName,
|
||||
"user_id": userID,
|
||||
"user_email": userEmail,
|
||||
"job_id": jobID,
|
||||
"raw_product_id": rawProductID,
|
||||
"role": role,
|
||||
"provider_mode": providerMode,
|
||||
"model": model,
|
||||
"outcome": outcome,
|
||||
"error": callErr,
|
||||
"finish_reason": finishReason,
|
||||
"system_prompt": systemPrompt,
|
||||
"user_prompt": userPrompt,
|
||||
"response_text": responseText,
|
||||
"prompt_tokens": promptTokens,
|
||||
"completion_tokens": completionTokens,
|
||||
"total_tokens": totalTokens,
|
||||
"duration_ms": durationMS,
|
||||
"created_at": createdAt,
|
||||
"retention_days": aiaudit.RetentionDays,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func readSourceFile(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
b, err := os.ReadFile(name)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", name, err)
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
// Filter parsing must reject bad input before touching the database, so a typo in
|
||||
// the admin UI cannot turn into a full-table scan or a 500.
|
||||
func TestHandleAdminListAICalls_rejectsBadFilters(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{}
|
||||
cases := map[string]string{
|
||||
"invalid company_id": "?company_id=not-a-uuid",
|
||||
"invalid user_id": "?user_id=123",
|
||||
"invalid job_id": "?job_id=abc",
|
||||
"invalid raw_product_id": "?raw_product_id=xyz",
|
||||
"invalid outcome": "?outcome=maybe",
|
||||
"invalid before cursor": "?before=yesterday",
|
||||
}
|
||||
for name, query := range cases {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/ai-calls"+query, nil)
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleAdminListAICalls(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d want 400 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHandleAdminGetAICall_rejectsBadID(t *testing.T) {
|
||||
t.Parallel()
|
||||
s := &Server{}
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/admin/ai-calls/nope", nil)
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleAdminGetAICall(rec, req)
|
||||
if rec.Code != http.StatusBadRequest {
|
||||
t.Fatalf("status=%d want 400 body=%s", rec.Code, rec.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
// The list endpoint returns previews, never the full bodies — one unfiltered
|
||||
// request must not stream a tenant's whole prompt corpus out of the admin API.
|
||||
func TestAdminAICallsListSQL_selectsPreviewsNotFullBodies(t *testing.T) {
|
||||
t.Parallel()
|
||||
sql := adminAICallsListSQL("TRUE", 1)
|
||||
selectList := sql[strings.Index(sql, "SELECT"):strings.Index(sql, "FROM ai_call_logs")]
|
||||
|
||||
// A bare column followed by a comma is the whole body; the length()/left()
|
||||
// wrappers put a ")" in between.
|
||||
for _, bare := range []string{" l.system_prompt,", " l.user_prompt,", " l.response_text,"} {
|
||||
if strings.Contains(selectList, bare) {
|
||||
t.Fatalf("list selects a full body (%q):\n%s", strings.TrimSpace(bare), selectList)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{
|
||||
"left(l.user_prompt, 240)",
|
||||
"left(l.response_text, 240)",
|
||||
"length(l.system_prompt)",
|
||||
} {
|
||||
if !strings.Contains(selectList, want) {
|
||||
t.Fatalf("list should select %s:\n%s", want, selectList)
|
||||
}
|
||||
}
|
||||
if !strings.Contains(sql, "ORDER BY l.created_at DESC") || !strings.Contains(sql, "LIMIT $1") {
|
||||
t.Fatalf("list must page newest-first with a bound limit:\n%s", sql)
|
||||
}
|
||||
}
|
||||
|
||||
// The detail endpoint is the only place full bodies come from.
|
||||
func TestAdminAICallsDetail_returnsFullBodies(t *testing.T) {
|
||||
t.Parallel()
|
||||
src := readSourceFile(t, "admin_ai_calls_handlers.go")
|
||||
detail := src[strings.Index(src, "func (s *Server) handleAdminGetAICall"):]
|
||||
if !strings.Contains(detail, "l.system_prompt, l.user_prompt, l.response_text") {
|
||||
t.Fatal("detail handler must select the full bodies")
|
||||
}
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/alexedwards/scs/v2"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/aiaudit"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprompts"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/aiprovider"
|
||||
"github.com/descrybe/descrybe-v2/apps/api/internal/auth"
|
||||
@@ -123,6 +124,9 @@ func NewServer(
|
||||
ProcessingRPM: cfg.ProcessingRPM,
|
||||
ProcessingMaxRetries: cfg.ProcessingMaxRetries,
|
||||
})
|
||||
// Campaign / SEO / support AI calls run in this process; capture them for
|
||||
// /admin/ai-calls the same way the worker captures processing.
|
||||
aiSvc.WithAudit(aiaudit.NewRecorder(pool))
|
||||
promptSvc := aiprompts.NewService(pool)
|
||||
platformSettings := platformsettings.NewService(pool, platformsettings.EnvConfig{
|
||||
AppEncryptionKey: cfg.AppEncryptionKey,
|
||||
@@ -400,6 +404,8 @@ func (s *Server) Router() http.Handler {
|
||||
r.Post("/companies/{id}/fix-catalog", s.handleAdminFixCompanyCatalog) // compat alias → Sync A1
|
||||
r.Get("/readiness", s.handleAdminReadiness)
|
||||
r.Get("/diagnostics", s.handleAdminDiagnostics)
|
||||
r.Get("/ai-calls", s.handleAdminListAICalls)
|
||||
r.Get("/ai-calls/{id}", s.handleAdminGetAICall)
|
||||
r.Get("/analytics", s.handleAdminAnalytics)
|
||||
r.Get("/jobs", s.handleAdminListJobs)
|
||||
r.Post("/jobs/stuck-cleanup", s.handleAdminStuckCleanup)
|
||||
|
||||
Reference in New Issue
Block a user