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), ©) != 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} }