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 } 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{} } 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 for attempt := 0; attempt < 2; attempt++ { copy, usage, transient, err := c.workflow(ctx, base, evidence, known, executor) if err == nil { return copy, usage, nil } last = err if !transient || ctx.Err() != nil { break } } return AICopy{}, Usage{}, 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, latency, 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) } } 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 { 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, secondLatency, secondTransient, err := c.do(ctx, secondBody) 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 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, int64, bool, error) { body, err := json.Marshal(bodyValue) if err != nil { return responseEnvelope{}, 0, 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) } 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) } 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) } 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) } var result responseEnvelope if err := json.Unmarshal(responseBody, &result); err != nil { return responseEnvelope{}, 0, false, fmt.Errorf("decoding AvalAI response: %w", err) } if result.Status != "completed" { return responseEnvelope{}, 0, 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 } 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), ©) != 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.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 { 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 { 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} }