package growthreport import ( "context" "encoding/json" "fmt" "time" "git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/growth" "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 { Forecast growth.Forecast `json:"forecast"` HistoricalWins []HistoricalWin `json:"historicalWins"` Opportunities []growth.Opportunity `json:"opportunities"` Risks []growth.Risk `json:"risks"` Peer *PeerEvidence `json:"peer,omitempty"` FallbackBriefs []growth.CampaignBrief `json:"fallbackBriefs"` } 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 } func NewPostgreSQLBuilder(pool *pgxpool.Pool) *PostgreSQLBuilder { return &PostgreSQLBuilder{pool: pool} } func (b *PostgreSQLBuilder) Build(ctx context.Context, merchantID int64, terminalID string) (Analysis, error) { catalog := growth.Catalog1405() var category string if err := b.pool.QueryRow(ctx, `SELECT category_title FROM merchants WHERE id=$1`, merchantID).Scan(&category); err != nil { return Analysis{}, fmt.Errorf("loading merchant category: %w", err) } rows, err := b.pool.Query(ctx, `WITH bounds AS (SELECT min(created_at AT TIME ZONE 'Asia/Tehran')::date first_day,max(created_at AT TIME ZONE 'Asia/Tehran')::date last_day FROM payment_sessions), days AS (SELECT generate_series(first_day,last_day,interval '1 day')::date local_date FROM bounds), scoped AS ( SELECT (ps.created_at AT TIME ZONE 'Asia/Tehran')::date local_date,count(*) sessions,count(*) FILTER(WHERE ps.status IN ('Paid','Verified')) paid_sessions,coalesce(sum(ps.amount_rial) FILTER(WHERE ps.status IN ('Paid','Verified')),0) paid_amount FROM payment_sessions ps WHERE ps.merchant_id=$1 AND ($2='' OR ps.terminal_id=(SELECT id FROM terminals WHERE merchant_id=$1 AND source_key=$2)) GROUP BY 1) SELECT d.local_date,coalesce(s.sessions,0),coalesce(s.paid_sessions,0),coalesce(s.paid_amount,0) FROM days d LEFT JOIN scoped s USING(local_date) ORDER BY d.local_date`, merchantID, terminalID) if err != nil { return Analysis{}, fmt.Errorf("loading growth history: %w", err) } defer rows.Close() history := make([]growth.DailyPoint, 0, 366) for rows.Next() { var point growth.DailyPoint if err := rows.Scan(&point.Date, &point.SessionCount, &point.PaidSessionCount, &point.PaidAmountRial); err != nil { return Analysis{}, fmt.Errorf("scanning growth history: %w", err) } for _, event := range catalog { if !point.Date.Before(event.StartDate) && !point.Date.After(event.EndDate) { point.EventIDs = append(point.EventIDs, event.ID) } } history = append(history, point) } if err := rows.Err(); err != nil { return Analysis{}, fmt.Errorf("iterating growth history: %w", err) } var latest time.Time if err := b.pool.QueryRow(ctx, `SELECT max(created_at AT TIME ZONE 'Asia/Tehran')::date FROM payment_sessions`).Scan(&latest); err != nil { return Analysis{}, fmt.Errorf("loading dataset watermark date: %w", err) } forecastStart := latest.AddDate(0, 0, 1) peer, err := b.loadPeer(ctx, merchantID, terminalID) if err != nil { return Analysis{}, err } deterministic := growth.BuildDeterministicReport(growth.ReportInput{History: history, Catalog: catalog, ForecastStart: forecastStart, HorizonDays: 90, Category: category, Peer: &peer}) events := make(map[string]growth.Event, len(catalog)) for _, event := range catalog { events[event.ID] = event } evidence := Evidence{Forecast: deterministic.Forecast, Opportunities: deterministic.Opportunities, Risks: deterministic.Risks, FallbackBriefs: deterministic.FallbackBriefs} for _, effect := range deterministic.HistoricalWins { event := events[effect.EventID] evidence.HistoricalWins = append(evidence.HistoricalWins, HistoricalWin{EventEffect: effect, EventTitleFA: event.TitleFA, StartDate: event.StartDate, EndDate: event.EndDate}) } if deterministic.Peer != nil { evidence.Peer = &PeerEvidence{State: deterministic.Peer.State, Reason: deterministic.Peer.Reason, EligiblePeerCount: deterministic.Peer.EligiblePeerCount, CombinedPeerSessionCount: deterministic.Peer.CombinedPeerSessions, MedianPaidSessions: deterministic.Peer.MedianPaidSessions, PercentileBPS: deterministic.Peer.PercentileBPS} } encoded, err := json.Marshal(evidence) if err != nil { return Analysis{}, fmt.Errorf("encoding deterministic growth evidence: %w", err) } known := map[string]struct{}{} for _, win := range evidence.HistoricalWins { known[win.EvidenceID] = struct{}{} } for _, opportunity := range evidence.Opportunities { known[opportunity.EvidenceID] = struct{}{} } for _, risk := range evidence.Risks { known[risk.EvidenceID] = struct{}{} } return Analysis{Evidence: evidence, Encoded: encoded, KnownEvidence: known, Fallback: convertFallback(deterministic.FallbackBriefs), ShouldCallAI: deterministic.Forecast.Confidence != growth.ConfidenceInsufficientData && (len(deterministic.Opportunities) > 0 || len(deterministic.Risks) > 0)}, 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{"confidence": t.analysis.Evidence.Forecast.Confidence, "opportunityEvidenceIds": evidenceIDs(t.analysis.Evidence.Opportunities), "riskEvidenceIds": riskEvidenceIDs(t.analysis.Evidence.Risks)}) case "get_peer_context": if t.analysis.Evidence.Peer == nil { return []byte(`{"state":"SUPPRESSED"}`), nil } return json.Marshal(t.analysis.Evidence.Peer) case "get_event_evidence": for _, item := range t.analysis.Evidence.HistoricalWins { if item.EvidenceID == args.EvidenceID { return json.Marshal(item) } } for _, item := range t.analysis.Evidence.Opportunities { 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 evidenceIDs(items []growth.Opportunity) []string { result := make([]string, 0, len(items)) for _, item := range items { result = append(result, item.EvidenceID) } return result } func riskEvidenceIDs(items []growth.Risk) []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") }