feat: orchestrate deterministic growth report generation
This commit is contained in:
@@ -17,94 +17,275 @@ const maxAvalAIResponseBytes = 1 << 20
|
||||
|
||||
type AvalAIConfig struct {
|
||||
BaseURL string
|
||||
APIKey string
|
||||
Model string
|
||||
APIKey string
|
||||
Model string
|
||||
Timeout time.Duration
|
||||
}
|
||||
|
||||
type AvalAIClient struct { config AvalAIConfig; client *http.Client }
|
||||
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{} }
|
||||
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{
|
||||
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": "شما تحلیلگر رشد فارسی هستید. فقط بر اساس شواهد ارائهشده پیشنهاد کیفی بدهید. هیچ عدد، تاریخ یا ادعای تازهای ننویسید. شناسه شواهد را دقیق ارجاع دهید.",
|
||||
"input": string(evidence), "tools": growthTools(),
|
||||
"text": map[string]any{"format": map[string]any{"type":"json_schema", "name":"growth_copy", "strict":true, "schema": aiCopySchema()}},
|
||||
"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 }
|
||||
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 }
|
||||
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()
|
||||
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"`
|
||||
}
|
||||
|
||||
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}
|
||||
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
|
||||
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 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) }
|
||||
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 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 != nil {
|
||||
return responseEnvelope{}, 0, true, fmt.Errorf("reading AvalAI response: %w", err)
|
||||
}
|
||||
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) }
|
||||
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)
|
||||
}
|
||||
return result, time.Since(started).Milliseconds(), false, nil
|
||||
}
|
||||
|
||||
func parseAICopy(result responseEnvelope, known map[string]struct{}) (AICopy, error) {
|
||||
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 } } } }
|
||||
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{}, 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
|
||||
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") }
|
||||
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") } }
|
||||
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") } }
|
||||
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")
|
||||
}
|
||||
}
|
||||
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"}
|
||||
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}}) }
|
||||
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}
|
||||
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