306 lines
8.7 KiB
Go
306 lines
8.7 KiB
Go
package aiaudit
|
|||
|
|
|
||
|
|
import (
|
||
|
|
"context"
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"time"
|
||
|
|
|
||
|
|
"github.com/google/uuid"
|
||
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
||
|
|
)
|
||
|
|
|
||
|
|
// Recovering cost for products processed before capture existed.
|
||
|
|
//
|
||
|
|
// processed_products.gpt_response keeps the provider's usage block for some rows
|
||
|
|
// (model + prompt/completion tokens). That is enough to reconstruct spend per
|
||
|
|
// company and day, so history is not simply lost. It is explicitly best effort:
|
||
|
|
//
|
||
|
|
// - Only rows that still carry a usage block are recoverable. gpt_response is
|
||
|
|
// overwritten on every reprocess and absent on older/failed rows.
|
||
|
|
// - There is no per-user attribution: processed_products.user_id is null on these
|
||
|
|
// rows, so backfilled spend lands under "Unattributed" in the by-user view.
|
||
|
|
// - The day is processed_products.updated_at, which is the LAST write to the row,
|
||
|
|
// not necessarily when the call was made.
|
||
|
|
// - Cached-token discounts cannot be recovered (the field did not exist), so a
|
||
|
|
// backfilled call is priced as if none of its input was cached — an
|
||
|
|
// over-estimate, never an under-estimate.
|
||
|
|
//
|
||
|
|
// Rows are written with source='backfill' so they never collide with live capture
|
||
|
|
// and a re-run replaces rather than doubles them.
|
||
|
|
|
||
|
|
// BackfillResult reports what a backfill pass recovered.
|
||
|
|
type BackfillResult struct {
|
||
|
|
ProductsScanned int `json:"products_scanned"`
|
||
|
|
ProductsUsed int `json:"products_used"`
|
||
|
|
ProductsNoUsage int `json:"products_without_usage"`
|
||
|
|
CallsRecovered int `json:"calls_recovered"`
|
||
|
|
PromptTokens int64 `json:"prompt_tokens"`
|
||
|
|
CompletionTokens int64 `json:"completion_tokens"`
|
||
|
|
CostMicros int64 `json:"cost_micros"`
|
||
|
|
RowsWritten int `json:"rows_written"`
|
||
|
|
RowsDeleted int64 `json:"rows_deleted"`
|
||
|
|
Cutoff string `json:"cutoff"`
|
||
|
|
DryRun bool `json:"dry_run"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// CostUSD renders the recovered spend for CLI output.
|
||
|
|
func (r BackfillResult) CostUSD() float64 {
|
||
|
|
return float64(r.CostMicros) / float64(MicrosPerUSD)
|
||
|
|
}
|
||
|
|
|
||
|
|
type backfillKey struct {
|
||
|
|
day time.Time
|
||
|
|
companyID uuid.UUID
|
||
|
|
model string
|
||
|
|
role string
|
||
|
|
}
|
||
|
|
|
||
|
|
type backfillAgg struct {
|
||
|
|
calls int
|
||
|
|
promptTokens int64
|
||
|
|
completionTokens int64
|
||
|
|
totalTokens int64
|
||
|
|
costMicros int64
|
||
|
|
}
|
||
|
|
|
||
|
|
// LiveCaptureStart returns the earliest day live capture recorded, or zero when
|
||
|
|
// nothing has been captured yet. The backfill uses it as a default cutoff so a
|
||
|
|
// product processed after capture began is never counted twice.
|
||
|
|
func LiveCaptureStart(ctx context.Context, pool *pgxpool.Pool) (time.Time, error) {
|
||
|
|
if pool == nil {
|
||
|
|
return time.Time{}, nil
|
||
|
|
}
|
||
|
|
var day *time.Time
|
||
|
|
err := pool.QueryRow(ctx, `
|
||
|
|
SELECT min(day) FROM ai_usage_daily WHERE source = $1`, SourceLive).Scan(&day)
|
||
|
|
if err != nil {
|
||
|
|
return time.Time{}, err
|
||
|
|
}
|
||
|
|
if day == nil {
|
||
|
|
return time.Time{}, nil
|
||
|
|
}
|
||
|
|
return *day, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// BackfillFromProcessedProducts reconstructs cost history from stored provider
|
||
|
|
// responses for products last written before cutoff. Passing a zero cutoff scans
|
||
|
|
// everything, which is only safe when live capture has recorded nothing yet.
|
||
|
|
func BackfillFromProcessedProducts(ctx context.Context, pool *pgxpool.Pool, cutoff time.Time, dryRun bool) (BackfillResult, error) {
|
||
|
|
out := BackfillResult{DryRun: dryRun}
|
||
|
|
if pool == nil {
|
||
|
|
return out, fmt.Errorf("nil pool")
|
||
|
|
}
|
||
|
|
if cutoff.IsZero() {
|
||
|
|
// Far future = no bound.
|
||
|
|
cutoff = time.Now().UTC().AddDate(100, 0, 0)
|
||
|
|
}
|
||
|
|
out.Cutoff = cutoff.Format("2006-01-02")
|
||
|
|
|
||
|
|
prices, err := loadPrices(ctx, pool)
|
||
|
|
if err != nil {
|
||
|
|
return out, err
|
||
|
|
}
|
||
|
|
|
||
|
|
rows, err := pool.Query(ctx, `
|
||
|
|
SELECT company_id, updated_at, COALESCE(gpt_response, '{}'::jsonb)
|
||
|
|
FROM processed_products
|
||
|
|
WHERE gpt_response IS NOT NULL
|
||
|
|
AND updated_at < $1
|
||
|
|
ORDER BY updated_at`, cutoff)
|
||
|
|
if err != nil {
|
||
|
|
return out, err
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
|
||
|
|
agg := map[backfillKey]*backfillAgg{}
|
||
|
|
for rows.Next() {
|
||
|
|
var companyID uuid.UUID
|
||
|
|
var updatedAt time.Time
|
||
|
|
var raw []byte
|
||
|
|
if err := rows.Scan(&companyID, &updatedAt, &raw); err != nil {
|
||
|
|
return out, err
|
||
|
|
}
|
||
|
|
out.ProductsScanned++
|
||
|
|
calls := extractUsageCalls(raw)
|
||
|
|
if len(calls) == 0 {
|
||
|
|
out.ProductsNoUsage++
|
||
|
|
continue
|
||
|
|
}
|
||
|
|
out.ProductsUsed++
|
||
|
|
day := updatedAt.UTC().Truncate(24 * time.Hour)
|
||
|
|
for _, c := range calls {
|
||
|
|
price := priceFromMap(prices, c.Model)
|
||
|
|
// Cached tokens are unknown for history — price all input as fresh.
|
||
|
|
cost := CostMicros(price, c.PromptTokens, 0, c.CompletionTokens)
|
||
|
|
k := backfillKey{day: day, companyID: companyID, model: NormalizeModel(c.Model), role: c.Role}
|
||
|
|
a := agg[k]
|
||
|
|
if a == nil {
|
||
|
|
a = &backfillAgg{}
|
||
|
|
agg[k] = a
|
||
|
|
}
|
||
|
|
a.calls++
|
||
|
|
a.promptTokens += int64(c.PromptTokens)
|
||
|
|
a.completionTokens += int64(c.CompletionTokens)
|
||
|
|
a.totalTokens += int64(c.TotalTokens)
|
||
|
|
a.costMicros += cost
|
||
|
|
|
||
|
|
out.CallsRecovered++
|
||
|
|
out.PromptTokens += int64(c.PromptTokens)
|
||
|
|
out.CompletionTokens += int64(c.CompletionTokens)
|
||
|
|
out.CostMicros += cost
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if err := rows.Err(); err != nil {
|
||
|
|
return out, err
|
||
|
|
}
|
||
|
|
out.RowsWritten = len(agg)
|
||
|
|
if dryRun {
|
||
|
|
return out, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
tx, err := pool.Begin(ctx)
|
||
|
|
if err != nil {
|
||
|
|
return out, err
|
||
|
|
}
|
||
|
|
defer func() { _ = tx.Rollback(ctx) }()
|
||
|
|
|
||
|
|
// Replace rather than add: a re-run must not double the recovered history.
|
||
|
|
tag, err := tx.Exec(ctx, `DELETE FROM ai_usage_daily WHERE source = $1`, SourceBackfill)
|
||
|
|
if err != nil {
|
||
|
|
return out, err
|
||
|
|
}
|
||
|
|
out.RowsDeleted = tag.RowsAffected()
|
||
|
|
|
||
|
|
for k, a := range agg {
|
||
|
|
if _, err := tx.Exec(ctx, `
|
||
|
|
INSERT INTO ai_usage_daily (
|
||
|
|
day, company_id, user_id, model, role, source,
|
||
|
|
calls, failed_calls, prompt_tokens, cached_prompt_tokens,
|
||
|
|
completion_tokens, total_tokens, cost_micros, updated_at)
|
||
|
|
VALUES ($1, $2, '00000000-0000-0000-0000-000000000000', $3, $4, $5,
|
||
|
|
$6, 0, $7, 0, $8, $9, $10, now())`,
|
||
|
|
k.day, k.companyID, k.model, k.role, SourceBackfill,
|
||
|
|
a.calls, a.promptTokens, a.completionTokens, a.totalTokens, a.costMicros); err != nil {
|
||
|
|
return out, err
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if err := tx.Commit(ctx); err != nil {
|
||
|
|
return out, err
|
||
|
|
}
|
||
|
|
return out, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
func loadPrices(ctx context.Context, pool *pgxpool.Pool) (map[string]ModelPrice, error) {
|
||
|
|
out := map[string]ModelPrice{}
|
||
|
|
rows, err := pool.Query(ctx, `
|
||
|
|
SELECT model, input_micros_per_mtok, cached_input_micros_per_mtok, output_micros_per_mtok
|
||
|
|
FROM ai_model_prices`)
|
||
|
|
if err != nil {
|
||
|
|
return out, err
|
||
|
|
}
|
||
|
|
defer rows.Close()
|
||
|
|
for rows.Next() {
|
||
|
|
var model string
|
||
|
|
var in, cached, o int64
|
||
|
|
if err := rows.Scan(&model, &in, &cached, &o); err != nil {
|
||
|
|
return out, err
|
||
|
|
}
|
||
|
|
out[NormalizeModel(model)] = ModelPrice{InputPerMTok: in, CachedInputPerMTok: cached, OutputPerMTok: o}
|
||
|
|
}
|
||
|
|
return out, rows.Err()
|
||
|
|
}
|
||
|
|
|
||
|
|
func priceFromMap(prices map[string]ModelPrice, model string) ModelPrice {
|
||
|
|
key := NormalizeModel(model)
|
||
|
|
if p, ok := prices[key]; ok {
|
||
|
|
return p
|
||
|
|
}
|
||
|
|
if p, ok := DefaultModelPrice(key); ok {
|
||
|
|
return p
|
||
|
|
}
|
||
|
|
return ModelPrice{}
|
||
|
|
}
|
||
|
|
|
||
|
|
// recoveredCall is one provider response found inside gpt_response.
|
||
|
|
type recoveredCall struct {
|
||
|
|
Model string
|
||
|
|
Role string
|
||
|
|
PromptTokens int
|
||
|
|
CompletionTokens int
|
||
|
|
TotalTokens int
|
||
|
|
}
|
||
|
|
|
||
|
|
// extractUsageCalls walks a stored gpt_response and returns every provider usage
|
||
|
|
// block it holds. The shape varies by pipeline version, so this looks for any
|
||
|
|
// object with a "usage" child rather than assuming one fixed path — older rows
|
||
|
|
// nest it differently and a rigid path would silently recover nothing.
|
||
|
|
func extractUsageCalls(raw []byte) []recoveredCall {
|
||
|
|
var doc any
|
||
|
|
if err := json.Unmarshal(raw, &doc); err != nil {
|
||
|
|
return nil
|
||
|
|
}
|
||
|
|
var out []recoveredCall
|
||
|
|
walkUsage(doc, "", &out)
|
||
|
|
return out
|
||
|
|
}
|
||
|
|
|
||
|
|
func walkUsage(node any, stepHint string, out *[]recoveredCall) {
|
||
|
|
switch v := node.(type) {
|
||
|
|
case map[string]any:
|
||
|
|
if s, ok := v["step"].(string); ok && s != "" {
|
||
|
|
stepHint = s
|
||
|
|
}
|
||
|
|
if usage, ok := v["usage"].(map[string]any); ok {
|
||
|
|
call := recoveredCall{
|
||
|
|
Model: stringField(v, "model"),
|
||
|
|
Role: roleFromStep(stepHint),
|
||
|
|
PromptTokens: intField(usage, "prompt_tokens"),
|
||
|
|
CompletionTokens: intField(usage, "completion_tokens"),
|
||
|
|
TotalTokens: intField(usage, "total_tokens"),
|
||
|
|
}
|
||
|
|
if call.PromptTokens > 0 || call.CompletionTokens > 0 {
|
||
|
|
*out = append(*out, call)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
for _, child := range v {
|
||
|
|
walkUsage(child, stepHint, out)
|
||
|
|
}
|
||
|
|
case []any:
|
||
|
|
for _, child := range v {
|
||
|
|
walkUsage(child, stepHint, out)
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func roleFromStep(step string) string {
|
||
|
|
switch step {
|
||
|
|
case "categorize":
|
||
|
|
return RoleCategorize
|
||
|
|
case "ai_enhance", "":
|
||
|
|
return RoleProcessing
|
||
|
|
default:
|
||
|
|
return RoleProcessing
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
func stringField(m map[string]any, key string) string {
|
||
|
|
if s, ok := m[key].(string); ok {
|
||
|
|
return s
|
||
|
|
}
|
||
|
|
return ""
|
||
|
|
}
|
||
|
|
|
||
|
|
func intField(m map[string]any, key string) int {
|
||
|
|
switch v := m[key].(type) {
|
||
|
|
case float64:
|
||
|
|
return int(v)
|
||
|
|
case int:
|
||
|
|
return v
|
||
|
|
case json.Number:
|
||
|
|
n, _ := v.Int64()
|
||
|
|
return int(n)
|
||
|
|
}
|
||
|
|
return 0
|
||
|
|
}
|