Files
zarinpal-analytics-backend/internal/growthreport/avalai.go

111 lines
6.8 KiB
Go

package growthreport
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"time"
"unicode"
)
const maxAvalAIResponseBytes = 1 << 20
type AvalAIConfig struct {
BaseURL string
APIKey string
Model string
Timeout time.Duration
}
type AvalAIClient struct { config AvalAIConfig; client *http.Client }
func NewAvalAIClient(config AvalAIConfig, client *http.Client) *AvalAIClient {
if config.BaseURL == "" { config.BaseURL = "https://api.avalai.ir" }
if config.Timeout <= 0 || config.Timeout > 30*time.Second { config.Timeout = 30*time.Second }
if client == nil { client = &http.Client{} }
return &AvalAIClient{config: config, client: client}
}
func (c *AvalAIClient) Generate(ctx context.Context, evidence json.RawMessage, known map[string]struct{}) (AICopy, Usage, error) {
if c.config.APIKey == "" || c.config.Model == "" { return AICopy{}, Usage{}, errors.New("avalai: disabled") }
body := map[string]any{
"model": c.config.Model, "store": false, "parallel_tool_calls": false,
"max_output_tokens": 1600, "max_tool_calls": 4,
"instructions": "شما تحلیل‌گر رشد فارسی هستید. فقط بر اساس شواهد ارائه‌شده پیشنهاد کیفی بدهید. هیچ عدد، تاریخ یا ادعای تازه‌ای ننویسید. شناسه شواهد را دقیق ارجاع دهید.",
"input": string(evidence), "tools": growthTools(),
"text": map[string]any{"format": map[string]any{"type":"json_schema", "name":"growth_copy", "strict":true, "schema": aiCopySchema()}},
}
encoded, err := json.Marshal(body); if err != nil { return AICopy{}, Usage{}, fmt.Errorf("encoding AvalAI request: %w", err) }
var last error
for attempt := 0; attempt < 2; attempt++ {
copy, usage, transient, err := c.do(ctx, encoded, known)
if err == nil { return copy, usage, nil }
last = err
if !transient || ctx.Err() != nil { break }
}
return AICopy{}, Usage{}, last
}
func (c *AvalAIClient) do(parent context.Context, body []byte, known map[string]struct{}) (AICopy, Usage, bool, error) {
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 AICopy{}, 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 AICopy{}, Usage{}, 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 AICopy{}, Usage{}, true, fmt.Errorf("reading AvalAI response: %w", err) }
if len(responseBody) > maxAvalAIResponseBytes { return AICopy{}, Usage{}, false, errors.New("avalai: response too large") }
if resp.StatusCode < 200 || resp.StatusCode >= 300 { return AICopy{}, Usage{}, resp.StatusCode == 429 || resp.StatusCode >= 500, fmt.Errorf("avalai: HTTP %d", resp.StatusCode) }
var result struct {
Status string `json:"status"`
Output []struct { Type string `json:"type"`; Content []struct { Type string `json:"type"`; Text string `json:"text"`; Refusal string `json:"refusal"` } `json:"content"` } `json:"output"`
Usage struct { InputTokens int64 `json:"input_tokens"`; OutputTokens int64 `json:"output_tokens"` } `json:"usage"`
}
if err := json.Unmarshal(responseBody, &result); err != nil { return AICopy{}, Usage{}, false, fmt.Errorf("decoding AvalAI response: %w", err) }
if result.Status != "completed" { return AICopy{}, Usage{}, false, fmt.Errorf("avalai: incomplete response status %q", result.Status) }
var text string
for _, item := range result.Output { if item.Type == "message" { for _, content := range item.Content { if content.Refusal != "" { return AICopy{}, Usage{}, false, errors.New("avalai: refused") }; if content.Type == "output_text" { text += content.Text } } } }
var copy AICopy
if text == "" || json.Unmarshal([]byte(text), &copy) != nil { return AICopy{}, Usage{}, false, errors.New("avalai: invalid structured output") }
if err := ValidateAICopy(copy, known); err != nil { return AICopy{}, Usage{}, false, err }
return copy, Usage{InputTokens: result.Usage.InputTokens, OutputTokens: result.Usage.OutputTokens, LatencyMS: time.Since(started).Milliseconds()}, false, nil
}
func ValidateAICopy(copy AICopy, known map[string]struct{}) error {
if len(copy.Briefs) == 0 || len(copy.Briefs) > 5 { return errors.New("avalai: invalid brief count") }
for _, brief := range copy.Briefs {
fields := []string{brief.Headline, brief.Interpretation, brief.OfferSuggestion, brief.MessageSuggestion, brief.PreparationAction, brief.Limitation}
for _, field := range fields {
if len([]rune(field)) > 600 { return errors.New("avalai: field too long") }
for _, r := range field { if unicode.IsDigit(r) { return errors.New("avalai: numeric claim rejected") } }
lower := strings.ToLower(field)
for _, forbidden := range []string{"payer", "card", "رمز", "شماره کارت", "sql", "merchant_id"} { if strings.Contains(lower, forbidden) { return errors.New("avalai: unsafe content rejected") } }
}
if brief.Headline == "" || len(brief.EvidenceIDs) == 0 { return errors.New("avalai: ungrounded brief") }
for _, id := range brief.EvidenceIDs { if _, ok := known[id]; !ok { return errors.New("avalai: unknown evidence reference") } }
}
return nil
}
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))
for _, name := range names { tools = append(tools, map[string]any{"type":"function","name":name,"description":"Return approved sanitized deterministic evidence for this authenticated report scope.","strict":true,"parameters":map[string]any{"type":"object","properties":map[string]any{"evidenceId":map[string]any{"type":"string"}},"required":[]string{"evidenceId"},"additionalProperties":false}}) }
return tools
}
func aiCopySchema() map[string]any {
briefProperties := map[string]any{}
for _, name := range []string{"headline","interpretation","offerSuggestion","messageSuggestion","preparationAction","limitation"} { briefProperties[name] = map[string]any{"type":"string"} }
briefProperties["evidenceIds"] = map[string]any{"type":"array","items":map[string]any{"type":"string"},"minItems":1}
return map[string]any{"type":"object","properties":map[string]any{"briefs":map[string]any{"type":"array","maxItems":5,"items":map[string]any{"type":"object","properties":briefProperties,"required":[]string{"headline","interpretation","offerSuggestion","messageSuggestion","preparationAction","limitation","evidenceIds"},"additionalProperties":false}}},"required":[]string{"briefs"},"additionalProperties":false}
}