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

366 lines
13 KiB
Go

package growthreport
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"math/big"
"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
}
type GrowthAnalyticsTools interface {
Execute(ctx context.Context, name string, arguments json.RawMessage) (json.RawMessage, error)
}
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{}
}
config.BaseURL = strings.TrimSuffix(strings.TrimRight(config.BaseURL, "/"), "/v1")
return &AvalAIClient{config: config, client: client}
}
func (c *AvalAIClient) Generate(ctx context.Context, evidence json.RawMessage, known map[string]struct{}) (AICopy, Usage, error) {
return c.GenerateWithTools(ctx, evidence, known, nil)
}
func (c *AvalAIClient) GenerateWithTools(ctx context.Context, evidence json.RawMessage, known map[string]struct{}, executor GrowthAnalyticsTools) (AICopy, Usage, error) {
if c.config.APIKey == "" || c.config.Model == "" {
return AICopy{}, Usage{}, errors.New("avalai: disabled")
}
base := map[string]any{
"model": c.config.Model, "store": false, "parallel_tool_calls": false,
"max_output_tokens": 1600, "max_tool_calls": 4,
"instructions": "شما تحلیل‌گر رشد فارسی هستید. فقط از رویدادهای بررسی‌شده در شواهد ارائه‌شده، دقیقاً سه رویداد متفاوت را به‌ترتیب ارتباط با کسب‌وکار انتخاب کنید. برای هر رویداد یک دلیل کوتاه فارسی و کیفی بنویسید. هیچ عدد، تاریخ یا ادعای تازه‌ای ننویسید و شناسه شواهد را دقیق ارجاع دهید.",
"tools": growthTools(),
"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, total, nil
}
last = err
if !transient || ctx.Err() != nil {
break
}
}
return AICopy{}, total, last
}
type responseEnvelope struct {
Status string `json:"status"`
Output []json.RawMessage `json:"output"`
Usage struct {
InputTokens int64 `json:"input_tokens"`
OutputTokens int64 `json:"output_tokens"`
} `json:"usage"`
EstimatedCost json.RawMessage `json:"estimated_cost"`
ProviderRequestID string `json:"-"`
}
type outputItem struct {
Type string `json:"type"`
CallID string `json:"call_id"`
Name string `json:"name"`
Arguments json.RawMessage `json:"arguments"`
Content []struct {
Type string `json:"type"`
Text string `json:"text"`
Refusal string `json:"refusal"`
} `json:"content"`
}
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, usage, transient, err := c.do(ctx, firstBody)
if err != nil {
return AICopy{}, usage, transient, err
}
calls := make([]outputItem, 0, 4)
for _, raw := range first.Output {
var item outputItem
if json.Unmarshal(raw, &item) == nil && item.Type == "function_call" {
calls = append(calls, item)
}
}
final := first
if len(calls) > 0 {
if executor == nil {
return AICopy{}, usage, false, errors.New("avalai: unexpected tool call")
}
if len(calls) > 4 {
return AICopy{}, usage, false, errors.New("avalai: tool call limit exceeded")
}
input := make([]any, 0, len(first.Output)+len(calls)+1)
input = append(input, map[string]any{"role": "user", "content": string(evidence)})
for _, raw := range first.Output {
var value any
if json.Unmarshal(raw, &value) == nil {
input = append(input, value)
}
}
for _, call := range calls {
if !allowedTool(call.Name) {
return AICopy{}, usage, false, errors.New("avalai: unknown tool")
}
result, err := executor.Execute(ctx, call.Name, call.Arguments)
if err != nil {
return AICopy{}, usage, false, fmt.Errorf("executing growth tool: %w", err)
}
input = append(input, map[string]any{"type": "function_call_output", "call_id": call.CallID, "output": string(result)})
}
secondBody := cloneRequest(base)
secondBody["input"] = input
second, secondUsage, secondTransient, err := c.do(ctx, secondBody)
usage = mergeUsage(usage, secondUsage)
if err != nil {
return AICopy{}, usage, secondTransient, err
}
final = second
for _, raw := range second.Output {
var item outputItem
if json.Unmarshal(raw, &item) == nil && item.Type == "function_call" {
return AICopy{}, usage, false, errors.New("avalai: model exceeded two turns")
}
}
}
copy, err := parseAICopy(final, known)
if err != nil {
return AICopy{}, usage, false, err
}
return copy, usage, false, nil
}
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{}, 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{}, 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{}, 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{}, 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{}, 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
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 result, usage, false, fmt.Errorf("avalai: incomplete response status %q", result.Status)
}
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 {
if len(raw) == 0 {
return ""
}
var text string
if json.Unmarshal(raw, &text) == nil {
if len(text) <= 64 {
return text
}
return ""
}
text = strings.TrimSpace(string(raw))
if len(text) > 64 {
return ""
}
for _, r := range text {
if !(unicode.IsDigit(r) || r == '.' || r == '-' || r == 'e' || r == 'E') {
return ""
}
}
return text
}
func parseAICopy(result responseEnvelope, known map[string]struct{}) (AICopy, error) {
var text string
for _, raw := range result.Output {
var item outputItem
if json.Unmarshal(raw, &item) == nil && item.Type == "message" {
for _, content := range item.Content {
if content.Refusal != "" {
return AICopy{}, 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{}, errors.New("avalai: invalid structured output")
}
if err := ValidateAICopy(copy, known); err != nil {
return AICopy{}, err
}
return copy, nil
}
func cloneRequest(base map[string]any) map[string]any {
result := make(map[string]any, len(base)+1)
for key, value := range base {
result[key] = value
}
return result
}
func allowedTool(name string) bool {
switch name {
case "get_growth_context", "get_event_evidence", "get_peer_context", "get_campaign_playbook":
return true
}
return false
}
func ValidateAICopy(copy AICopy, known map[string]struct{}) error {
if len(copy.EventRecommendations) != 3 {
return errors.New("avalai: invalid event recommendation count")
}
seen := make(map[string]struct{}, len(copy.EventRecommendations))
for _, recommendation := range copy.EventRecommendations {
if _, ok := known[recommendation.EvidenceID]; !ok {
return errors.New("avalai: unknown event evidence reference")
}
if _, duplicate := seen[recommendation.EvidenceID]; duplicate {
return errors.New("avalai: duplicate event evidence reference")
}
seen[recommendation.EvidenceID] = struct{}{}
if !validPersianReason(recommendation.RelevanceReason) {
return errors.New("avalai: invalid event relevance reason")
}
}
return nil
}
func validPersianReason(value string) bool {
value = strings.TrimSpace(value)
if len(value) == 0 || len(value) > 320 {
return false
}
for _, r := range value {
if unicode.IsDigit(r) {
return false
}
}
for _, word := range strings.Fields(value) {
switch strings.Trim(word, ".,;:!?،؛؟٪%()[]{}«»\\\"'") {
case "صفر", "یک", "دو", "سه", "چهار", "پنج", "شش", "هفت", "هشت", "نه", "ده", "یازده", "دوازده", "سیزده", "چهارده", "پانزده", "بیست", "سی", "چهل", "پنجاه", "شصت", "هفتاد", "هشتاد", "نود", "صد", "هزار", "میلیون", "میلیارد", "درصد":
return false
}
}
for _, r := range value {
if r >= '\u0600' && r <= '\u06ff' && unicode.IsLetter(r) {
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))
for _, name := range names {
properties := map[string]any{}
required := []string{}
if name == "get_event_evidence" || name == "get_campaign_playbook" {
properties["evidenceId"] = map[string]any{"type": "string", "minLength": 1, "maxLength": 128}
required = []string{"evidenceId"}
}
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": properties, "required": required, "additionalProperties": false}})
}
return tools
}
func aiCopySchema() map[string]any {
recommendationProperties := map[string]any{
"evidenceId": map[string]any{"type": "string", "minLength": 1, "maxLength": 128},
"relevanceReason": map[string]any{"type": "string", "minLength": 1, "maxLength": 320},
}
return map[string]any{"type": "object", "properties": map[string]any{"eventRecommendations": map[string]any{"type": "array", "minItems": 3, "maxItems": 3, "items": map[string]any{"type": "object", "properties": recommendationProperties, "required": []string{"evidenceId", "relevanceReason"}, "additionalProperties": false}}}, "required": []string{"eventRecommendations"}, "additionalProperties": false}
}