fix: deliver validated AI insight responses
This commit is contained in:
@@ -3,11 +3,13 @@ package insight
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"math"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
)
|
||||
|
||||
@@ -35,11 +37,36 @@ func NewPostgreSQLBuilder(pool *pgxpool.Pool) *PostgreSQLBuilder {
|
||||
}
|
||||
|
||||
type aggregate struct {
|
||||
Sessions, Paid, Failed, Amount, Fees int64
|
||||
AverageLatencyMS int64
|
||||
Sessions, Paid, Verified, Unverified, Failed, Reversed, Amount, Fees int64
|
||||
AverageLatencyMS int64
|
||||
}
|
||||
|
||||
type businessContext struct {
|
||||
Industry string `json:"industry"`
|
||||
ProfileVersion int `json:"profileVersion,omitempty"`
|
||||
Description string `json:"businessDescription,omitempty"`
|
||||
Niches []string `json:"niches,omitempty"`
|
||||
SalesChannels []string `json:"salesChannels,omitempty"`
|
||||
PrimaryGoal string `json:"primaryGoal,omitempty"`
|
||||
Constraints string `json:"constraints,omitempty"`
|
||||
ChangedFields []string `json:"changedFields,omitempty"`
|
||||
ChangedValues map[string]businessFieldChange `json:"changedValues,omitempty"`
|
||||
}
|
||||
|
||||
type businessFieldChange struct {
|
||||
Before any `json:"before"`
|
||||
After any `json:"after"`
|
||||
}
|
||||
|
||||
func (b *PostgreSQLBuilder) Build(ctx context.Context, merchantID int64, terminalID string, module Module) (Analysis, error) {
|
||||
return b.build(ctx, merchantID, terminalID, module, nil)
|
||||
}
|
||||
|
||||
func (b *PostgreSQLBuilder) BuildForPeriod(ctx context.Context, merchantID int64, terminalID string, module Module, period PeriodScope) (Analysis, error) {
|
||||
return b.build(ctx, merchantID, terminalID, module, &period)
|
||||
}
|
||||
|
||||
func (b *PostgreSQLBuilder) build(ctx context.Context, merchantID int64, terminalID string, module Module, period *PeriodScope) (Analysis, error) {
|
||||
if terminalID != "" {
|
||||
var exists bool
|
||||
if err := b.pool.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM terminals WHERE merchant_id=$1 AND source_key=$2)`, merchantID, terminalID).Scan(&exists); err != nil {
|
||||
@@ -54,11 +81,20 @@ func (b *PostgreSQLBuilder) Build(ctx context.Context, merchantID int64, termina
|
||||
return Analysis{}, fmt.Errorf("loading insight watermark: %w", err)
|
||||
}
|
||||
timeContext := BuildTimeContext(b.now(), dataThrough)
|
||||
latest, err := b.aggregate(ctx, merchantID, terminalID, dataThrough.AddDate(0, 0, -29), dataThrough)
|
||||
if err != nil {
|
||||
return Analysis{}, err
|
||||
var latest, previous aggregate
|
||||
var err error
|
||||
if period != nil && module == ModuleTransactions {
|
||||
latest, err = b.aggregateRange(ctx, merchantID, terminalID, period.StartAt, period.EndAt)
|
||||
if err == nil {
|
||||
duration := period.EndAt.Sub(period.StartAt)
|
||||
previous, err = b.aggregateRange(ctx, merchantID, terminalID, period.StartAt.Add(-duration), period.StartAt)
|
||||
}
|
||||
} else {
|
||||
latest, err = b.aggregate(ctx, merchantID, terminalID, dataThrough.AddDate(0, 0, -29), dataThrough)
|
||||
if err == nil {
|
||||
previous, err = b.aggregate(ctx, merchantID, terminalID, dataThrough.AddDate(-1, 0, -29), dataThrough.AddDate(-1, 0, 0))
|
||||
}
|
||||
}
|
||||
previous, err := b.aggregate(ctx, merchantID, terminalID, dataThrough.AddDate(-1, 0, -29), dataThrough.AddDate(-1, 0, 0))
|
||||
if err != nil {
|
||||
return Analysis{}, err
|
||||
}
|
||||
@@ -75,11 +111,24 @@ func (b *PostgreSQLBuilder) Build(ctx context.Context, merchantID int64, termina
|
||||
"latest30Days": latest, "previousYearEquivalent": previous,
|
||||
"time": timeContext, "privacy": map[string]any{"aggregateOnly": true, "peerSuppressionApplied": module == ModulePeers},
|
||||
}
|
||||
if period != nil && module == ModuleTransactions {
|
||||
evidenceMap["selectedPeriod"] = map[string]any{"periodId": period.PeriodID, "periodType": period.PeriodType, "startAt": period.StartAt, "endAt": period.EndAt, "jalaliLabelFa": period.JalaliLabelFA}
|
||||
evidenceMap["statusDistribution"] = map[string]any{
|
||||
"total": latest.Sessions, "paidAwaitingVerification": latest.Unverified, "verified": latest.Verified, "failed": latest.Failed, "reversed": latest.Reversed,
|
||||
"bankSuccessRate": ratio(latest.Paid, latest.Sessions), "verificationCompletionRate": ratio(latest.Verified, latest.Paid), "failureRate": ratio(latest.Failed, latest.Sessions),
|
||||
}
|
||||
evidenceMap["precedingPeriodStatus"] = previous
|
||||
}
|
||||
business, err := b.businessContext(ctx, merchantID, terminalID)
|
||||
if err != nil {
|
||||
return Analysis{}, err
|
||||
}
|
||||
evidenceMap["businessContextUntrustedData"] = business
|
||||
forecastStart, err := time.Parse(time.DateOnly, timeContext.ForecastStartDate)
|
||||
if err != nil {
|
||||
return Analysis{}, fmt.Errorf("parsing forecast start: %w", err)
|
||||
}
|
||||
calendar, err := b.calendar(ctx, forecastStart, horizon(module))
|
||||
calendar, err := b.calendar(ctx, business, forecastStart, horizon(module))
|
||||
if err != nil {
|
||||
return Analysis{}, err
|
||||
}
|
||||
@@ -89,28 +138,114 @@ func (b *PostgreSQLBuilder) Build(ctx context.Context, merchantID int64, termina
|
||||
return Analysis{}, fmt.Errorf("encoding insight evidence: %w", err)
|
||||
}
|
||||
copy := deterministicCopy(module, latest, previous, comparisonAvailable, timeContext, confidence)
|
||||
if period != nil && module == ModuleTransactions {
|
||||
copy = deterministicTransactionCopy(*period, latest, previous, comparisonAvailable, timeContext, confidence)
|
||||
}
|
||||
return Analysis{Time: timeContext, Confidence: confidence, Evidence: encoded, Copy: copy, Calendar: calendar}, nil
|
||||
}
|
||||
|
||||
func (b *PostgreSQLBuilder) aggregate(ctx context.Context, merchantID int64, terminalID string, start, end time.Time) (aggregate, error) {
|
||||
var value aggregate
|
||||
err := b.pool.QueryRow(ctx, `SELECT count(*),count(*) FILTER(WHERE ps.status IN ('Paid','Verified')),count(*) FILTER(WHERE ps.status NOT IN ('Paid','Verified')),
|
||||
err := b.pool.QueryRow(ctx, `SELECT count(*),count(*) FILTER(WHERE ps.status IN ('Paid','Verified')),count(*) FILTER(WHERE ps.status='Verified'),count(*) FILTER(WHERE ps.status='Paid'),count(*) FILTER(WHERE ps.status='Failed'),count(*) FILTER(WHERE ps.status='Reversed'),
|
||||
coalesce(sum(ps.amount_rial) FILTER(WHERE ps.status IN ('Paid','Verified')),0),coalesce(sum(ps.fee_amount_rial),0),
|
||||
coalesce(avg(coalesce(pa.verify_time_ms,pa.init_time_ms,0)),0)::bigint
|
||||
FROM payment_sessions ps LEFT JOIN LATERAL (SELECT init_time_ms,verify_time_ms FROM payment_attempts WHERE session_id=ps.id ORDER BY sequence DESC LIMIT 1) pa ON true
|
||||
WHERE ps.merchant_id=$1 AND ($2='' OR ps.terminal_id=(SELECT id FROM terminals WHERE merchant_id=$1 AND source_key=$2))
|
||||
AND (ps.created_at AT TIME ZONE 'Asia/Tehran')::date BETWEEN $3 AND $4`, merchantID, terminalID, start, end).
|
||||
Scan(&value.Sessions, &value.Paid, &value.Failed, &value.Amount, &value.Fees, &value.AverageLatencyMS)
|
||||
Scan(&value.Sessions, &value.Paid, &value.Verified, &value.Unverified, &value.Failed, &value.Reversed, &value.Amount, &value.Fees, &value.AverageLatencyMS)
|
||||
if err != nil {
|
||||
return aggregate{}, fmt.Errorf("loading aggregate insight evidence: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func (b *PostgreSQLBuilder) calendar(ctx context.Context, start time.Time, days int) ([]CalendarContext, error) {
|
||||
func (b *PostgreSQLBuilder) aggregateRange(ctx context.Context, merchantID int64, terminalID string, start, end time.Time) (aggregate, error) {
|
||||
var value aggregate
|
||||
err := b.pool.QueryRow(ctx, `SELECT count(*),count(*) FILTER(WHERE ps.status IN ('Paid','Verified')),count(*) FILTER(WHERE ps.status='Verified'),count(*) FILTER(WHERE ps.status='Paid'),count(*) FILTER(WHERE ps.status='Failed'),count(*) FILTER(WHERE ps.status='Reversed'),
|
||||
coalesce(sum(ps.amount_rial) FILTER(WHERE ps.status IN ('Paid','Verified')),0),coalesce(sum(ps.fee_amount_rial),0),
|
||||
coalesce(avg(coalesce(pa.verify_time_ms,pa.init_time_ms,0)),0)::bigint
|
||||
FROM payment_sessions ps LEFT JOIN LATERAL (SELECT init_time_ms,verify_time_ms FROM payment_attempts WHERE session_id=ps.id ORDER BY sequence DESC LIMIT 1) pa ON true
|
||||
WHERE ps.merchant_id=$1 AND ($2='' OR ps.terminal_id=(SELECT id FROM terminals WHERE merchant_id=$1 AND source_key=$2))
|
||||
AND ps.created_at >= $3 AND ps.created_at < $4`, merchantID, terminalID, start, end).
|
||||
Scan(&value.Sessions, &value.Paid, &value.Verified, &value.Unverified, &value.Failed, &value.Reversed, &value.Amount, &value.Fees, &value.AverageLatencyMS)
|
||||
if err != nil {
|
||||
return aggregate{}, fmt.Errorf("loading period-scoped insight evidence: %w", err)
|
||||
}
|
||||
return value, nil
|
||||
}
|
||||
|
||||
func ratio(numerator, denominator int64) any {
|
||||
if denominator == 0 {
|
||||
return nil
|
||||
}
|
||||
return fmt.Sprintf("%.2f", float64(numerator)*100/float64(denominator))
|
||||
}
|
||||
|
||||
func ratioText(numerator, denominator int64) string {
|
||||
value := ratio(numerator, denominator)
|
||||
if value == nil {
|
||||
return "ناموجود"
|
||||
}
|
||||
return fmt.Sprintf("%v درصد", value)
|
||||
}
|
||||
|
||||
func (b *PostgreSQLBuilder) businessContext(ctx context.Context, merchantID int64, terminalID string) (businessContext, error) {
|
||||
var result businessContext
|
||||
if err := b.pool.QueryRow(ctx, `SELECT category_title FROM merchants WHERE id=$1`, merchantID).Scan(&result.Industry); err != nil {
|
||||
return businessContext{}, fmt.Errorf("loading insight business industry: %w", err)
|
||||
}
|
||||
if terminalID == "" {
|
||||
return result, nil
|
||||
}
|
||||
var niches, channels, changed []byte
|
||||
err := b.pool.QueryRow(ctx, `SELECT version,business_description,niches,sales_channels,primary_goal,constraints_text,changed_fields FROM growth_coach_profiles WHERE merchant_id=$1 AND terminal_source_key=$2 AND is_current`, merchantID, terminalID).Scan(&result.ProfileVersion, &result.Description, &niches, &channels, &result.PrimaryGoal, &result.Constraints, &changed)
|
||||
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
|
||||
return businessContext{}, fmt.Errorf("loading insight business profile: %w", err)
|
||||
}
|
||||
if err == nil {
|
||||
_ = json.Unmarshal(niches, &result.Niches)
|
||||
_ = json.Unmarshal(channels, &result.SalesChannels)
|
||||
_ = json.Unmarshal(changed, &result.ChangedFields)
|
||||
if result.ProfileVersion > 1 && len(result.ChangedFields) > 0 {
|
||||
var previous businessContext
|
||||
var previousNiches, previousChannels []byte
|
||||
previousErr := b.pool.QueryRow(ctx, `SELECT business_description,niches,sales_channels,primary_goal,constraints_text FROM growth_coach_profiles WHERE merchant_id=$1 AND terminal_source_key=$2 AND version=$3`, merchantID, terminalID, result.ProfileVersion-1).Scan(&previous.Description, &previousNiches, &previousChannels, &previous.PrimaryGoal, &previous.Constraints)
|
||||
if previousErr != nil && !errors.Is(previousErr, pgx.ErrNoRows) {
|
||||
return businessContext{}, fmt.Errorf("loading previous insight business profile: %w", previousErr)
|
||||
}
|
||||
if previousErr == nil {
|
||||
_ = json.Unmarshal(previousNiches, &previous.Niches)
|
||||
_ = json.Unmarshal(previousChannels, &previous.SalesChannels)
|
||||
result.ChangedValues = make(map[string]businessFieldChange, len(result.ChangedFields))
|
||||
for _, field := range result.ChangedFields {
|
||||
switch field {
|
||||
case "businessDescription":
|
||||
result.ChangedValues[field] = businessFieldChange{Before: previous.Description, After: result.Description}
|
||||
case "niches":
|
||||
result.ChangedValues[field] = businessFieldChange{Before: previous.Niches, After: result.Niches}
|
||||
case "salesChannels":
|
||||
result.ChangedValues[field] = businessFieldChange{Before: previous.SalesChannels, After: result.SalesChannels}
|
||||
case "primaryGoal":
|
||||
result.ChangedValues[field] = businessFieldChange{Before: previous.PrimaryGoal, After: result.PrimaryGoal}
|
||||
case "constraints":
|
||||
result.ChangedValues[field] = businessFieldChange{Before: previous.Constraints, After: result.Constraints}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (b *PostgreSQLBuilder) calendar(ctx context.Context, business businessContext, start time.Time, days int) ([]CalendarContext, error) {
|
||||
terms := append([]string{business.Industry}, business.Niches...)
|
||||
terms = append(terms, business.SalesChannels...)
|
||||
rows, err := b.pool.Query(ctx, `SELECT e.event_key,e.title_fa,e.start_date,e.end_date,e.tone,e.commercial_posture,e.official_holiday
|
||||
FROM calendar_events e JOIN calendar_catalog_versions v ON v.id=e.catalog_version_id AND v.status='ACTIVE'
|
||||
WHERE e.end_date+e.lag_days >= $1 AND e.start_date-e.lead_days <= $2 ORDER BY e.start_date,e.event_key`, start, start.AddDate(0, 0, days-1))
|
||||
WHERE e.end_date+e.lag_days >= $1 AND e.start_date-e.lead_days <= $2
|
||||
AND (e.event_type IN('COMMERCIAL','SEASONAL','EDUCATION','ECONOMIC','EMERGENCY_CLOSURE') OR e.merchant_categories ?| $3::text[] OR e.behavioral_channels ?| $3::text[])
|
||||
AND (NOT e.official_holiday OR e.relevance_bps>=7000 OR e.merchant_categories ?| $3::text[] OR e.behavioral_channels ?| $3::text[])
|
||||
ORDER BY (e.merchant_categories ?| $3::text[]) DESC,(e.behavioral_channels ?| $3::text[]) DESC,e.relevance_bps DESC,e.start_date,e.event_key LIMIT 12`, start, start.AddDate(0, 0, days-1), terms)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("loading active calendar context: %w", err)
|
||||
}
|
||||
@@ -176,6 +311,27 @@ func deterministicCopy(module Module, latest, previous aggregate, comparison boo
|
||||
return copy
|
||||
}
|
||||
|
||||
func deterministicTransactionCopy(period PeriodScope, latest, previous aggregate, comparison bool, _ TimeContext, confidence Confidence) Copy {
|
||||
latestBody := fmt.Sprintf("در بازهٔ %s، %d تراکنش ثبت شده است: %d پرداختشده و در انتظار تأیید، %d تأیید نهایی، %d ناموفق و %d برگشتخورده. نرخ موفقیت بانکی %s، تکمیل تأیید %s و نرخ ناموفق %s است.", period.JalaliLabelFA, latest.Sessions, latest.Unverified, latest.Verified, latest.Failed, latest.Reversed, ratioText(latest.Paid, latest.Sessions), ratioText(latest.Verified, latest.Paid), ratioText(latest.Failed, latest.Sessions))
|
||||
pastBody := "برای بازهٔ بلافاصله قبل دادهٔ کافی برای مقایسه وجود ندارد."
|
||||
if comparison {
|
||||
pastBody = fmt.Sprintf("در بازهٔ بلافاصله قبل %d تراکنش و %d پرداخت موفق بانکی ثبت شده بود؛ این مقایسه فقط تغییر مشاهدهشده را نشان میدهد و علت آن را مشخص نمیکند.", previous.Sessions, previous.Paid)
|
||||
}
|
||||
uncertainty := "این جمعبندی فقط الگوی مشاهدهشده در نمودارها را توصیف میکند و علت تغییر وضعیتها را تعیین نمیکند."
|
||||
return Copy{
|
||||
Past: Narrative{Headline: "مقایسه با بازهٔ قبل", Body: pastBody, EvidenceID: []string{"period-comparison"}},
|
||||
LatestObserved: Narrative{Headline: "خلاصهٔ وضعیت تراکنشها", Body: latestBody, EvidenceID: []string{"status-distribution", "verification-health", "failure-health"}},
|
||||
Future7Days: Narrative{Headline: "برداشت کوتاهمدت", Body: uncertainty, EvidenceID: []string{"status-distribution"}},
|
||||
Future30Days: Narrative{Headline: "برداشت روند", Body: uncertainty + " برای تصمیمگیری، ترکیب وضعیتها را در چند بازهٔ پیاپی بررسی کنید.", EvidenceID: []string{"period-comparison"}},
|
||||
Findings: []Finding{
|
||||
{EvidenceID: "status-distribution", Title: "ترکیب وضعیتها", Summary: latestBody, ScoreBPS: score(latest.Sessions, confidence), Confidence: confidence},
|
||||
{EvidenceID: "verification-health", Title: "تکمیل تأیید", Summary: fmt.Sprintf("از %d پرداخت موفق بانکی، %d مورد تأیید نهایی شده و %d مورد در انتظار تأیید است.", latest.Paid, latest.Verified, latest.Unverified), ScoreBPS: score(latest.Unverified, confidence), Confidence: confidence},
|
||||
{EvidenceID: "failure-health", Title: "تراکنشهای ناموفق", Summary: fmt.Sprintf("در این بازه %d تراکنش ناموفق و %d تراکنش برگشتخورده ثبت شده است.", latest.Failed, latest.Reversed), ScoreBPS: score(latest.Failed+latest.Reversed, confidence), Confidence: confidence},
|
||||
},
|
||||
Options: []Option{{Title: "بررسی روند وضعیتها", Description: "نمودار ترکیب وضعیتها را برای پیدا کردن تغییرهای پایدار در چند بازهٔ پیاپی بررسی کنید.", EvidenceID: []string{"status-distribution", "period-comparison"}}},
|
||||
}
|
||||
}
|
||||
|
||||
func score(impact int64, confidence Confidence) int {
|
||||
base := int(math.Min(8000, math.Log10(float64(max(int64(1), impact))+1)*1200))
|
||||
weight := map[Confidence]int{ConfidenceHigh: 10000, ConfidenceMedium: 7500, ConfidenceLow: 4500, ConfidenceInsufficientData: 2000}[confidence]
|
||||
|
||||
Reference in New Issue
Block a user