feat: add safe growth report persistence and AvalAI client
This commit is contained in:
110
internal/growthreport/avalai.go
Normal file
110
internal/growthreport/avalai.go
Normal file
@@ -0,0 +1,110 @@
|
||||
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}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user