feat: ground competitive insights in peer metrics

This commit is contained in:
2026-08-22 18:45:29 +08:00
parent 1a8a61cfe7
commit 4f91726edb
10 changed files with 497 additions and 86 deletions

View File

@@ -4,10 +4,12 @@ import (
"context"
"encoding/json"
"errors"
"fmt"
"strings"
"unicode"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/aiprovider"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/peercomparison"
)
type AvalAIGenerator struct{ client *aiprovider.Client }
@@ -61,7 +63,7 @@ func (g *AvalAIGenerator) Generate(ctx context.Context, module Module, section S
if err := json.Unmarshal(raw, &generated); err != nil {
return Copy{}, providerUsage, errors.New("avalai: invalid page insight output")
}
copy, err := mergeGenerated(fallback, generated, periodContext.AllowedLabels)
copy, err := mergeGenerated(module, fallback, generated, periodContext.AllowedLabels, evidence)
return copy, providerUsage, err
}
@@ -70,10 +72,13 @@ func insightInstructions(module Module) string {
if module == ModuleTransactions {
return base + " خلاصه باید دقیقاً وضعیت همان بازه انتخاب‌شده در صفحه تراکنش‌ها را توضیح دهد. Paid را پرداخت‌شده و در انتظار تأیید و Verified را تأیید نهایی بنامید؛ آن‌ها را با هم یا با Failed یکی نکنید. روند را توصیف کنید، اما برای تغییرها علت نسازید."
}
if module == ModulePeers {
return base + " برای جایگاه رقابتی، فقط از peerComparison استفاده کنید. وضعیت را میان کسب‌وکارهای مشابه و بر پایه داده‌های تجمیعی توضیح دهید، قوی‌ترین و ضعیف‌ترین دسته را مشخص کنید و برای ضعیف‌ترین دسته یک اقدام ساده پیشنهاد دهید. صدک‌ها و میانه‌ها را دقیقاً از شواهد کپی کنید و هیچ عدد تازه‌ای نسازید؛ از نام یا مقدار یک کسب‌وکار دیگر صحبت نکنید."
}
return base
}
func mergeGenerated(fallback Copy, generated generatedInsight, allowedPeriodLabels []string) (Copy, error) {
func mergeGenerated(module Module, fallback Copy, generated generatedInsight, allowedPeriodLabels []string, evidence json.RawMessage) (Copy, error) {
known := map[string]struct{}{}
for _, id := range evidenceIDs(fallback) {
known[id] = struct{}{}
@@ -103,7 +108,11 @@ func mergeGenerated(fallback Copy, generated generatedInsight, allowedPeriodLabe
}
}
for _, text := range texts {
if strings.TrimSpace(text) == "" || len([]rune(text)) > 600 || containsUnexpectedDigit(text, allowedPeriodLabels) || !isPlainParagraph(text) {
unexpectedNumber := containsUnexpectedDigit(text, allowedPeriodLabels)
if module == ModulePeers {
unexpectedNumber = containsUnexpectedPeerNumber(text, allowedPeriodLabels, evidence)
}
if strings.TrimSpace(text) == "" || len([]rune(text)) > 600 || unexpectedNumber || !isPlainParagraph(text) {
return Copy{}, errors.New("avalai: unsafe page insight text")
}
}
@@ -132,6 +141,77 @@ func mergeGenerated(fallback Copy, generated generatedInsight, allowedPeriodLabe
return result, nil
}
func containsUnexpectedPeerNumber(value string, labels []string, evidence json.RawMessage) bool {
for _, label := range labels {
value = strings.ReplaceAll(value, label, "")
}
allowed := map[string]struct{}{}
var comparison peercomparison.Result
if json.Unmarshal(evidence, &struct {
Peer *peercomparison.Result `json:"peerComparison"`
}{Peer: &comparison}) == nil {
for _, metric := range comparison.Metrics {
for _, item := range []string{metric.CohortMedian, metric.MerchantPerformancePercentile} {
var number float64
if _, err := fmt.Sscanf(item, "%f", &number); err == nil {
allowed[fmt.Sprintf("%.2f", number)] = struct{}{}
allowed[fmt.Sprintf("%.0f", number)] = struct{}{}
}
}
}
allowed[fmt.Sprintf("%d", comparison.EligiblePeerCount)] = struct{}{}
allowed[fmt.Sprintf("%d", comparison.CombinedPeerSessions)] = struct{}{}
}
value = normalizeDigits(value)
for _, token := range numericTokens(value) {
if _, ok := allowed[token]; !ok {
return true
}
}
return false
}
func normalizeDigits(value string) string {
var builder strings.Builder
for _, r := range value {
switch {
case r >= '۰' && r <= '۹':
builder.WriteRune('0' + (r - '۰'))
case r >= '٠' && r <= '٩':
builder.WriteRune('0' + (r - '٠'))
case r == '٫':
builder.WriteByte('.')
case r == '٬':
// Persian thousands separators do not change the numeric token.
default:
builder.WriteRune(r)
}
}
return builder.String()
}
func numericTokens(value string) []string {
result := []string{}
start := -1
for index, r := range value {
isNumber := r >= '0' && r <= '9'
if r == '.' && start >= 0 {
continue
}
if isNumber && start < 0 {
start = index
}
if !isNumber && start >= 0 {
result = append(result, strings.TrimRight(value[start:index], "."))
start = -1
}
}
if start >= 0 {
result = append(result, strings.TrimRight(value[start:], "."))
}
return result
}
func hasExactPeriodReferences(generated generatedInsight, labels []string) bool {
if len(labels) < 2 {
return true
@@ -147,7 +227,7 @@ type generationPeriodContext struct {
func periodGenerationContext(evidence json.RawMessage) generationPeriodContext {
var values struct {
ComparisonAvailable bool `json:"comparisonAvailable"`
SelectedPeriod struct {
SelectedPeriod struct {
JalaliLabelFA string `json:"jalaliLabelFa"`
} `json:"selectedPeriod"`
PrecedingPeriod struct {