update see calss

This commit is contained in:
2026-08-23 22:03:57 +02:00
parent f3d4fb56ed
commit 8983cfc8a1
32 changed files with 1884 additions and 22 deletions
@@ -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")
}
}
+6
View File
@@ -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)