feat: rank growth events with grounded evidence
This commit is contained in:
@@ -3,7 +3,10 @@ package growthreport
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/growth"
|
||||
@@ -32,32 +35,62 @@ type Evidence struct {
|
||||
MerchantContext MerchantContext `json:"merchantContext"`
|
||||
}
|
||||
|
||||
// UpcomingEvent is calendar metadata only. It deliberately contains no
|
||||
// payment forecast, historical-performance, or peer-comparison fields.
|
||||
type InsightBasis string
|
||||
|
||||
const (
|
||||
InsightBasisHistorical InsightBasis = "HISTORICAL"
|
||||
InsightBasisBusinessProfile InsightBasis = "BUSINESS_PROFILE"
|
||||
)
|
||||
|
||||
type HistoricalEvidence struct {
|
||||
PriorStartDate time.Time `json:"priorStartDate"`
|
||||
PriorEndDate time.Time `json:"priorEndDate"`
|
||||
ObservationCount int `json:"observationCount"`
|
||||
PaidSessionUpliftBPS int64 `json:"paidSessionUpliftBps"`
|
||||
AmountUpliftBPS int64 `json:"amountUpliftBps"`
|
||||
MaterialWin bool `json:"materialWin"`
|
||||
MaterialDecline bool `json:"materialDecline"`
|
||||
}
|
||||
|
||||
type ProfileEvidence struct {
|
||||
MatchedSignals []string `json:"matchedSignals"`
|
||||
HistoryUnavailableReason growth.SuppressionReason `json:"historyUnavailableReason,omitempty"`
|
||||
}
|
||||
|
||||
type UpcomingEvent struct {
|
||||
EvidenceID string `json:"evidenceId"`
|
||||
EventID string `json:"eventId"`
|
||||
EventTitleFA string `json:"eventTitleFa"`
|
||||
Date time.Time `json:"date"`
|
||||
EventType string `json:"eventType"`
|
||||
OfficialHoliday bool `json:"officialHoliday"`
|
||||
MerchantCategories []string `json:"merchantCategories"`
|
||||
BehavioralChannels []string `json:"behavioralChannels"`
|
||||
EvidenceID string `json:"evidenceId"`
|
||||
EventID string `json:"eventId"`
|
||||
EventTitleFA string `json:"eventTitleFa"`
|
||||
Date time.Time `json:"date"`
|
||||
EndDate time.Time `json:"endDate"`
|
||||
OccasionKey string `json:"occasionKey"`
|
||||
EventVariant string `json:"eventVariant"`
|
||||
EventType string `json:"eventType"`
|
||||
OfficialHoliday bool `json:"officialHoliday"`
|
||||
MerchantCategories []string `json:"merchantCategories"`
|
||||
BehavioralChannels []string `json:"behavioralChannels"`
|
||||
InsightBasis InsightBasis `json:"insightBasis"`
|
||||
HistoricalEvidence *HistoricalEvidence `json:"historicalEvidence,omitempty"`
|
||||
ProfileEvidence *ProfileEvidence `json:"profileEvidence,omitempty"`
|
||||
RankingScore int64 `json:"-"`
|
||||
CommercialPosture string `json:"-"`
|
||||
Tone string `json:"-"`
|
||||
}
|
||||
|
||||
// MerchantContext is the minimal, non-identifying business profile provided to
|
||||
// the model so it can rank only the reviewed calendar candidates.
|
||||
type MerchantContext struct {
|
||||
Category string `json:"category"`
|
||||
Niches []string `json:"niches"`
|
||||
SalesChannels []string `json:"salesChannels"`
|
||||
Category string `json:"category"`
|
||||
BusinessDescriptions []string `json:"businessDescriptions,omitempty"`
|
||||
Niches []string `json:"niches"`
|
||||
SalesChannels []string `json:"salesChannels"`
|
||||
}
|
||||
|
||||
type Analysis struct {
|
||||
Evidence Evidence
|
||||
Encoded json.RawMessage
|
||||
KnownEvidence map[string]struct{}
|
||||
Fallback AICopy
|
||||
KnownEvidence KnownEvidence
|
||||
Fallback *AICopy
|
||||
ShouldCallAI bool
|
||||
}
|
||||
|
||||
@@ -85,16 +118,32 @@ func (b *PostgreSQLBuilder) Build(ctx context.Context, merchantID int64, termina
|
||||
return Analysis{}, err
|
||||
}
|
||||
events := upcomingEvents(catalog, forecastStart, 5)
|
||||
byID := make(map[string]growth.Event, len(catalog))
|
||||
for _, event := range catalog {
|
||||
byID[event.ID] = event
|
||||
}
|
||||
for index := range events {
|
||||
sourceEvent := byID[events[index].EventID]
|
||||
events[index].ProfileEvidence.MatchedSignals = matchedProfileSignals(sourceEvent, merchantContext)
|
||||
withinPreparationWindow := !forecastStart.Before(sourceEvent.StartDate.AddDate(0, 0, -sourceEvent.LeadDays))
|
||||
events[index].RankingScore = growth.RankEvent(sourceEvent, growth.RankInput{MerchantTerms: merchantContext.terms(), WithinPreparationWindow: withinPreparationWindow}).ScoreBPS
|
||||
prior, effect, unavailableReason, evidenceErr := b.loadEventHistory(ctx, merchantID, terminalID, sourceEvent)
|
||||
if evidenceErr != nil {
|
||||
return Analysis{}, evidenceErr
|
||||
}
|
||||
events[index] = applyEventEvidence(events[index], prior, effect, unavailableReason)
|
||||
}
|
||||
evidence := Evidence{Events: events, MerchantContext: merchantContext}
|
||||
encoded, err := json.Marshal(evidence)
|
||||
if err != nil {
|
||||
return Analysis{}, fmt.Errorf("encoding deterministic growth evidence: %w", err)
|
||||
}
|
||||
known := map[string]struct{}{}
|
||||
known := KnownEvidence{}
|
||||
for _, event := range evidence.Events {
|
||||
known[event.EvidenceID] = struct{}{}
|
||||
known[event.EvidenceID] = evidencePolicy(event)
|
||||
}
|
||||
return Analysis{Evidence: evidence, Encoded: encoded, KnownEvidence: known, Fallback: AICopy{EventRecommendations: fallbackEventRecommendations(events)}, ShouldCallAI: len(events) == 5}, nil
|
||||
fallback, shouldCallAI := recommendationFallback(events)
|
||||
return Analysis{Evidence: evidence, Encoded: encoded, KnownEvidence: known, Fallback: fallback, ShouldCallAI: shouldCallAI}, nil
|
||||
}
|
||||
|
||||
func upcomingEvents(catalog []growth.Event, start time.Time, limit int) []UpcomingEvent {
|
||||
@@ -108,10 +157,18 @@ func upcomingEvents(catalog []growth.Event, start time.Time, limit int) []Upcomi
|
||||
EventID: event.ID,
|
||||
EventTitleFA: event.TitleFA,
|
||||
Date: event.StartDate,
|
||||
EndDate: event.EndDate,
|
||||
OccasionKey: event.OccasionKey,
|
||||
EventVariant: event.EventVariant,
|
||||
EventType: event.Family,
|
||||
OfficialHoliday: event.OfficialHoliday,
|
||||
MerchantCategories: append([]string(nil), event.Categories...),
|
||||
BehavioralChannels: append([]string(nil), event.Tags...),
|
||||
InsightBasis: InsightBasisBusinessProfile,
|
||||
ProfileEvidence: &ProfileEvidence{MatchedSignals: []string{}},
|
||||
RankingScore: event.ImportanceBPS + event.RelevanceBPS + event.ConfidenceBPS,
|
||||
CommercialPosture: event.CommercialPosture,
|
||||
Tone: event.Tone,
|
||||
})
|
||||
if len(result) == limit {
|
||||
break
|
||||
@@ -120,38 +177,281 @@ func upcomingEvents(catalog []growth.Event, start time.Time, limit int) []Upcomi
|
||||
return result
|
||||
}
|
||||
|
||||
func recommendationFallback(events []UpcomingEvent) (*AICopy, bool) {
|
||||
if len(events) < 3 {
|
||||
return nil, false
|
||||
}
|
||||
copy := &AICopy{EventRecommendations: fallbackEventRecommendations(events)}
|
||||
return copy, len(events) <= 5
|
||||
}
|
||||
|
||||
func evidencePolicy(event UpcomingEvent) EvidencePolicy {
|
||||
materialDecline := event.HistoricalEvidence != nil && event.HistoricalEvidence.MaterialDecline
|
||||
promotable := event.CommercialPosture != "NO_PROMOTION" && event.CommercialPosture != "CAUTION" && event.Tone != "MOURNING" && event.Tone != "MEMORIAL" && !materialDecline
|
||||
return EvidencePolicy{InsightBasis: event.InsightBasis, MaterialDecline: materialDecline, Promotable: promotable}
|
||||
}
|
||||
|
||||
func fallbackEventRecommendations(events []UpcomingEvent) []EventRecommendation {
|
||||
events = slices.Clone(events)
|
||||
slices.SortFunc(events, func(a, b UpcomingEvent) int {
|
||||
if a.InsightBasis != b.InsightBasis {
|
||||
if a.InsightBasis == InsightBasisHistorical {
|
||||
return -1
|
||||
}
|
||||
if b.InsightBasis == InsightBasisHistorical {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
if a.RankingScore != b.RankingScore {
|
||||
if a.RankingScore > b.RankingScore {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if !a.Date.Equal(b.Date) {
|
||||
if a.Date.Before(b.Date) {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
return strings.Compare(a.EvidenceID, b.EvidenceID)
|
||||
})
|
||||
limit := min(len(events), 3)
|
||||
result := make([]EventRecommendation, 0, limit)
|
||||
for _, event := range events[:limit] {
|
||||
result = append(result, EventRecommendation{EvidenceID: event.EvidenceID, RelevanceReason: "این مناسبت در تقویم بررسیشده بهعنوان رویدادی مرتبط با کسبوکار ثبت شده است."})
|
||||
shares := normalizedPriorityShares(events[:limit])
|
||||
for index, event := range events[:limit] {
|
||||
result = append(result, EventRecommendation{EvidenceID: event.EvidenceID, PriorityPercent: shares[index], Stance: fallbackStance(event), RelevanceReason: fallbackReason(event), InsightBasis: event.InsightBasis})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizedPriorityShares(events []UpcomingEvent) []int {
|
||||
if len(events) == 0 {
|
||||
return nil
|
||||
}
|
||||
shares := make([]int, len(events))
|
||||
if len(events) != 3 {
|
||||
for index := range shares {
|
||||
shares[index] = 100 / len(events)
|
||||
}
|
||||
for index := 0; index < 100%len(events); index++ {
|
||||
shares[index]++
|
||||
}
|
||||
return shares
|
||||
}
|
||||
if events[0].RankingScore == events[1].RankingScore && events[1].RankingScore == events[2].RankingScore {
|
||||
return []int{34, 33, 33}
|
||||
}
|
||||
weights := make([]int64, 3)
|
||||
var total int64
|
||||
for index, event := range events {
|
||||
weights[index] = max(event.RankingScore, 1)
|
||||
total += weights[index]
|
||||
}
|
||||
remainders := make([]int64, 3)
|
||||
assigned := 0
|
||||
for index, weight := range weights {
|
||||
numerator := weight * 97
|
||||
shares[index] = 1 + int(numerator/total)
|
||||
remainders[index] = numerator % total
|
||||
assigned += shares[index]
|
||||
}
|
||||
for assigned < 100 {
|
||||
best := 0
|
||||
for index := 1; index < len(remainders); index++ {
|
||||
if remainders[index] > remainders[best] {
|
||||
best = index
|
||||
}
|
||||
}
|
||||
shares[best]++
|
||||
remainders[best] = -1
|
||||
assigned++
|
||||
}
|
||||
return shares
|
||||
}
|
||||
|
||||
func fallbackStance(event UpcomingEvent) EventStance {
|
||||
if !evidencePolicy(event).Promotable {
|
||||
return StanceCaution
|
||||
}
|
||||
if event.HistoricalEvidence != nil {
|
||||
if event.HistoricalEvidence.MaterialDecline {
|
||||
return StanceCaution
|
||||
}
|
||||
if event.HistoricalEvidence.MaterialWin {
|
||||
return StancePromote
|
||||
}
|
||||
return StancePrepare
|
||||
}
|
||||
switch event.CommercialPosture {
|
||||
case "CAUTION", "NO_PROMOTION":
|
||||
return StanceCaution
|
||||
case "PROMOTE":
|
||||
return StancePromote
|
||||
default:
|
||||
return StancePrepare
|
||||
}
|
||||
}
|
||||
|
||||
func fallbackReason(event UpcomingEvent) string {
|
||||
if event.HistoricalEvidence != nil {
|
||||
if event.HistoricalEvidence.MaterialDecline {
|
||||
return "تجربه قبلی شما در این مناسبت نیاز به برنامهریزی محتاطانه را نشان میدهد."
|
||||
}
|
||||
if event.HistoricalEvidence.MaterialWin {
|
||||
return "تجربه قبلی شما در این مناسبت از آمادگی برای یک کمپین هدفمند پشتیبانی میکند."
|
||||
}
|
||||
return "تجربه قبلی شما در این مناسبت برای برنامهریزی دوباره قابل استفاده است."
|
||||
}
|
||||
if event.ProfileEvidence != nil && len(event.ProfileEvidence.MatchedSignals) > 0 {
|
||||
return "این مناسبت با زمینه فعالیت و کانالهای فروش ثبتشده شما همخوان است."
|
||||
}
|
||||
return "این مناسبت با شناخت فعلی از کسبوکار شما برای برنامهریزی بررسی شده است."
|
||||
}
|
||||
|
||||
func applyEventEvidence(event UpcomingEvent, prior *growth.Event, effect growth.EventEffect, unavailableReason growth.SuppressionReason) UpcomingEvent {
|
||||
if prior != nil && effect.Eligible {
|
||||
event.InsightBasis = InsightBasisHistorical
|
||||
event.HistoricalEvidence = &HistoricalEvidence{
|
||||
PriorStartDate: prior.StartDate,
|
||||
PriorEndDate: prior.EndDate,
|
||||
ObservationCount: effect.ObservationCount,
|
||||
PaidSessionUpliftBPS: effect.PaidSessionUpliftBPS,
|
||||
AmountUpliftBPS: effect.AmountUpliftBPS,
|
||||
MaterialWin: effect.MaterialWin,
|
||||
MaterialDecline: effect.MaterialDecline,
|
||||
}
|
||||
event.ProfileEvidence = nil
|
||||
event.RankingScore += 30_000 + abs64(effect.PaidSessionUpliftBPS)/10 + abs64(effect.AmountUpliftBPS)/10
|
||||
return event
|
||||
}
|
||||
if event.ProfileEvidence == nil {
|
||||
event.ProfileEvidence = &ProfileEvidence{MatchedSignals: []string{}}
|
||||
}
|
||||
event.InsightBasis = InsightBasisBusinessProfile
|
||||
event.HistoricalEvidence = nil
|
||||
if unavailableReason == "" {
|
||||
unavailableReason = growth.SuppressionInsufficientHistory
|
||||
}
|
||||
event.ProfileEvidence.HistoryUnavailableReason = unavailableReason
|
||||
return event
|
||||
}
|
||||
|
||||
func abs64(value int64) int64 {
|
||||
if value < 0 {
|
||||
return -value
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func matchedProfileSignals(event growth.Event, context MerchantContext) []string {
|
||||
eventTerms := normalizedTerms(append(slices.Clone(event.Categories), event.Tags...))
|
||||
if len(eventTerms) == 0 {
|
||||
return []string{}
|
||||
}
|
||||
result := make([]string, 0)
|
||||
for _, signal := range append([]string{context.Category}, append(slices.Clone(context.Niches), context.SalesChannels...)...) {
|
||||
if _, ok := eventTerms[strings.ToLower(strings.TrimSpace(signal))]; ok {
|
||||
result = append(result, signal)
|
||||
}
|
||||
}
|
||||
result = dedupeStable(result)
|
||||
return result
|
||||
}
|
||||
|
||||
func normalizedTerms(values []string) map[string]struct{} {
|
||||
result := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.ToLower(strings.TrimSpace(value))
|
||||
if value != "" {
|
||||
result[value] = struct{}{}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func dedupeStable(values []string) []string {
|
||||
result := make([]string, 0, len(values))
|
||||
seen := make(map[string]struct{}, len(values))
|
||||
for _, value := range values {
|
||||
value = strings.TrimSpace(value)
|
||||
key := strings.ToLower(value)
|
||||
if value == "" {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[key]; ok {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
result = append(result, value)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (b *PostgreSQLBuilder) loadMerchantContext(ctx context.Context, merchantID int64, terminalID string) (MerchantContext, error) {
|
||||
var context MerchantContext
|
||||
if err := b.pool.QueryRow(ctx, `SELECT category_title FROM merchants WHERE id=$1`, merchantID).Scan(&context.Category); err != nil {
|
||||
var category string
|
||||
if err := b.pool.QueryRow(ctx, `SELECT category_title FROM merchants WHERE id=$1`, merchantID).Scan(&category); err != nil {
|
||||
return MerchantContext{}, fmt.Errorf("loading merchant category: %w", err)
|
||||
}
|
||||
var profileNiches, profileChannels []byte
|
||||
profileErr := b.pool.QueryRow(ctx, `SELECT p.niches,p.sales_channels FROM growth_coach_profiles p
|
||||
JOIN terminals t ON t.id=p.terminal_id
|
||||
WHERE p.merchant_id=$1 AND ($2='' OR t.source_key=$2)
|
||||
ORDER BY p.version DESC LIMIT 1`, merchantID, terminalID).Scan(&profileNiches, &profileChannels)
|
||||
if profileErr == nil {
|
||||
_ = json.Unmarshal(profileNiches, &context.Niches)
|
||||
_ = json.Unmarshal(profileChannels, &context.SalesChannels)
|
||||
} else if profileErr != pgx.ErrNoRows {
|
||||
return MerchantContext{}, fmt.Errorf("loading growth event profile: %w", profileErr)
|
||||
rows, err := b.pool.Query(ctx, `SELECT p.business_description,p.niches,p.sales_channels
|
||||
FROM growth_coach_profiles p
|
||||
WHERE p.merchant_id=$1 AND p.is_current AND ($2='' OR p.terminal_source_key=$2)
|
||||
ORDER BY p.terminal_source_key,p.id`, merchantID, terminalID)
|
||||
if err != nil {
|
||||
return MerchantContext{}, fmt.Errorf("loading growth event profiles: %w", err)
|
||||
}
|
||||
if context.Niches == nil {
|
||||
context.Niches = []string{}
|
||||
defer rows.Close()
|
||||
profiles := make([]MerchantContext, 0)
|
||||
for rows.Next() {
|
||||
var description string
|
||||
var nichesJSON, channelsJSON []byte
|
||||
if err := rows.Scan(&description, &nichesJSON, &channelsJSON); err != nil {
|
||||
return MerchantContext{}, fmt.Errorf("scanning growth event profile: %w", err)
|
||||
}
|
||||
profile := MerchantContext{BusinessDescriptions: []string{description}}
|
||||
if err := json.Unmarshal(nichesJSON, &profile.Niches); err != nil {
|
||||
return MerchantContext{}, fmt.Errorf("decoding growth event profile niches: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(channelsJSON, &profile.SalesChannels); err != nil {
|
||||
return MerchantContext{}, fmt.Errorf("decoding growth event profile sales channels: %w", err)
|
||||
}
|
||||
profiles = append(profiles, profile)
|
||||
}
|
||||
if context.SalesChannels == nil {
|
||||
context.SalesChannels = []string{}
|
||||
if err := rows.Err(); err != nil {
|
||||
return MerchantContext{}, fmt.Errorf("iterating growth event profiles: %w", err)
|
||||
}
|
||||
return context, nil
|
||||
return mergeMerchantContexts(category, profiles), nil
|
||||
}
|
||||
|
||||
func mergeMerchantContexts(category string, profiles []MerchantContext) MerchantContext {
|
||||
result := MerchantContext{Category: category, BusinessDescriptions: []string{}, Niches: []string{}, SalesChannels: []string{}}
|
||||
for _, profile := range profiles {
|
||||
result.BusinessDescriptions = append(result.BusinessDescriptions, profile.BusinessDescriptions...)
|
||||
result.Niches = append(result.Niches, profile.Niches...)
|
||||
result.SalesChannels = append(result.SalesChannels, profile.SalesChannels...)
|
||||
}
|
||||
result.BusinessDescriptions = dedupeStable(result.BusinessDescriptions)
|
||||
result.Niches = dedupeStable(result.Niches)
|
||||
result.SalesChannels = dedupeStable(result.SalesChannels)
|
||||
result.BusinessDescriptions = boundedProfileValues(result.BusinessDescriptions, 5, 320)
|
||||
result.Niches = boundedProfileValues(result.Niches, 20, 80)
|
||||
result.SalesChannels = boundedProfileValues(result.SalesChannels, 20, 80)
|
||||
return result
|
||||
}
|
||||
|
||||
func boundedProfileValues(values []string, countLimit, runeLimit int) []string {
|
||||
if len(values) > countLimit {
|
||||
values = values[:countLimit]
|
||||
}
|
||||
result := make([]string, len(values))
|
||||
for index, value := range values {
|
||||
runes := []rune(value)
|
||||
if len(runes) > runeLimit {
|
||||
runes = runes[:runeLimit]
|
||||
}
|
||||
result[index] = string(runes)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (c MerchantContext) terms() []string {
|
||||
@@ -162,8 +462,8 @@ func (c MerchantContext) terms() []string {
|
||||
}
|
||||
|
||||
func (b *PostgreSQLBuilder) loadCatalog(ctx context.Context, terms []string, from time.Time, limit int) ([]growth.Event, error) {
|
||||
rows, err := b.pool.Query(ctx, `SELECT e.event_key,e.title_fa,e.start_date,e.end_date,e.event_type,e.official_holiday,
|
||||
e.source_uri,e.source_version,e.merchant_categories,e.behavioral_channels
|
||||
rows, err := b.pool.Query(ctx, `SELECT e.event_key,e.occasion_key,e.event_variant,e.title_fa,e.start_date,e.end_date,e.event_type,e.tone,e.commercial_posture,e.official_holiday,
|
||||
e.source_uri,e.source_version,e.merchant_categories,e.behavioral_channels,e.relevance_bps,e.confidence_bps,e.importance_bps,e.lead_days
|
||||
FROM calendar_events e JOIN calendar_catalog_versions v ON v.id=e.catalog_version_id AND v.status='ACTIVE'
|
||||
WHERE e.start_date >= $1
|
||||
AND (e.merchant_categories ?| $2::text[] OR e.behavioral_channels ?| $2::text[]
|
||||
@@ -176,21 +476,10 @@ func (b *PostgreSQLBuilder) loadCatalog(ctx context.Context, terms []string, fro
|
||||
defer rows.Close()
|
||||
var catalog []growth.Event
|
||||
for rows.Next() {
|
||||
var event growth.Event
|
||||
var eventType string
|
||||
var categoriesJSON, channelsJSON []byte
|
||||
if err := rows.Scan(&event.ID, &event.TitleFA, &event.StartDate, &event.EndDate, &eventType, &event.OfficialHoliday, &event.Source, &event.SourceVersion, &categoriesJSON, &channelsJSON); err != nil {
|
||||
event, err := scanGrowthEvent(rows)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("scanning reviewed growth event: %w", err)
|
||||
}
|
||||
_ = json.Unmarshal(categoriesJSON, &event.Categories)
|
||||
_ = json.Unmarshal(channelsJSON, &event.Tags)
|
||||
event.Family = eventType
|
||||
event.Universal = len(event.Categories) == 0
|
||||
if eventType == "COMMERCIAL" || eventType == "SEASONAL" || eventType == "EDUCATION" || eventType == "ECONOMIC" {
|
||||
event.Kind = growth.EventCommercial
|
||||
} else {
|
||||
event.Kind = growth.EventOfficial
|
||||
}
|
||||
catalog = append(catalog, event)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
@@ -202,6 +491,132 @@ func (b *PostgreSQLBuilder) loadCatalog(ctx context.Context, terms []string, fro
|
||||
return catalog, nil
|
||||
}
|
||||
|
||||
type eventRowScanner interface {
|
||||
Scan(dest ...any) error
|
||||
}
|
||||
|
||||
func scanGrowthEvent(row eventRowScanner) (growth.Event, error) {
|
||||
var event growth.Event
|
||||
var eventType string
|
||||
var categoriesJSON, channelsJSON []byte
|
||||
if err := row.Scan(&event.ID, &event.OccasionKey, &event.EventVariant, &event.TitleFA, &event.StartDate, &event.EndDate, &eventType, &event.Tone, &event.CommercialPosture, &event.OfficialHoliday, &event.Source, &event.SourceVersion, &categoriesJSON, &channelsJSON, &event.RelevanceBPS, &event.ConfidenceBPS, &event.ImportanceBPS, &event.LeadDays); err != nil {
|
||||
return growth.Event{}, err
|
||||
}
|
||||
if err := json.Unmarshal(categoriesJSON, &event.Categories); err != nil {
|
||||
return growth.Event{}, fmt.Errorf("decoding merchant categories: %w", err)
|
||||
}
|
||||
if err := json.Unmarshal(channelsJSON, &event.Tags); err != nil {
|
||||
return growth.Event{}, fmt.Errorf("decoding behavioral channels: %w", err)
|
||||
}
|
||||
event.Family = eventType
|
||||
event.Universal = len(event.Categories) == 0
|
||||
if eventType == "COMMERCIAL" || eventType == "SEASONAL" || eventType == "EDUCATION" || eventType == "ECONOMIC" {
|
||||
event.Kind = growth.EventCommercial
|
||||
} else {
|
||||
event.Kind = growth.EventOfficial
|
||||
}
|
||||
return event, nil
|
||||
}
|
||||
|
||||
func (b *PostgreSQLBuilder) loadEventHistory(ctx context.Context, merchantID int64, terminalID string, upcoming growth.Event) (*growth.Event, growth.EventEffect, growth.SuppressionReason, error) {
|
||||
prior, err := b.loadMostRecentExactOccurrence(ctx, upcoming)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, growth.EventEffect{}, growth.SuppressionInsufficientHistory, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, growth.EventEffect{}, "", err
|
||||
}
|
||||
history, err := b.loadDailyEventHistory(ctx, merchantID, terminalID, prior.StartDate.AddDate(0, 0, -56), prior.EndDate)
|
||||
if err != nil {
|
||||
return nil, growth.EventEffect{}, "", err
|
||||
}
|
||||
catalog, err := b.loadCatalogDates(ctx, prior.StartDate.AddDate(0, 0, -56), prior.EndDate)
|
||||
if err != nil {
|
||||
return nil, growth.EventEffect{}, "", err
|
||||
}
|
||||
effect := growth.AnalyzeEventEffect(history, prior, catalog)
|
||||
if !effect.Eligible {
|
||||
return &prior, effect, effect.SuppressionReason, nil
|
||||
}
|
||||
return &prior, effect, "", nil
|
||||
}
|
||||
|
||||
func (b *PostgreSQLBuilder) loadMostRecentExactOccurrence(ctx context.Context, upcoming growth.Event) (growth.Event, error) {
|
||||
row := b.pool.QueryRow(ctx, `SELECT e.event_key,e.occasion_key,e.event_variant,e.title_fa,e.start_date,e.end_date,e.event_type,e.tone,e.commercial_posture,e.official_holiday,
|
||||
e.source_uri,e.source_version,e.merchant_categories,e.behavioral_channels,e.relevance_bps,e.confidence_bps,e.importance_bps,e.lead_days
|
||||
FROM calendar_events e JOIN calendar_catalog_versions v ON v.id=e.catalog_version_id AND v.status='ACTIVE'
|
||||
WHERE e.occasion_key=$1 AND e.event_variant=$2 AND e.end_date<$3
|
||||
ORDER BY e.end_date DESC,e.start_date DESC,e.event_key LIMIT 1`, upcoming.OccasionKey, upcoming.EventVariant, upcoming.StartDate)
|
||||
event, err := scanGrowthEvent(row)
|
||||
if err != nil {
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return growth.Event{}, pgx.ErrNoRows
|
||||
}
|
||||
return growth.Event{}, fmt.Errorf("loading exact historical occasion: %w", err)
|
||||
}
|
||||
return event, nil
|
||||
}
|
||||
|
||||
func (b *PostgreSQLBuilder) loadCatalogDates(ctx context.Context, start, end time.Time) ([]growth.Event, error) {
|
||||
rows, err := b.pool.Query(ctx, `SELECT e.event_key,e.start_date,e.end_date
|
||||
FROM calendar_events e JOIN calendar_catalog_versions v ON v.id=e.catalog_version_id AND v.status='ACTIVE'
|
||||
WHERE e.end_date >= $1 AND e.start_date <= $2 ORDER BY e.start_date,e.end_date,e.event_key`, start, end)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading comparison calendar dates: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]growth.Event, 0)
|
||||
for rows.Next() {
|
||||
var event growth.Event
|
||||
if err := rows.Scan(&event.ID, &event.StartDate, &event.EndDate); err != nil {
|
||||
return nil, fmt.Errorf("scanning comparison calendar date: %w", err)
|
||||
}
|
||||
result = append(result, event)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterating comparison calendar dates: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (b *PostgreSQLBuilder) loadDailyEventHistory(ctx context.Context, merchantID int64, terminalID string, start, end time.Time) ([]growth.DailyPoint, error) {
|
||||
location := tehranLocation()
|
||||
startLocal := time.Date(start.Year(), start.Month(), start.Day(), 0, 0, 0, 0, location)
|
||||
endExclusive := time.Date(end.Year(), end.Month(), end.Day(), 0, 0, 0, 0, location).AddDate(0, 0, 1)
|
||||
rows, err := b.pool.Query(ctx, `SELECT (ps.created_at AT TIME ZONE 'Asia/Tehran')::date,
|
||||
count(*),count(*) FILTER(WHERE ps.status IN('Paid','Verified')),
|
||||
coalesce(sum(ps.amount_rial) FILTER(WHERE ps.status IN('Paid','Verified')),0)
|
||||
FROM payment_sessions ps JOIN terminals t ON t.id=ps.terminal_id AND t.merchant_id=ps.merchant_id
|
||||
WHERE ps.merchant_id=$1 AND ($2='' OR t.source_key=$2) AND ps.created_at >= $3 AND ps.created_at < $4
|
||||
GROUP BY (ps.created_at AT TIME ZONE 'Asia/Tehran')::date
|
||||
ORDER BY (ps.created_at AT TIME ZONE 'Asia/Tehran')::date`, merchantID, terminalID, startLocal.UTC(), endExclusive.UTC())
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading event daily aggregates: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
result := make([]growth.DailyPoint, 0)
|
||||
for rows.Next() {
|
||||
var point growth.DailyPoint
|
||||
if err := rows.Scan(&point.Date, &point.SessionCount, &point.PaidSessionCount, &point.PaidAmountRial); err != nil {
|
||||
return nil, fmt.Errorf("scanning event daily aggregate: %w", err)
|
||||
}
|
||||
point.Date = time.Date(point.Date.Year(), point.Date.Month(), point.Date.Day(), 0, 0, 0, 0, location)
|
||||
result = append(result, point)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, fmt.Errorf("iterating event daily aggregates: %w", err)
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func tehranLocation() *time.Location {
|
||||
location, err := time.LoadLocation("Asia/Tehran")
|
||||
if err != nil {
|
||||
return time.FixedZone("Asia/Tehran", 3*60*60+30*60)
|
||||
}
|
||||
return location
|
||||
}
|
||||
|
||||
func (b *PostgreSQLBuilder) loadPeer(ctx context.Context, merchantID int64, terminalID string) (growth.PeerInput, error) {
|
||||
if terminalID != "" {
|
||||
return growth.PeerInput{TerminalScoped: true}, nil
|
||||
@@ -263,10 +678,12 @@ func (t EvidenceTools) Execute(_ context.Context, name string, arguments json.Ra
|
||||
}
|
||||
}
|
||||
case "get_campaign_playbook":
|
||||
for _, item := range t.analysis.Fallback.Briefs {
|
||||
for _, id := range item.EvidenceIDs {
|
||||
if id == args.EvidenceID {
|
||||
return json.Marshal(item)
|
||||
if t.analysis.Fallback != nil {
|
||||
for _, item := range t.analysis.Fallback.Briefs {
|
||||
for _, id := range item.EvidenceIDs {
|
||||
if id == args.EvidenceID {
|
||||
return json.Marshal(item)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user