feat: ground competitive insights in peer metrics
This commit is contained in:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user