284 lines
11 KiB
Go
284 lines
11 KiB
Go
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") }
|