271 lines
9.0 KiB
Go
271 lines
9.0 KiB
Go
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,
|
|
})
|
|
}
|