111 lines
6.8 KiB
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), ©) != 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}
|
|
}
|
|
|