package growthreport import ( "context" "encoding/json" "fmt" "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"` } // UpcomingEvent is calendar metadata only. It deliberately contains no // payment forecast, historical-performance, or peer-comparison fields. 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"` } // 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"` } type Analysis struct { Evidence Evidence Encoded json.RawMessage KnownEvidence map[string]struct{} 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) 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{}{} for _, event := range evidence.Events { known[event.EvidenceID] = struct{}{} } return Analysis{Evidence: evidence, Encoded: encoded, KnownEvidence: known, Fallback: AICopy{EventRecommendations: fallbackEventRecommendations(events)}, ShouldCallAI: len(events) == 5}, 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, EventType: event.Family, OfficialHoliday: event.OfficialHoliday, MerchantCategories: append([]string(nil), event.Categories...), BehavioralChannels: append([]string(nil), event.Tags...), }) if len(result) == limit { break } } return result } func fallbackEventRecommendations(events []UpcomingEvent) []EventRecommendation { limit := min(len(events), 3) result := make([]EventRecommendation, 0, limit) for _, event := range events[:limit] { result = append(result, EventRecommendation{EvidenceID: event.EvidenceID, RelevanceReason: "این مناسبت در تقویم بررسی‌شده به‌عنوان رویدادی مرتبط با کسب‌وکار ثبت شده است."}) } 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 { 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) } if context.Niches == nil { context.Niches = []string{} } if context.SalesChannels == nil { context.SalesChannels = []string{} } return context, nil } 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.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 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() { 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 { 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 { 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 } 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": for _, item := range t.analysis.Fallback.Briefs { for _, id := range item.EvidenceIDs { if id == args.EvidenceID { return json.Marshal(item) } } } } 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") }