fix: deliver validated AI insight responses

This commit is contained in:
2026-08-22 15:47:38 +08:00
parent ca55c2fb3d
commit 3fba4261ee
68 changed files with 6870 additions and 493 deletions

View File

@@ -7,6 +7,7 @@ import (
"time"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/growth"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
@@ -27,12 +28,29 @@ type PeerEvidence struct {
}
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"`
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 {
@@ -57,73 +75,131 @@ func NewPostgreSQLBuilder(pool *pgxpool.Pool) *PostgreSQLBuilder {
}
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 := tehranToday(b.now())
peer, err := b.loadPeer(ctx, merchantID, terminalID)
merchantContext, err := b.loadMerchantContext(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}
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 _, win := range evidence.HistoricalWins {
known[win.EvidenceID] = struct{}{}
for _, event := range evidence.Events {
known[event.EvidenceID] = struct{}{}
}
for _, opportunity := range evidence.Opportunities {
known[opportunity.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
}
}
for _, risk := range evidence.Risks {
known[risk.EvidenceID] = struct{}{}
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 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
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) {
@@ -177,19 +253,11 @@ func (t EvidenceTools) Execute(_ context.Context, name string, arguments json.Ra
}
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)})
return json.Marshal(map[string]any{"mode": "EVENTS_ONLY", "merchantContext": t.analysis.Evidence.MerchantContext, "eventEvidenceIds": upcomingEventEvidenceIDs(t.analysis.Evidence.Events)})
case "get_peer_context":
if t.analysis.Evidence.Peer == nil {
return []byte(`{"state":"SUPPRESSED"}`), nil
}
return json.Marshal(t.analysis.Evidence.Peer)
return []byte(`{"state":"UNAVAILABLE","reason":"EVENTS_ONLY"}`), nil
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 {
for _, item := range t.analysis.Evidence.Events {
if item.EvidenceID == args.EvidenceID {
return json.Marshal(item)
}
@@ -205,14 +273,7 @@ func (t EvidenceTools) Execute(_ context.Context, name string, arguments json.Ra
}
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 {
func upcomingEventEvidenceIDs(items []UpcomingEvent) []string {
result := make([]string, 0, len(items))
for _, item := range items {
result = append(result, item.EvidenceID)