Files

271 lines
9.0 KiB
Go
Raw Permalink Normal View History

2026-08-23 22:03:57 +02:00
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,
})
}