fix: deliver validated AI insight responses
This commit is contained in:
@@ -42,6 +42,7 @@ func NewAvalAIClient(config AvalAIConfig, client *http.Client) *AvalAIClient {
|
||||
if client == nil {
|
||||
client = &http.Client{}
|
||||
}
|
||||
config.BaseURL = strings.TrimSuffix(strings.TrimRight(config.BaseURL, "/"), "/v1")
|
||||
return &AvalAIClient{config: config, client: client}
|
||||
}
|
||||
|
||||
@@ -56,7 +57,7 @@ func (c *AvalAIClient) GenerateWithTools(ctx context.Context, evidence json.RawM
|
||||
base := map[string]any{
|
||||
"model": c.config.Model, "store": false, "parallel_tool_calls": false,
|
||||
"max_output_tokens": 1600, "max_tool_calls": 4,
|
||||
"instructions": "شما تحلیلگر رشد فارسی هستید. فقط بر اساس شواهد ارائهشده پیشنهاد کیفی بدهید. هیچ عدد، تاریخ یا ادعای تازهای ننویسید. شناسه شواهد را دقیق ارجاع دهید.",
|
||||
"instructions": "شما تحلیلگر رشد فارسی هستید. فقط از رویدادهای بررسیشده در شواهد ارائهشده، دقیقاً سه رویداد متفاوت را بهترتیب ارتباط با کسبوکار انتخاب کنید. برای هر رویداد یک دلیل کوتاه فارسی و کیفی بنویسید. هیچ عدد، تاریخ یا ادعای تازهای ننویسید و شناسه شواهد را دقیق ارجاع دهید.",
|
||||
"tools": growthTools(),
|
||||
"text": map[string]any{"format": map[string]any{"type": "json_schema", "name": "growth_copy", "strict": true, "schema": aiCopySchema()}},
|
||||
}
|
||||
@@ -297,50 +298,43 @@ func allowedTool(name string) bool {
|
||||
}
|
||||
|
||||
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.EventRecommendations) != 3 {
|
||||
return errors.New("avalai: invalid event recommendation 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 containsQuantityWord(field) {
|
||||
return errors.New("avalai: written 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")
|
||||
}
|
||||
}
|
||||
seen := make(map[string]struct{}, len(copy.EventRecommendations))
|
||||
for _, recommendation := range copy.EventRecommendations {
|
||||
if _, ok := known[recommendation.EvidenceID]; !ok {
|
||||
return errors.New("avalai: unknown event evidence reference")
|
||||
}
|
||||
if brief.Headline == "" || len(brief.EvidenceIDs) == 0 {
|
||||
return errors.New("avalai: ungrounded brief")
|
||||
if _, duplicate := seen[recommendation.EvidenceID]; duplicate {
|
||||
return errors.New("avalai: duplicate event evidence reference")
|
||||
}
|
||||
for _, id := range brief.EvidenceIDs {
|
||||
if _, ok := known[id]; !ok {
|
||||
return errors.New("avalai: unknown evidence reference")
|
||||
}
|
||||
seen[recommendation.EvidenceID] = struct{}{}
|
||||
if !validPersianReason(recommendation.RelevanceReason) {
|
||||
return errors.New("avalai: invalid event relevance reason")
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func containsQuantityWord(value string) bool {
|
||||
quantityWords := map[string]struct{}{
|
||||
"zero": {}, "one": {}, "two": {}, "three": {}, "four": {}, "five": {}, "six": {}, "seven": {}, "eight": {}, "nine": {}, "ten": {}, "eleven": {}, "twelve": {}, "thirteen": {}, "fourteen": {}, "fifteen": {}, "sixteen": {}, "seventeen": {}, "eighteen": {}, "nineteen": {}, "twenty": {}, "thirty": {}, "forty": {}, "fifty": {}, "sixty": {}, "seventy": {}, "eighty": {}, "ninety": {}, "hundred": {}, "thousand": {}, "million": {}, "billion": {}, "percent": {}, "percentage": {}, "double": {}, "triple": {}, "half": {},
|
||||
"صفر": {}, "یک": {}, "دو": {}, "سه": {}, "چهار": {}, "پنج": {}, "شش": {}, "هفت": {}, "هشت": {}, "نه": {}, "ده": {}, "یازده": {}, "دوازده": {}, "سیزده": {}, "چهارده": {}, "پانزده": {}, "شانزده": {}, "هفده": {}, "هجده": {}, "نوزده": {}, "بیست": {}, "سی": {}, "چهل": {}, "پنجاه": {}, "شصت": {}, "هفتاد": {}, "هشتاد": {}, "نود": {}, "صد": {}, "هزار": {}, "میلیون": {}, "میلیارد": {}, "درصد": {}, "برابر": {}, "نصف": {},
|
||||
func validPersianReason(value string) bool {
|
||||
value = strings.TrimSpace(value)
|
||||
if len(value) == 0 || len(value) > 320 {
|
||||
return false
|
||||
}
|
||||
for _, word := range strings.Fields(strings.ToLower(value)) {
|
||||
word = strings.Trim(word, ".,;:!?،؛؟٪%()[]{}«»\"'")
|
||||
if _, found := quantityWords[word]; found {
|
||||
for _, r := range value {
|
||||
if unicode.IsDigit(r) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, word := range strings.Fields(value) {
|
||||
switch strings.Trim(word, ".,;:!?،؛؟٪%()[]{}«»\\\"'") {
|
||||
case "صفر", "یک", "دو", "سه", "چهار", "پنج", "شش", "هفت", "هشت", "نه", "ده", "یازده", "دوازده", "سیزده", "چهارده", "پانزده", "بیست", "سی", "چهل", "پنجاه", "شصت", "هفتاد", "هشتاد", "نود", "صد", "هزار", "میلیون", "میلیارد", "درصد":
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, r := range value {
|
||||
if r >= '\u0600' && r <= '\u06ff' && unicode.IsLetter(r) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
@@ -363,10 +357,9 @@ func growthTools() []map[string]any {
|
||||
}
|
||||
|
||||
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"}
|
||||
recommendationProperties := map[string]any{
|
||||
"evidenceId": map[string]any{"type": "string", "minLength": 1, "maxLength": 128},
|
||||
"relevanceReason": map[string]any{"type": "string", "minLength": 1, "maxLength": 320},
|
||||
}
|
||||
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}
|
||||
return map[string]any{"type": "object", "properties": map[string]any{"eventRecommendations": map[string]any{"type": "array", "minItems": 3, "maxItems": 3, "items": map[string]any{"type": "object", "properties": recommendationProperties, "required": []string{"evidenceId", "relevanceReason"}, "additionalProperties": false}}}, "required": []string{"eventRecommendations"}, "additionalProperties": false}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user