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 {

View File

@@ -1,8 +1,11 @@
package insight
import (
"encoding/json"
"strings"
"testing"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/peercomparison"
)
func TestGeneratedInsightTextMustBeOnePlainParagraph(t *testing.T) {
@@ -55,3 +58,32 @@ func TestGeneratedInsightMustNameSelectedAndPrecedingPeriods(t *testing.T) {
t.Fatal("generic period wording was accepted")
}
}
func TestPeerInsightAllowsOnlyGroundedAggregateNumbers(t *testing.T) {
evidence, err := json.Marshal(map[string]any{
"peerComparison": peercomparison.Result{
State: peercomparison.StateReady,
EligiblePeerCount: 10,
CombinedPeerSessions: 1000,
Metrics: []peercomparison.Metric{{
MetricID: "payment_success_rate",
CohortMedian: "72.50",
MerchantPerformancePercentile: "85.00",
Direction: "HIGHER_IS_BETTER",
}},
},
})
if err != nil {
t.Fatalf("marshal evidence: %v", err)
}
labels := []string{"مرداد ۱۴۰۵"}
if containsUnexpectedPeerNumber("در مرداد ۱۴۰۵، جایگاه شما صدک ۸۵٫۰۰ است.", labels, evidence) {
t.Fatal("grounded Persian percentile was rejected")
}
if containsUnexpectedPeerNumber("این مقایسه بر پایه ۱٬۰۰۰ نشست است.", labels, evidence) {
t.Fatal("grounded Persian session count was rejected")
}
if !containsUnexpectedPeerNumber("جایگاه شما صدک ۹۱ است.", labels, evidence) {
t.Fatal("invented percentile was accepted")
}
}

View File

@@ -7,14 +7,16 @@ import (
"fmt"
"math"
"sort"
"strconv"
"time"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/analytics"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/peercomparison"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
const AnalyticsVersion = "2.0.0"
const AnalyticsVersion = "2.1.0"
type Analysis struct {
Time TimeContext
@@ -99,6 +101,14 @@ func (b *PostgreSQLBuilder) build(ctx context.Context, merchantID int64, termina
if err != nil {
return Analysis{}, err
}
var peerResult *peercomparison.Result
if module == ModulePeers {
result, peerErr := b.peerComparison(ctx, merchantID, terminalID, period, dataThrough)
if peerErr != nil {
return Analysis{}, peerErr
}
peerResult = &result
}
confidence := ConfidenceMedium
if timeContext.Freshness == FreshnessStale {
confidence = ConfidenceLow
@@ -108,7 +118,7 @@ func (b *PostgreSQLBuilder) build(ctx context.Context, merchantID int64, termina
confidence = ConfidenceInsufficientData
}
evidenceMap := map[string]any{
"schemaVersion": "1.0.0", "module": module, "comparisonAvailable": comparisonAvailable,
"schemaVersion": "2.0.0", "module": module, "comparisonAvailable": comparisonAvailable,
"time": timeContext, "privacy": map[string]any{"aggregateOnly": true, "peerSuppressionApplied": module == ModulePeers},
}
precedingPeriodLabel := ""
@@ -124,6 +134,9 @@ func (b *PostgreSQLBuilder) build(ctx context.Context, merchantID int64, termina
evidenceMap["latest30Days"] = latest
evidenceMap["previousYearEquivalent"] = previous
}
if peerResult != nil {
evidenceMap["peerComparison"] = peerResult
}
if period != nil && module == ModuleTransactions {
evidenceMap["statusDistribution"] = map[string]any{
"total": latest.Sessions, "paidAwaitingVerification": latest.Unverified, "verified": latest.Verified, "failed": latest.Failed, "reversed": latest.Reversed,
@@ -151,9 +164,9 @@ func (b *PostgreSQLBuilder) build(ctx context.Context, merchantID int64, termina
if err != nil {
return Analysis{}, fmt.Errorf("encoding insight evidence: %w", err)
}
copy := deterministicCopy(module, latest, previous, comparisonAvailable, timeContext, confidence)
copy := deterministicCopy(module, latest, previous, comparisonAvailable, timeContext, confidence, peerResult)
if period != nil {
copy = deterministicPeriodCopy(module, *period, precedingPeriodLabel, latest, previous, comparisonAvailable, timeContext, confidence)
copy = deterministicPeriodCopy(module, *period, precedingPeriodLabel, latest, previous, comparisonAvailable, timeContext, confidence, peerResult)
}
return Analysis{Time: timeContext, Confidence: confidence, Evidence: encoded, Copy: copy, Calendar: calendar}, nil
}
@@ -188,6 +201,59 @@ func (b *PostgreSQLBuilder) aggregateRange(ctx context.Context, merchantID int64
return value, nil
}
func (b *PostgreSQLBuilder) peerComparison(ctx context.Context, merchantID int64, terminalID string, period *PeriodScope, dataThrough time.Time) (peercomparison.Result, error) {
if terminalID != "" {
return peercomparison.Calculate(merchantID, true, nil), nil
}
start, end := dataThrough.AddDate(0, 0, -29), dataThrough.AddDate(0, 0, 1)
if period != nil {
start, end = period.StartAt, period.EndAt
}
query := `WITH session_stats AS (
SELECT ps.merchant_id,ps.id,ps.status,ps.amount_rial,ps.fee_amount_rial,count(pa.id) attempts
FROM payment_sessions ps LEFT JOIN payment_attempts pa ON pa.session_id=ps.id
JOIN merchants m ON m.id=ps.merchant_id
WHERE m.category_id=(SELECT category_id FROM merchants WHERE id=$1) AND ps.created_at >= $2 AND ps.created_at < $3
GROUP BY ps.id
), aggregate AS (
SELECT merchant_id,count(*) session_count,
100.0*count(*) FILTER(WHERE status IN ('Paid','Verified'))/nullif(count(*),0) success,
100.0*count(*) FILTER(WHERE status='Verified')/nullif(count(*) FILTER(WHERE status IN ('Paid','Verified')),0) verification,
100.0*count(*) FILTER(WHERE attempts>1 AND status IN ('Paid','Verified'))/nullif(count(*) FILTER(WHERE attempts>1),0) recovery,
100.0*sum(fee_amount_rial) FILTER(WHERE status IN ('Paid','Verified'))/nullif(sum(amount_rial) FILTER(WHERE status IN ('Paid','Verified')),0) fee
FROM session_stats GROUP BY merchant_id
), timing AS (
SELECT ps.merchant_id,
percentile_disc(.5) within group(order by pa.init_time_ms)::float8 ip50,
percentile_disc(.95) within group(order by pa.init_time_ms)::float8 ip95,
percentile_disc(.5) within group(order by pa.verify_time_ms)::float8 vp50,
percentile_disc(.95) within group(order by pa.verify_time_ms)::float8 vp95
FROM payment_sessions ps JOIN payment_attempts pa ON pa.session_id=ps.id JOIN merchants m ON m.id=ps.merchant_id
WHERE m.category_id=(SELECT category_id FROM merchants WHERE id=$1) AND ps.created_at >= $2 AND ps.created_at < $3
GROUP BY ps.merchant_id
)
SELECT a.merchant_id,a.session_count,a.success::float8,a.verification::float8,a.recovery::float8,
t.ip50,t.ip95,t.vp50,t.vp95,a.fee::float8
FROM aggregate a LEFT JOIN timing t USING(merchant_id)`
rows, err := b.pool.Query(ctx, query, merchantID, start, end)
if err != nil {
return peercomparison.Result{}, fmt.Errorf("loading peer insight evidence: %w", err)
}
defer rows.Close()
merchants := make([]peercomparison.MerchantValues, 0)
for rows.Next() {
var row peercomparison.MerchantValues
if err := rows.Scan(&row.MerchantID, &row.SessionCount, &row.Values[0], &row.Values[1], &row.Values[2], &row.Values[3], &row.Values[4], &row.Values[5], &row.Values[6], &row.Values[7]); err != nil {
return peercomparison.Result{}, fmt.Errorf("scanning peer insight evidence: %w", err)
}
merchants = append(merchants, row)
}
if err := rows.Err(); err != nil {
return peercomparison.Result{}, fmt.Errorf("iterating peer insight evidence: %w", err)
}
return peercomparison.Calculate(merchantID, false, merchants), nil
}
func ratio(numerator, denominator int64) any {
if denominator == 0 {
return nil
@@ -284,8 +350,11 @@ func horizon(module Module) int {
return 30
}
func deterministicCopy(module Module, latest, previous aggregate, comparison bool, tc TimeContext, confidence Confidence) Copy {
label := map[Module]string{ModuleOverview: "وضعیت کلی کسب‌وکار", ModulePayments: "عملکرد پرداخت", ModuleFees: "کارمزدها", ModulePeers: "مقایسه هم‌گروه", ModuleTransactions: "الگوی تراکنش‌ها", ModuleGrowth: "رشد"}[module]
func deterministicCopy(module Module, latest, previous aggregate, comparison bool, tc TimeContext, confidence Confidence, peer ...*peercomparison.Result) Copy {
label := map[Module]string{ModuleOverview: "وضعیت کلی کسب‌وکار", ModulePayments: "عملکرد پرداخت", ModuleFees: "کارمزدها", ModulePeers: "جایگاه رقابتی", ModuleTransactions: "الگوی تراکنش‌ها", ModuleGrowth: "رشد"}[module]
if module == ModulePeers && len(peer) > 0 && peer[0] != nil {
return deterministicPeerCopy(peer[0], tc.DataThroughDate)
}
change := 0.0
if previous.Paid > 0 {
change = float64(latest.Paid-previous.Paid) / float64(previous.Paid) * 100
@@ -346,10 +415,13 @@ func deterministicTransactionCopy(period PeriodScope, precedingPeriodLabel strin
}
}
func deterministicPeriodCopy(module Module, period PeriodScope, precedingPeriodLabel string, latest, previous aggregate, comparison bool, tc TimeContext, confidence Confidence) Copy {
func deterministicPeriodCopy(module Module, period PeriodScope, precedingPeriodLabel string, latest, previous aggregate, comparison bool, tc TimeContext, confidence Confidence, peer ...*peercomparison.Result) Copy {
if module == ModuleTransactions {
return deterministicTransactionCopy(period, precedingPeriodLabel, latest, previous, comparison, tc, confidence)
}
if module == ModulePeers && len(peer) > 0 && peer[0] != nil {
return deterministicPeerCopy(peer[0], period.JalaliLabelFA)
}
latestBody := fmt.Sprintf("در %s، %d تراکنش ثبت شد و %d پرداخت با موفقیت انجام شد.", period.JalaliLabelFA, latest.Sessions, latest.Paid)
switch module {
@@ -378,6 +450,56 @@ func deterministicPeriodCopy(module Module, period PeriodScope, precedingPeriodL
return copy
}
var peerMetricLabels = map[string]string{
"payment_success_rate": "موفقیت پرداخت", "verification_completion_rate": "تکمیل تأیید", "retry_recovery_rate": "بازیابی تلاش مجدد",
"init_time_ms_p50": "میانه زمان شروع", "init_time_ms_p95": "زمان شروع پرتکرار", "verify_time_ms_p50": "میانه زمان تأیید", "verify_time_ms_p95": "زمان تأیید پرتکرار", "paid_fee_rate": "نرخ کارمزد پرداخت موفق",
}
func deterministicPeerCopy(result *peercomparison.Result, periodLabel string) Copy {
if result.State != peercomparison.StateReady || len(result.Metrics) == 0 {
body := "برای مقایسه جایگاه رقابتی، هنوز دادهٔ تجمیعی کافی از کسب‌وکارهای مشابه وجود ندارد."
return Copy{Past: Narrative{Headline: "مقایسه با گروه مقایسه", Body: body, EvidenceID: []string{"peer-comparison"}}, LatestObserved: Narrative{Headline: "جایگاه رقابتی", Body: body, EvidenceID: []string{"peer-comparison"}}, Future7Days: Narrative{Headline: "برداشت کوتاه‌مدت", Body: body, EvidenceID: []string{"peer-comparison"}}, Future30Days: Narrative{Headline: "اقدام پیشنهادی", Body: "با تکمیل دادهٔ گروه مقایسه، این بخش جایگاه شما را دقیق‌تر توضیح می‌دهد.", EvidenceID: []string{"peer-comparison"}}, Findings: []Finding{{EvidenceID: "peer-comparison", Title: "دادهٔ مقایسه", Summary: body}}, Options: []Option{{Title: "بررسی دوباره", Description: "پس از تکمیل دادهٔ گروه مقایسه، این تحلیل را دوباره بررسی کنید.", EvidenceID: []string{"peer-comparison"}}}}
}
strongest, weakest := result.Metrics[0], result.Metrics[0]
total := 0.0
for _, metric := range result.Metrics {
value := parsePercent(metric.MerchantPerformancePercentile)
total += value
if value > parsePercent(strongest.MerchantPerformancePercentile) {
strongest = metric
}
if value < parsePercent(weakest.MerchantPerformancePercentile) {
weakest = metric
}
}
overall := total / float64(len(result.Metrics))
strongLabel, weakLabel := peerMetricLabels[strongest.MetricID], peerMetricLabels[weakest.MetricID]
if strongLabel == "" {
strongLabel = strongest.MetricID
}
if weakLabel == "" {
weakLabel = weakest.MetricID
}
periodPrefix := ""
if periodLabel != "" {
periodPrefix = fmt.Sprintf("در %s، ", periodLabel)
}
position := fmt.Sprintf("%sمیانگین جایگاه شما در هشت شاخص گروه مقایسه صدک %.2f است؛ صدک بالاتر یعنی عملکرد بهتر در این مقایسه.", periodPrefix, overall)
strongBody := fmt.Sprintf("قوی‌ترین دستهٔ شما «%s» با صدک %s است.", strongLabel, strongest.MerchantPerformancePercentile)
weakBody := fmt.Sprintf("ضعیف‌ترین دستهٔ شما «%s» با صدک %s است؛ بهبود همین دسته بیشترین فرصت رشد را دارد.", weakLabel, weakest.MerchantPerformancePercentile)
action := fmt.Sprintf("روند «%s» را در بازه‌های بعد بررسی کنید و برای بهبود آن یک اقدام مشخص در فرایند پرداخت تعریف کنید.", weakLabel)
latest := position + " " + strongBody + " " + weakBody
return Copy{Past: Narrative{Headline: "مقایسه با گروه مقایسه", Body: position, EvidenceID: []string{"peer-position"}}, LatestObserved: Narrative{Headline: "جایگاه رقابتی", Body: latest, EvidenceID: []string{"peer-position", "peer-strongest", "peer-weakest"}}, Future7Days: Narrative{Headline: "برداشت کوتاه‌مدت", Body: strongBody, EvidenceID: []string{"peer-strongest"}}, Future30Days: Narrative{Headline: "اقدام پیشنهادی", Body: action, EvidenceID: []string{"peer-weakest", "peer-action"}}, Findings: []Finding{{EvidenceID: "peer-position", Title: "جایگاه کلی", Summary: position, ScoreBPS: int(overall * 100), Confidence: ConfidenceMedium}, {EvidenceID: "peer-strongest", Title: "قوی‌ترین دسته", Summary: strongBody, ScoreBPS: int(parsePercent(strongest.MerchantPerformancePercentile) * 100), Confidence: ConfidenceMedium}, {EvidenceID: "peer-weakest", Title: "فرصت رشد", Summary: weakBody, ScoreBPS: int(parsePercent(weakest.MerchantPerformancePercentile) * 100), Confidence: ConfidenceMedium}}, Options: []Option{{Title: "تمرکز بر فرصت رشد", Description: action, EvidenceID: []string{"peer-weakest", "peer-action"}}}}
}
func parsePercent(value string) float64 {
result, err := strconv.ParseFloat(value, 64)
if err != nil {
return 0
}
return result
}
func score(impact int64, confidence Confidence) int {
base := int(math.Min(8000, math.Log10(float64(max(int64(1), impact))+1)*1200))
weight := map[Confidence]int{ConfidenceHigh: 10000, ConfidenceMedium: 7500, ConfidenceLow: 4500, ConfidenceInsufficientData: 2000}[confidence]

View File

@@ -3,6 +3,8 @@ package insight
import (
"strings"
"testing"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/peercomparison"
)
func TestDeterministicTransactionCopyDistinguishesPaymentStatesWithoutCausalClaims(t *testing.T) {
@@ -27,6 +29,22 @@ func TestDeterministicTransactionCopyDistinguishesPaymentStatesWithoutCausalClai
}
}
func TestDeterministicPeerCopyNamesStrongestWeakestAndAction(t *testing.T) {
result := peercomparison.Result{State: peercomparison.StateReady, Metrics: []peercomparison.Metric{
{MetricID: "payment_success_rate", MerchantPerformancePercentile: "85.00"},
{MetricID: "verification_completion_rate", MerchantPerformancePercentile: "40.00"},
}}
copy := deterministicPeerCopy(&result, "مرداد ۱۴۰۵")
for _, expected := range []string{"مرداد ۱۴۰۵", "موفقیت پرداخت", "صدک 85.00", "تکمیل تأیید", "صدک 40.00"} {
if !strings.Contains(copy.LatestObserved.Body, expected) {
t.Fatalf("peer summary missing %q: %s", expected, copy.LatestObserved.Body)
}
}
if !strings.Contains(copy.Options[0].Description, "تکمیل تأیید") {
t.Fatalf("peer action did not target weakest category: %+v", copy.Options)
}
}
func TestDeterministicTransactionCopyHandlesEmptyPeriod(t *testing.T) {
copy := deterministicTransactionCopy(
PeriodScope{JalaliLabelFA: "امروز"},

View File

@@ -10,7 +10,7 @@ type Module string
type Section string
const PageInsightVersion = "page_insight_v7"
const PageInsightVersion = "page_insight_v8"
const (
ModuleOverview Module = "overview"

View File

@@ -151,7 +151,7 @@ func (s *Service) EnqueueAll(ctx context.Context, reason string) (int, error) {
return 0, err
}
timeContext := BuildTimeContext(s.now(), dataThrough)
modules := []Module{ModuleOverview, ModulePayments, ModuleFees, ModulePeers, ModuleGrowth}
modules := []Module{ModuleOverview, ModulePayments, ModuleFees, ModulePeers, ModuleTransactions, ModuleGrowth}
created := 0
for _, scope := range scopes {
for _, module := range modules {