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, }) }