package growthreport import ( "context" "encoding/json" "errors" "fmt" "slices" "strings" "time" "git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/growth" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" ) type HistoricalWin struct { growth.EventEffect EventTitleFA string `json:"eventTitleFa"` StartDate time.Time `json:"startDate"` EndDate time.Time `json:"endDate"` } type PeerEvidence struct { State growth.PeerState `json:"state"` Reason growth.SuppressionReason `json:"reason,omitempty"` EligiblePeerCount int `json:"eligiblePeerCount"` CombinedPeerSessionCount int64 `json:"combinedPeerSessionCount"` MedianPaidSessions int64 `json:"medianPaidSessions"` PercentileBPS int64 `json:"percentileBps"` } type Evidence struct { Events []UpcomingEvent `json:"events"` MerchantContext MerchantContext `json:"merchantContext"` } 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"` 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"` BusinessDescriptions []string `json:"businessDescriptions,omitempty"` Niches []string `json:"niches"` SalesChannels []string `json:"salesChannels"` } type Analysis struct { Evidence Evidence Encoded json.RawMessage KnownEvidence KnownEvidence Fallback *AICopy ShouldCallAI bool } type Builder interface { Build(ctx context.Context, merchantID int64, terminalID string) (Analysis, error) } type PostgreSQLBuilder struct { pool *pgxpool.Pool now func() time.Time } func NewPostgreSQLBuilder(pool *pgxpool.Pool) *PostgreSQLBuilder { return &PostgreSQLBuilder{pool: pool, now: time.Now} } func (b *PostgreSQLBuilder) Build(ctx context.Context, merchantID int64, terminalID string) (Analysis, error) { forecastStart := tehranToday(b.now()) merchantContext, err := b.loadMerchantContext(ctx, merchantID, terminalID) if err != nil { return Analysis{}, err } catalog, err := b.loadCatalog(ctx, merchantContext.terms(), forecastStart, 5) if err != nil { 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 := KnownEvidence{} for _, event := range evidence.Events { known[event.EvidenceID] = evidencePolicy(event) } 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 { result := make([]UpcomingEvent, 0, limit) for _, event := range catalog { if event.StartDate.Before(start) { continue } result = append(result, UpcomingEvent{ EvidenceID: "event:" + event.ID, 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 } } 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) 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 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) } 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) } 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 err := rows.Err(); err != nil { return MerchantContext{}, fmt.Errorf("iterating growth event profiles: %w", err) } 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 { terms := make([]string, 0, 1+len(c.Niches)+len(c.SalesChannels)) terms = append(terms, c.Category) terms = append(terms, c.Niches...) return append(terms, c.SalesChannels...) } 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.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[] OR (e.event_type IN('COMMERCIAL','SEASONAL','EDUCATION','ECONOMIC') AND e.relevance_bps>=7000) OR (e.official_holiday AND e.relevance_bps>=7000)) ORDER BY e.start_date,e.end_date,e.event_key LIMIT $3`, from, terms, limit) if err != nil { return nil, fmt.Errorf("loading reviewed growth event catalog: %w", err) } defer rows.Close() var catalog []growth.Event for rows.Next() { event, err := scanGrowthEvent(rows) if err != nil { return nil, fmt.Errorf("scanning reviewed growth event: %w", err) } catalog = append(catalog, event) } if err := rows.Err(); err != nil { return nil, fmt.Errorf("iterating reviewed growth events: %w", err) } if err := growth.ValidateCatalog(catalog); err != nil { return nil, fmt.Errorf("validating reviewed growth event catalog: %w", err) } 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 } var own int64 if err := b.pool.QueryRow(ctx, `SELECT count(*) FROM payment_sessions WHERE merchant_id=$1 AND status IN ('Paid','Verified')`, merchantID).Scan(&own); err != nil { return growth.PeerInput{}, fmt.Errorf("loading merchant peer value: %w", err) } rows, err := b.pool.Query(ctx, `SELECT ps.merchant_id,count(*) FILTER(WHERE ps.status IN ('Paid','Verified')),count(*) FROM payment_sessions ps JOIN merchants m ON m.id=ps.merchant_id WHERE m.category_id=(SELECT category_id FROM merchants WHERE id=$1) AND ps.merchant_id<>$1 GROUP BY ps.merchant_id`, merchantID) if err != nil { return growth.PeerInput{}, fmt.Errorf("loading peer values: %w", err) } defer rows.Close() input := growth.PeerInput{MerchantPaidSessions: own} for rows.Next() { var peer growth.PeerMerchantValue var id int64 if err := rows.Scan(&id, &peer.PaidSessions, &peer.SessionCount); err != nil { return growth.PeerInput{}, fmt.Errorf("scanning peer value: %w", err) } input.Peers = append(input.Peers, peer) } if err := rows.Err(); err != nil { return growth.PeerInput{}, fmt.Errorf("iterating peer values: %w", err) } return input, nil } func convertFallback(items []growth.CampaignBrief) AICopy { result := AICopy{Briefs: make([]CampaignBrief, 0, len(items))} for _, item := range items { result.Briefs = append(result.Briefs, CampaignBrief{Headline: item.HeadlineFA, Interpretation: item.GuidanceFA, OfferSuggestion: item.OfferFA, MessageSuggestion: item.MessageFA, PreparationAction: item.PreparationFA, Limitation: item.LimitationFA, EvidenceIDs: item.EvidenceIDs}) } return result } type EvidenceTools struct{ analysis Analysis } func NewEvidenceTools(analysis Analysis) EvidenceTools { return EvidenceTools{analysis: analysis} } func (t EvidenceTools) Execute(_ context.Context, name string, arguments json.RawMessage) (json.RawMessage, error) { var args struct { EvidenceID string `json:"evidenceId"` } if err := json.Unmarshal(arguments, &args); err != nil { return nil, fmt.Errorf("invalid tool arguments: %w", err) } if len(args.EvidenceID) > 128 { return nil, fmt.Errorf("growth tool: invalid evidence id") } switch name { case "get_growth_context": return json.Marshal(map[string]any{"mode": "EVENTS_ONLY", "merchantContext": t.analysis.Evidence.MerchantContext, "eventEvidenceIds": upcomingEventEvidenceIDs(t.analysis.Evidence.Events)}) case "get_peer_context": return []byte(`{"state":"UNAVAILABLE","reason":"EVENTS_ONLY"}`), nil case "get_event_evidence": for _, item := range t.analysis.Evidence.Events { if item.EvidenceID == args.EvidenceID { return json.Marshal(item) } } case "get_campaign_playbook": 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) } } } } for _, item := range t.analysis.Evidence.Events { if item.EvidenceID == args.EvidenceID { return []byte(`{"state":"UNAVAILABLE","reason":"NO_PLAYBOOK"}`), nil } } } return nil, errorsNewUnknownEvidence() } func upcomingEventEvidenceIDs(items []UpcomingEvent) []string { result := make([]string, 0, len(items)) for _, item := range items { result = append(result, item.EvidenceID) } return result } func errorsNewUnknownEvidence() error { return fmt.Errorf("growth tool: unknown evidence") }