fix: deliver validated AI insight responses

This commit is contained in:
2026-08-22 15:47:38 +08:00
parent ca55c2fb3d
commit 3fba4261ee
68 changed files with 6870 additions and 493 deletions

View File

@@ -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}
}