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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user