fix(growth): harden AI jobs and cost controls
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"math/big"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -60,17 +61,19 @@ func (c *AvalAIClient) GenerateWithTools(ctx context.Context, evidence json.RawM
|
||||
"text": map[string]any{"format": map[string]any{"type": "json_schema", "name": "growth_copy", "strict": true, "schema": aiCopySchema()}},
|
||||
}
|
||||
var last error
|
||||
var total Usage
|
||||
for attempt := 0; attempt < 2; attempt++ {
|
||||
copy, usage, transient, err := c.workflow(ctx, base, evidence, known, executor)
|
||||
total = mergeUsage(total, usage)
|
||||
if err == nil {
|
||||
return copy, usage, nil
|
||||
return copy, total, nil
|
||||
}
|
||||
last = err
|
||||
if !transient || ctx.Err() != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
return AICopy{}, Usage{}, last
|
||||
return AICopy{}, total, last
|
||||
}
|
||||
|
||||
type responseEnvelope struct {
|
||||
@@ -99,9 +102,9 @@ type outputItem struct {
|
||||
func (c *AvalAIClient) workflow(ctx context.Context, base map[string]any, evidence json.RawMessage, known map[string]struct{}, executor GrowthAnalyticsTools) (AICopy, Usage, bool, error) {
|
||||
firstBody := cloneRequest(base)
|
||||
firstBody["input"] = string(evidence)
|
||||
first, latency, transient, err := c.do(ctx, firstBody)
|
||||
first, usage, transient, err := c.do(ctx, firstBody)
|
||||
if err != nil {
|
||||
return AICopy{}, Usage{}, transient, err
|
||||
return AICopy{}, usage, transient, err
|
||||
}
|
||||
calls := make([]outputItem, 0, 4)
|
||||
for _, raw := range first.Output {
|
||||
@@ -110,9 +113,6 @@ func (c *AvalAIClient) workflow(ctx context.Context, base map[string]any, eviden
|
||||
calls = append(calls, item)
|
||||
}
|
||||
}
|
||||
usage := Usage{InputTokens: first.Usage.InputTokens, OutputTokens: first.Usage.OutputTokens, LatencyMS: latency}
|
||||
usage.ProviderRequestID = first.ProviderRequestID
|
||||
usage.EstimatedCost = costString(first.EstimatedCost)
|
||||
final := first
|
||||
if len(calls) > 0 {
|
||||
if executor == nil {
|
||||
@@ -141,19 +141,11 @@ func (c *AvalAIClient) workflow(ctx context.Context, base map[string]any, eviden
|
||||
}
|
||||
secondBody := cloneRequest(base)
|
||||
secondBody["input"] = input
|
||||
second, secondLatency, secondTransient, err := c.do(ctx, secondBody)
|
||||
second, secondUsage, secondTransient, err := c.do(ctx, secondBody)
|
||||
usage = mergeUsage(usage, secondUsage)
|
||||
if err != nil {
|
||||
return AICopy{}, usage, secondTransient, err
|
||||
}
|
||||
usage.InputTokens += second.Usage.InputTokens
|
||||
usage.OutputTokens += second.Usage.OutputTokens
|
||||
usage.LatencyMS += secondLatency
|
||||
if second.ProviderRequestID != "" {
|
||||
usage.ProviderRequestID = second.ProviderRequestID
|
||||
}
|
||||
if cost := costString(second.EstimatedCost); cost != "" {
|
||||
usage.EstimatedCost = cost
|
||||
}
|
||||
final = second
|
||||
for _, raw := range second.Output {
|
||||
var item outputItem
|
||||
@@ -169,44 +161,76 @@ func (c *AvalAIClient) workflow(ctx context.Context, base map[string]any, eviden
|
||||
return copy, usage, false, nil
|
||||
}
|
||||
|
||||
func (c *AvalAIClient) do(parent context.Context, bodyValue map[string]any) (responseEnvelope, int64, bool, error) {
|
||||
func (c *AvalAIClient) do(parent context.Context, bodyValue map[string]any) (responseEnvelope, Usage, bool, error) {
|
||||
body, err := json.Marshal(bodyValue)
|
||||
if err != nil {
|
||||
return responseEnvelope{}, 0, false, fmt.Errorf("encoding AvalAI request: %w", err)
|
||||
return responseEnvelope{}, Usage{}, false, fmt.Errorf("encoding AvalAI request: %w", err)
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(parent, c.config.Timeout)
|
||||
defer cancel()
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(c.config.BaseURL, "/")+"/v1/responses", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return responseEnvelope{}, 0, false, fmt.Errorf("creating AvalAI request: %w", err)
|
||||
return responseEnvelope{}, Usage{}, false, fmt.Errorf("creating AvalAI request: %w", err)
|
||||
}
|
||||
req.Header.Set("Authorization", "Bearer "+c.config.APIKey)
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
started := time.Now()
|
||||
resp, err := c.client.Do(req)
|
||||
if err != nil {
|
||||
return responseEnvelope{}, 0, true, fmt.Errorf("calling AvalAI: %w", err)
|
||||
return responseEnvelope{}, Usage{ProviderRequests: 1, LatencyMS: time.Since(started).Milliseconds()}, true, fmt.Errorf("calling AvalAI: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
responseBody, err := io.ReadAll(io.LimitReader(resp.Body, maxAvalAIResponseBytes+1))
|
||||
if err != nil {
|
||||
return responseEnvelope{}, 0, true, fmt.Errorf("reading AvalAI response: %w", err)
|
||||
return responseEnvelope{}, Usage{ProviderRequests: 1, LatencyMS: time.Since(started).Milliseconds(), ProviderRequestID: resp.Header.Get("X-Request-ID")}, true, fmt.Errorf("reading AvalAI response: %w", err)
|
||||
}
|
||||
if len(responseBody) > maxAvalAIResponseBytes {
|
||||
return responseEnvelope{}, 0, false, errors.New("avalai: response too large")
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return responseEnvelope{}, 0, resp.StatusCode == 429 || resp.StatusCode >= 500, fmt.Errorf("avalai: HTTP %d", resp.StatusCode)
|
||||
return responseEnvelope{}, Usage{ProviderRequests: 1, LatencyMS: time.Since(started).Milliseconds(), ProviderRequestID: resp.Header.Get("X-Request-ID")}, false, errors.New("avalai: response too large")
|
||||
}
|
||||
var result responseEnvelope
|
||||
if err := json.Unmarshal(responseBody, &result); err != nil {
|
||||
return responseEnvelope{}, 0, false, fmt.Errorf("decoding AvalAI response: %w", err)
|
||||
decodeErr := json.Unmarshal(responseBody, &result)
|
||||
result.ProviderRequestID = resp.Header.Get("X-Request-ID")
|
||||
usage := Usage{InputTokens: result.Usage.InputTokens, OutputTokens: result.Usage.OutputTokens, LatencyMS: time.Since(started).Milliseconds(), ProviderRequests: 1, ProviderRequestID: result.ProviderRequestID, EstimatedCost: costString(result.EstimatedCost)}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return result, usage, resp.StatusCode == 429 || resp.StatusCode >= 500, fmt.Errorf("avalai: HTTP %d", resp.StatusCode)
|
||||
}
|
||||
if decodeErr != nil {
|
||||
return responseEnvelope{}, usage, false, fmt.Errorf("decoding AvalAI response: %w", decodeErr)
|
||||
}
|
||||
if result.Status != "completed" {
|
||||
return responseEnvelope{}, 0, false, fmt.Errorf("avalai: incomplete response status %q", result.Status)
|
||||
return result, usage, false, fmt.Errorf("avalai: incomplete response status %q", result.Status)
|
||||
}
|
||||
result.ProviderRequestID = resp.Header.Get("X-Request-ID")
|
||||
return result, time.Since(started).Milliseconds(), false, nil
|
||||
return result, usage, false, nil
|
||||
}
|
||||
|
||||
func mergeUsage(total, next Usage) Usage {
|
||||
total.InputTokens += next.InputTokens
|
||||
total.OutputTokens += next.OutputTokens
|
||||
total.LatencyMS += next.LatencyMS
|
||||
total.ProviderRequests += next.ProviderRequests
|
||||
total.ReservedTokens += next.ReservedTokens
|
||||
if next.ProviderRequestID != "" {
|
||||
total.ProviderRequestID = next.ProviderRequestID
|
||||
}
|
||||
total.EstimatedCost = addDecimalStrings(total.EstimatedCost, next.EstimatedCost)
|
||||
return total
|
||||
}
|
||||
|
||||
func addDecimalStrings(left, right string) string {
|
||||
if left == "" {
|
||||
return right
|
||||
}
|
||||
if right == "" {
|
||||
return left
|
||||
}
|
||||
a, okA := new(big.Rat).SetString(left)
|
||||
b, okB := new(big.Rat).SetString(right)
|
||||
if !okA || !okB {
|
||||
return left
|
||||
}
|
||||
text := new(big.Rat).Add(a, b).FloatString(12)
|
||||
text = strings.TrimRight(strings.TrimRight(text, "0"), ".")
|
||||
return text
|
||||
}
|
||||
|
||||
func costString(raw json.RawMessage) string {
|
||||
@@ -287,6 +311,9 @@ func ValidateAICopy(copy AICopy, known map[string]struct{}) error {
|
||||
return errors.New("avalai: numeric claim rejected")
|
||||
}
|
||||
}
|
||||
if containsQuantityWord(field) {
|
||||
return errors.New("avalai: written numeric claim rejected")
|
||||
}
|
||||
lower := strings.ToLower(field)
|
||||
for _, forbidden := range []string{"payer", "card", "رمز", "شماره کارت", "sql", "merchant_id"} {
|
||||
if strings.Contains(lower, forbidden) {
|
||||
@@ -306,6 +333,20 @@ func ValidateAICopy(copy AICopy, known map[string]struct{}) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func containsQuantityWord(value string) bool {
|
||||
quantityWords := map[string]struct{}{
|
||||
"zero": {}, "one": {}, "two": {}, "three": {}, "four": {}, "five": {}, "six": {}, "seven": {}, "eight": {}, "nine": {}, "ten": {}, "eleven": {}, "twelve": {}, "thirteen": {}, "fourteen": {}, "fifteen": {}, "sixteen": {}, "seventeen": {}, "eighteen": {}, "nineteen": {}, "twenty": {}, "thirty": {}, "forty": {}, "fifty": {}, "sixty": {}, "seventy": {}, "eighty": {}, "ninety": {}, "hundred": {}, "thousand": {}, "million": {}, "billion": {}, "percent": {}, "percentage": {}, "double": {}, "triple": {}, "half": {},
|
||||
"صفر": {}, "یک": {}, "دو": {}, "سه": {}, "چهار": {}, "پنج": {}, "شش": {}, "هفت": {}, "هشت": {}, "نه": {}, "ده": {}, "یازده": {}, "دوازده": {}, "سیزده": {}, "چهارده": {}, "پانزده": {}, "شانزده": {}, "هفده": {}, "هجده": {}, "نوزده": {}, "بیست": {}, "سی": {}, "چهل": {}, "پنجاه": {}, "شصت": {}, "هفتاد": {}, "هشتاد": {}, "نود": {}, "صد": {}, "هزار": {}, "میلیون": {}, "میلیارد": {}, "درصد": {}, "برابر": {}, "نصف": {},
|
||||
}
|
||||
for _, word := range strings.Fields(strings.ToLower(value)) {
|
||||
word = strings.Trim(word, ".,;:!?،؛؟٪%()[]{}«»\"'")
|
||||
if _, found := quantityWords[word]; found {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func growthTools() []map[string]any {
|
||||
names := []string{"get_growth_context", "get_event_evidence", "get_peer_context", "get_campaign_playbook"}
|
||||
tools := make([]map[string]any, 0, len(names))
|
||||
|
||||
Reference in New Issue
Block a user