3.5 KiB
AI call inspector (Admin → AI calls)
Admin → AI calls (/admin/ai-calls, platform admin only) shows the exact
system prompt, user prompt and raw response of every LLM call, per tenant and per
user.
Before this existed the prompt was only ever in the worker log, truncated to 500
runes from each end, and processed_products.gpt_response kept response metadata
without the prompt at all — so "what exactly did we send for this job" had no
answer.
What is captured
Everything resolved through aiprovider.Service, tagged by role:
| role | where it comes from |
|---|---|
processing |
product enhance (title / description / meta / attrs) |
categorize |
taxonomy pick when the product has no category |
seo_meta |
standalone SEO meta generation |
campaign |
marketing email generation |
support |
support draft assist |
Each row carries company, the user who started the work, job id, raw product id, model, provider mode, finish reason, token counts, duration, and the provider error when the call failed.
How it works
internal/aiauditis a leaf package: context helpers plus the writer. Callers attach detail withaiaudit.WithCall(ctx, …);ProcessJobsets company/user/job once andprocessOnenarrows it to the product.aiproviderwraps every Completer it hands out (audit.go). One capture point, so a new caller cannot forget to log. The wrapper forwardsCompleterWithOptionswhen the client has it — otherwise every enhance call would silently loseMaxTokens/Temperature/ReasoningEffort.- Capture never breaks the call it observes: write failures are swallowed after one
log line, and the insert uses
context.WithoutCancelso a cancelled job still records the call that was in flight.
Retention
Rows are large and high volume — one per product per language, a few KB each. They are a debugging buffer, not an audit trail:
aiaudit.RetentionDays= 7.- The worker prunes on its retention tick (
aiaudit.CleanupExpired). - Each body is capped at 60k runes.
- Never build billing or reporting on this table.
For scale: a 25k-product job writes roughly 50–150 MB, which ages out within a week.
API
Platform admin only (RequirePlatformAdmin; 401 unauthenticated, 403 non-admin).
GET /api/admin/ai-calls?company_id=&user_id=&job_id=&raw_product_id=&role=&outcome=&q=&limit=&before=
GET /api/admin/ai-calls/{id}
The list returns lengths plus 240-rune previews — one unfiltered request must never
stream a tenant's whole prompt corpus. Full bodies come from the detail endpoint,
one row at a time. q searches system prompt, user prompt and response.
before is the RFC3339 cursor returned as next_before.
Reading a row
Start from the outcome, then the prompt:
outcome=failed— theerrorfield holds the provider error. If it is a dial or 5xx error the model never answered and enhance fell back to supplier copy.role=processingwith a shortuser_length— the category formula probably did not key. Check that the prompt containsGPT predloga:/Product template:and a<name>{block.- Response present but the product still looks like the feed — compare the response
against the formula sections; a reply that ignores them is rejected by the
formula gate and marked
synthesized/refused(see docs/category-formulas.md).
A job with no rows at all made no LLM calls: it was either hash-skipped
(ai_enhance outcome=skip reason=unchanged_hash), gated by plan/credits, or the
provider was unset.