feat: orchestrate deterministic growth report generation
This commit is contained in:
213
internal/growthreport/adapter.go
Normal file
213
internal/growthreport/adapter.go
Normal file
@@ -0,0 +1,213 @@
|
||||
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, `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 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 ORDER BY 1`, 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, 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)
|
||||
}
|
||||
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") }
|
||||
Reference in New Issue
Block a user