fix: deliver validated AI insight responses
This commit is contained in:
@@ -67,13 +67,22 @@ func (r *Repository) ActiveCatalogVersion(ctx context.Context) (string, error) {
|
||||
return version, nil
|
||||
}
|
||||
|
||||
func (r *Repository) DailyProviderUsage(ctx context.Context) (int64, int64, error) {
|
||||
var requests, tokens int64
|
||||
err := r.pool.QueryRow(ctx, `SELECT coalesce(sum(provider_requests),0),coalesce(sum(input_tokens+output_tokens),0) FROM ai_insight_snapshots WHERE (created_at AT TIME ZONE 'Asia/Tehran')::date=(now() AT TIME ZONE 'Asia/Tehran')::date`).Scan(&requests, &tokens)
|
||||
return requests, tokens, err
|
||||
}
|
||||
|
||||
func (r *Repository) Create(ctx context.Context, in CreateRequest) (Job, bool, error) {
|
||||
if in.Section == "" {
|
||||
in.Section = SectionFull
|
||||
}
|
||||
tx, err := r.pool.BeginTx(ctx, pgx.TxOptions{IsoLevel: pgx.Serializable})
|
||||
if err != nil {
|
||||
return Job{}, false, fmt.Errorf("beginning insight request: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
lockKey := fmt.Sprintf("ai-insight:%d:%s:%s", in.MerchantID, in.TerminalID, in.Module)
|
||||
lockKey := fmt.Sprintf("ai-insight:%d:%s:%s:%s", in.MerchantID, in.TerminalID, in.Module, in.Period.PeriodID)
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, lockKey); err != nil {
|
||||
return Job{}, false, fmt.Errorf("locking insight request: %w", err)
|
||||
}
|
||||
@@ -88,6 +97,13 @@ func (r *Repository) Create(ctx context.Context, in CreateRequest) (Job, bool, e
|
||||
return Job{}, false, fmt.Errorf("validating insight terminal: %w", err)
|
||||
}
|
||||
terminalDBID = &id
|
||||
var held bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM ai_generation_holds WHERE merchant_id=$1 AND terminal_id=$2)`, in.MerchantID, id).Scan(&held); err != nil {
|
||||
return Job{}, false, fmt.Errorf("checking terminal AI generation hold: %w", err)
|
||||
}
|
||||
if held {
|
||||
return Job{}, false, ErrOnboardingRequired
|
||||
}
|
||||
}
|
||||
if in.IdempotencyKey != "" {
|
||||
var hash string
|
||||
@@ -108,7 +124,7 @@ func (r *Repository) Create(ctx context.Context, in CreateRequest) (Job, bool, e
|
||||
var jobID string
|
||||
err := tx.QueryRow(ctx, `SELECT j.id FROM ai_insight_jobs j JOIN ai_insight_snapshots s ON s.id=j.snapshot_id
|
||||
WHERE s.merchant_id=$1 AND s.terminal_source_key IS NOT DISTINCT FROM $2::text AND s.module=$3 AND s.fingerprint=$4
|
||||
AND s.manual_refresh=false AND s.status IN ('QUEUED','GENERATING','READY','PARTIAL') ORDER BY s.created_at DESC LIMIT 1`, in.MerchantID, nilIfEmpty(in.TerminalID), in.Module, in.Fingerprint).Scan(&jobID)
|
||||
AND s.period_id IS NOT DISTINCT FROM $5::text AND s.manual_refresh=false AND s.status IN ('QUEUED','GENERATING','READY','PARTIAL') ORDER BY s.created_at DESC LIMIT 1`, in.MerchantID, nilIfEmpty(in.TerminalID), in.Module, in.Fingerprint, nilIfEmpty(in.Period.PeriodID)).Scan(&jobID)
|
||||
if err == nil {
|
||||
job, err := getJobWith(ctx, tx, in.MerchantID, jobID)
|
||||
return job, true, err
|
||||
@@ -117,29 +133,46 @@ func (r *Repository) Create(ctx context.Context, in CreateRequest) (Job, bool, e
|
||||
return Job{}, false, fmt.Errorf("checking duplicate insight: %w", err)
|
||||
}
|
||||
}
|
||||
refreshSections := []Section{in.Section}
|
||||
if in.Section == SectionFull {
|
||||
refreshSections = refreshableSections(in.Module)
|
||||
}
|
||||
if in.ManualRefresh {
|
||||
var used bool
|
||||
if err := tx.QueryRow(ctx, `SELECT EXISTS(SELECT 1 FROM ai_insight_snapshots WHERE merchant_id=$1 AND terminal_source_key IS NOT DISTINCT FROM $2::text AND module=$3 AND manual_refresh=true AND tehran_refresh_day=$4)`, in.MerchantID, nilIfEmpty(in.TerminalID), in.Module, in.Time.AsOfDate).Scan(&used); err != nil {
|
||||
return Job{}, false, fmt.Errorf("checking daily insight refresh: %w", err)
|
||||
}
|
||||
if used {
|
||||
return Job{}, false, ErrRefreshLimited
|
||||
for _, section := range refreshSections {
|
||||
var uses int
|
||||
var latest *time.Time
|
||||
if err := tx.QueryRow(ctx, `SELECT count(*),max(accepted_at) FROM ai_insight_refresh_attempts WHERE merchant_id=$1 AND terminal_source_key IS NOT DISTINCT FROM $2::text AND module=$3 AND period_id IS NOT DISTINCT FROM $4::text AND section_id=$5 AND tehran_refresh_day=$6`, in.MerchantID, nilIfEmpty(in.TerminalID), in.Module, nilIfEmpty(in.Period.PeriodID), section, in.Time.AsOfDate).Scan(&uses, &latest); err != nil {
|
||||
return Job{}, false, fmt.Errorf("checking section insight refresh: %w", err)
|
||||
}
|
||||
if uses >= 3 {
|
||||
return Job{}, false, ErrRefreshLimited
|
||||
}
|
||||
if latest != nil && time.Since(*latest) < time.Minute {
|
||||
return Job{}, false, &RefreshCooldownError{NextRefreshAt: latest.Add(time.Minute)}
|
||||
}
|
||||
}
|
||||
}
|
||||
var revision int
|
||||
if err := tx.QueryRow(ctx, `SELECT coalesce(max(revision),0)+1 FROM ai_insight_snapshots WHERE merchant_id=$1 AND terminal_source_key IS NOT DISTINCT FROM $2::text AND module=$3`, in.MerchantID, nilIfEmpty(in.TerminalID), in.Module).Scan(&revision); err != nil {
|
||||
if err := tx.QueryRow(ctx, `SELECT coalesce(max(revision),0)+1 FROM ai_insight_snapshots WHERE merchant_id=$1 AND terminal_source_key IS NOT DISTINCT FROM $2::text AND module=$3 AND period_id IS NOT DISTINCT FROM $4::text`, in.MerchantID, nilIfEmpty(in.TerminalID), in.Module, nilIfEmpty(in.Period.PeriodID)).Scan(&revision); err != nil {
|
||||
return Job{}, false, fmt.Errorf("calculating insight revision: %w", err)
|
||||
}
|
||||
snapshotID, jobID := newID("insight_"), newID("insightjob_")
|
||||
_, err = tx.Exec(ctx, `INSERT INTO ai_insight_snapshots(id,merchant_id,terminal_id,terminal_source_key,module,revision,fingerprint,status,as_of_date,data_through_date,freshness,data_lag_days,confidence,catalog_version,analytics_version,prompt_version,schema_version,model,deterministic_evidence,manual_refresh,tehran_refresh_day)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,'QUEUED',$8,$9,$10,$11,$12,$13,$14,'1.0.0','1.0.0','deterministic-fallback',$15,$16,$17)`, snapshotID, in.MerchantID, terminalDBID, nilIfEmpty(in.TerminalID), in.Module, revision, in.Fingerprint, in.Time.AsOfDate, in.Time.DataThroughDate, in.Time.Freshness, in.Time.DataLagDays, in.Confidence, in.CatalogVersion, AnalyticsVersion, in.Evidence, in.ManualRefresh, nullableDate(in.ManualRefresh, in.Time.AsOfDate))
|
||||
_, err = tx.Exec(ctx, `INSERT INTO ai_insight_snapshots(id,merchant_id,terminal_id,terminal_source_key,module,section_id,revision,fingerprint,status,as_of_date,data_through_date,freshness,data_lag_days,confidence,catalog_version,analytics_version,prompt_version,schema_version,model,deterministic_evidence,manual_refresh,tehran_refresh_day,period_id,period_type,period_start_at,period_end_at,period_label_fa)
|
||||
VALUES($1,$2,$3,$4,$5,$6,$7,$8,'QUEUED',$9,$10,$11,$12,$13,$14,$15,'page_insight_v5','page_insight_v5','unassigned',$16,$17,$18,$19,$20,$21,$22,$23)`, snapshotID, in.MerchantID, terminalDBID, nilIfEmpty(in.TerminalID), in.Module, in.Section, revision, in.Fingerprint, in.Time.AsOfDate, in.Time.DataThroughDate, in.Time.Freshness, in.Time.DataLagDays, in.Confidence, in.CatalogVersion, AnalyticsVersion, in.Evidence, in.ManualRefresh, nullableDate(in.ManualRefresh, in.Time.AsOfDate), nilIfEmpty(in.Period.PeriodID), nilIfEmpty(in.Period.PeriodType), nullableTime(in.Period.PeriodID != "", in.Period.StartAt), nullableTime(in.Period.PeriodID != "", in.Period.EndAt), nilIfEmpty(in.Period.JalaliLabelFA))
|
||||
if err != nil {
|
||||
return Job{}, false, fmt.Errorf("creating insight snapshot: %w", err)
|
||||
}
|
||||
_, err = tx.Exec(ctx, `INSERT INTO ai_insight_jobs(id,snapshot_id,merchant_id,module,trigger_reason,status,idempotency_key,request_hash) VALUES($1,$2,$3,$4,$5,'QUEUED',$6,$7)`, jobID, snapshotID, in.MerchantID, in.Module, in.TriggerReason, nilIfEmpty(in.IdempotencyKey), nilIfEmpty(in.RequestHash))
|
||||
_, err = tx.Exec(ctx, `INSERT INTO ai_insight_jobs(id,snapshot_id,merchant_id,module,section_id,trigger_reason,status,idempotency_key,request_hash) VALUES($1,$2,$3,$4,$5,$6,'QUEUED',$7,$8)`, jobID, snapshotID, in.MerchantID, in.Module, in.Section, in.TriggerReason, nilIfEmpty(in.IdempotencyKey), nilIfEmpty(in.RequestHash))
|
||||
if err != nil {
|
||||
return Job{}, false, fmt.Errorf("creating insight job: %w", err)
|
||||
}
|
||||
if in.ManualRefresh {
|
||||
for _, section := range refreshSections {
|
||||
if _, err := tx.Exec(ctx, `INSERT INTO ai_insight_refresh_attempts(job_id,merchant_id,terminal_source_key,module,period_id,section_id,tehran_refresh_day) VALUES($1,$2,$3,$4,$5,$6,$7)`, jobID, in.MerchantID, nilIfEmpty(in.TerminalID), in.Module, nilIfEmpty(in.Period.PeriodID), section, in.Time.AsOfDate); err != nil {
|
||||
return Job{}, false, fmt.Errorf("recording accepted section refresh: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := insertEvent(ctx, tx, jobID, in.MerchantID, "job.queued", map[string]any{"state": "QUEUED", "snapshotId": snapshotID}); err != nil {
|
||||
return Job{}, false, err
|
||||
}
|
||||
@@ -158,7 +191,9 @@ type claimedJob struct {
|
||||
MerchantID int64
|
||||
TerminalID string
|
||||
Module Module
|
||||
Period PeriodScope
|
||||
LeaseToken string
|
||||
BaseCopy Copy
|
||||
}
|
||||
|
||||
func (r *Repository) Claim(ctx context.Context) (claimedJob, error) {
|
||||
@@ -172,10 +207,12 @@ func (r *Repository) Claim(ctx context.Context) (claimedJob, error) {
|
||||
}
|
||||
var job claimedJob
|
||||
var terminal *string
|
||||
err = tx.QueryRow(ctx, `SELECT j.id,j.snapshot_id,j.merchant_id,s.terminal_source_key,j.module,j.created_at,j.updated_at
|
||||
var periodID, periodType, periodLabel *string
|
||||
var periodStart, periodEnd *time.Time
|
||||
err = tx.QueryRow(ctx, `SELECT j.id,j.snapshot_id,j.merchant_id,s.terminal_source_key,j.module,j.section_id,j.created_at,j.updated_at,s.period_id,s.period_type,s.period_start_at,s.period_end_at,s.period_label_fa
|
||||
FROM ai_insight_jobs j JOIN ai_insight_snapshots s ON s.id=j.snapshot_id
|
||||
WHERE j.id=(SELECT id FROM ai_insight_jobs WHERE status='QUEUED' AND scheduled_at<=now()
|
||||
ORDER BY CASE WHEN trigger_reason='MANUAL' THEN 0 ELSE 1 END,scheduled_at,created_at FOR UPDATE SKIP LOCKED LIMIT 1)`).Scan(&job.JobID, &job.SnapshotID, &job.MerchantID, &terminal, &job.Module, &job.CreatedAt, &job.UpdatedAt)
|
||||
ORDER BY CASE trigger_reason WHEN 'MANUAL' THEN 0 WHEN 'PROFILE' THEN 1 ELSE 2 END,scheduled_at,created_at FOR UPDATE SKIP LOCKED LIMIT 1)`).Scan(&job.JobID, &job.SnapshotID, &job.MerchantID, &terminal, &job.Module, &job.Section, &job.CreatedAt, &job.UpdatedAt, &periodID, &periodType, &periodStart, &periodEnd, &periodLabel)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return claimedJob{}, ErrNotFound
|
||||
}
|
||||
@@ -185,6 +222,15 @@ func (r *Repository) Claim(ctx context.Context) (claimedJob, error) {
|
||||
if terminal != nil {
|
||||
job.TerminalID = *terminal
|
||||
}
|
||||
if periodID != nil {
|
||||
job.Period = PeriodScope{PeriodID: *periodID, PeriodType: *periodType, StartAt: *periodStart, EndAt: *periodEnd, JalaliLabelFA: *periodLabel}
|
||||
}
|
||||
if job.Section != SectionFull {
|
||||
var encoded []byte
|
||||
if err := tx.QueryRow(ctx, `SELECT coalesce(ai_response,deterministic_copy) FROM ai_insight_snapshots WHERE merchant_id=$1 AND terminal_source_key IS NOT DISTINCT FROM $2::text AND module=$3 AND period_id IS NOT DISTINCT FROM $4::text AND id<>$5 AND status IN('READY','PARTIAL') ORDER BY (status='READY') DESC,revision DESC LIMIT 1`, job.MerchantID, nilIfEmpty(job.TerminalID), job.Module, nilIfEmpty(job.Period.PeriodID), job.SnapshotID).Scan(&encoded); err == nil {
|
||||
_ = json.Unmarshal(encoded, &job.BaseCopy)
|
||||
}
|
||||
}
|
||||
job.State, job.EventsURL, job.LeaseToken = "RUNNING", "/api/v1/analytics/ai-insight-refreshes/"+job.JobID+"/events", newID("lease_")
|
||||
if _, err := tx.Exec(ctx, `UPDATE ai_insight_jobs SET status='RUNNING',attempts=attempts+1,lease_token=$2,lease_expires_at=now()+make_interval(secs=>$3),updated_at=now() WHERE id=$1`, job.JobID, job.LeaseToken, int(leaseDuration/time.Second)); err != nil {
|
||||
return claimedJob{}, fmt.Errorf("claiming insight job: %w", err)
|
||||
@@ -201,14 +247,56 @@ func (r *Repository) Claim(ctx context.Context) (claimedJob, error) {
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func (r *Repository) Complete(ctx context.Context, job claimedJob, analysis Analysis, status, failureCode string) error {
|
||||
func (r *Repository) Prioritize(ctx context.Context, merchantID int64, jobID string) error {
|
||||
_, err := r.pool.Exec(ctx, `UPDATE ai_insight_jobs SET trigger_reason='MANUAL',scheduled_at=least(scheduled_at,now()),updated_at=now() WHERE id=$1 AND merchant_id=$2 AND status='QUEUED'`, jobID, merchantID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prioritizing interactive insight job: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) Complete(ctx context.Context, job claimedJob, completion Completion) error {
|
||||
tx, err := r.pool.Begin(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("beginning insight completion: %w", err)
|
||||
}
|
||||
defer func() { _ = tx.Rollback(ctx) }()
|
||||
copyJSON, _ := json.Marshal(analysis.Copy)
|
||||
_, err = tx.Exec(ctx, `UPDATE ai_insight_snapshots SET status=$3,as_of_date=$4,data_through_date=$5,freshness=$6,data_lag_days=$7,confidence=$8,deterministic_evidence=$9,ai_response=$10,failure_code=$11,updated_at=now(),completed_at=now() WHERE id=$1 AND EXISTS(SELECT 1 FROM ai_insight_jobs WHERE id=$2 AND lease_token=$12)`, job.SnapshotID, job.JobID, status, analysis.Time.AsOfDate, analysis.Time.DataThroughDate, analysis.Time.Freshness, analysis.Time.DataLagDays, analysis.Confidence, analysis.Evidence, copyJSON, nilIfEmpty(failureCode), job.LeaseToken)
|
||||
lockKey := fmt.Sprintf("ai-insight-complete:%d:%s:%s:%s", job.MerchantID, job.TerminalID, job.Module, job.Period.PeriodID)
|
||||
if _, err := tx.Exec(ctx, `SELECT pg_advisory_xact_lock(hashtextextended($1,0))`, lockKey); err != nil {
|
||||
return fmt.Errorf("locking insight completion: %w", err)
|
||||
}
|
||||
if job.Section != SectionFull {
|
||||
var latestJSON []byte
|
||||
if err := tx.QueryRow(ctx, `SELECT coalesce(ai_response,deterministic_copy) FROM ai_insight_snapshots WHERE merchant_id=$1 AND terminal_source_key IS NOT DISTINCT FROM $2::text AND module=$3 AND period_id IS NOT DISTINCT FROM $4::text AND id<>$5 AND status IN('READY','PARTIAL') ORDER BY completed_at DESC NULLS LAST,revision DESC LIMIT 1`, job.MerchantID, nilIfEmpty(job.TerminalID), job.Module, nilIfEmpty(job.Period.PeriodID), job.SnapshotID).Scan(&latestJSON); err == nil {
|
||||
var latest Copy
|
||||
if json.Unmarshal(latestJSON, &latest) == nil {
|
||||
mergedDeterministic, mergeErr := MergeSection(latest, completion.Analysis.Copy, job.Section)
|
||||
if mergeErr != nil {
|
||||
return fmt.Errorf("merging latest deterministic insight section: %w", mergeErr)
|
||||
}
|
||||
completion.Analysis.Copy = mergedDeterministic
|
||||
if completion.AICopy != nil {
|
||||
merged, mergeErr := MergeSection(latest, *completion.AICopy, job.Section)
|
||||
if mergeErr != nil {
|
||||
return fmt.Errorf("merging latest AI insight section: %w", mergeErr)
|
||||
}
|
||||
completion.AICopy = &merged
|
||||
}
|
||||
}
|
||||
} else if !errors.Is(err, pgx.ErrNoRows) {
|
||||
return fmt.Errorf("loading latest insight copy at completion: %w", err)
|
||||
}
|
||||
if _, err := tx.Exec(ctx, `UPDATE ai_insight_snapshots SET revision=(SELECT coalesce(max(revision),0)+1 FROM ai_insight_snapshots WHERE merchant_id=$1 AND terminal_source_key IS NOT DISTINCT FROM $2::text AND module=$3 AND period_id IS NOT DISTINCT FROM $4::text AND id<>$5) WHERE id=$5`, job.MerchantID, nilIfEmpty(job.TerminalID), job.Module, nilIfEmpty(job.Period.PeriodID), job.SnapshotID); err != nil {
|
||||
return fmt.Errorf("ordering insight revision: %w", err)
|
||||
}
|
||||
}
|
||||
deterministicCopy, _ := json.Marshal(completion.Analysis.Copy)
|
||||
var aiCopy any
|
||||
if completion.AICopy != nil {
|
||||
encoded, _ := json.Marshal(completion.AICopy)
|
||||
aiCopy = encoded
|
||||
}
|
||||
_, err = tx.Exec(ctx, `UPDATE ai_insight_snapshots SET status=$3,as_of_date=$4,data_through_date=$5,freshness=$6,data_lag_days=$7,confidence=$8,deterministic_evidence=$9,deterministic_copy=$10,ai_response=$11,failure_code=$12,model=coalesce(nullif($13,''),model),input_tokens=$14,output_tokens=$15,provider_requests=$16,latency_ms=$17,provider_request_id=nullif($18,''),estimated_cost=nullif($19,''),updated_at=now(),completed_at=now() WHERE id=$1 AND EXISTS(SELECT 1 FROM ai_insight_jobs WHERE id=$2 AND lease_token=$20)`, job.SnapshotID, job.JobID, completion.Status, completion.Analysis.Time.AsOfDate, completion.Analysis.Time.DataThroughDate, completion.Analysis.Time.Freshness, completion.Analysis.Time.DataLagDays, completion.Analysis.Confidence, completion.Analysis.Evidence, deterministicCopy, aiCopy, nilIfEmpty(completion.FailureCode), completion.Model, completion.Usage.InputTokens, completion.Usage.OutputTokens, completion.Usage.ProviderRequests, completion.Usage.LatencyMS, completion.Usage.ProviderRequestID, completion.Usage.EstimatedCost, job.LeaseToken)
|
||||
if err != nil {
|
||||
return fmt.Errorf("completing insight snapshot: %w", err)
|
||||
}
|
||||
@@ -216,23 +304,58 @@ func (r *Repository) Complete(ctx context.Context, job claimedJob, analysis Anal
|
||||
if err != nil {
|
||||
return fmt.Errorf("completing insight job: %w", err)
|
||||
}
|
||||
if err := insertEvent(ctx, tx, job.JobID, job.MerchantID, "job.completed", map[string]any{"state": "COMPLETED", "snapshotStatus": status}); err != nil {
|
||||
if err := insertEvent(ctx, tx, job.JobID, job.MerchantID, "job.completed", map[string]any{"state": "COMPLETED", "snapshotStatus": completion.Status}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Commit(ctx)
|
||||
}
|
||||
|
||||
func (r *Repository) Latest(ctx context.Context, merchantID int64, terminalID string, module Module) (Snapshot, error) {
|
||||
row := r.pool.QueryRow(ctx, snapshotSelect+` WHERE s.merchant_id=$1 AND s.terminal_source_key IS NOT DISTINCT FROM $2::text AND s.module=$3 AND s.status IN ('READY','PARTIAL') ORDER BY s.revision DESC LIMIT 1`, merchantID, nilIfEmpty(terminalID), module)
|
||||
return r.latest(ctx, merchantID, terminalID, module, "")
|
||||
}
|
||||
|
||||
func (r *Repository) LatestForPeriod(ctx context.Context, merchantID int64, terminalID string, module Module, periodID string) (Snapshot, error) {
|
||||
return r.latest(ctx, merchantID, terminalID, module, periodID)
|
||||
}
|
||||
|
||||
func (r *Repository) latest(ctx context.Context, merchantID int64, terminalID string, module Module, periodID string) (Snapshot, error) {
|
||||
row := r.pool.QueryRow(ctx, snapshotSelect+` WHERE s.merchant_id=$1 AND s.terminal_source_key IS NOT DISTINCT FROM $2::text AND s.module=$3 AND s.period_id IS NOT DISTINCT FROM $4::text AND s.status IN ('READY','PARTIAL') ORDER BY (s.status='READY') DESC,s.revision DESC LIMIT 1`, merchantID, nilIfEmpty(terminalID), module, nilIfEmpty(periodID))
|
||||
result, err := scanSnapshot(row)
|
||||
if err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
var jobID, state *string
|
||||
_ = r.pool.QueryRow(ctx, `SELECT j.id,j.status FROM ai_insight_jobs j JOIN ai_insight_snapshots s ON s.id=j.snapshot_id WHERE s.merchant_id=$1 AND s.terminal_source_key IS NOT DISTINCT FROM $2::text AND s.module=$3 AND j.status IN ('QUEUED','RUNNING') ORDER BY j.created_at DESC LIMIT 1`, merchantID, nilIfEmpty(terminalID), module).Scan(&jobID, &state)
|
||||
if jobID != nil {
|
||||
result.RefreshState = RefreshState{State: *state, JobID: *jobID, EventsURL: "/api/v1/analytics/ai-insight-refreshes/" + *jobID + "/events"}
|
||||
} else {
|
||||
result.SectionRefresh = make(map[Section]RefreshState)
|
||||
for _, section := range refreshableSections(module) {
|
||||
result.SectionRefresh[section] = RefreshState{State: "IDLE"}
|
||||
}
|
||||
if _, err := r.pool.Exec(ctx, `UPDATE ai_insight_jobs j SET trigger_reason='MANUAL',scheduled_at=least(j.scheduled_at,now()),updated_at=now() FROM ai_insight_snapshots s WHERE s.id=j.snapshot_id AND s.merchant_id=$1 AND s.terminal_source_key IS NOT DISTINCT FROM $2::text AND s.module=$3 AND s.period_id IS NOT DISTINCT FROM $4::text AND j.status='QUEUED'`, merchantID, nilIfEmpty(terminalID), module, nilIfEmpty(periodID)); err != nil {
|
||||
return Snapshot{}, fmt.Errorf("prioritizing active insight refreshes: %w", err)
|
||||
}
|
||||
jobs, queryErr := r.pool.Query(ctx, `SELECT j.id,j.status,j.section_id FROM ai_insight_jobs j JOIN ai_insight_snapshots s ON s.id=j.snapshot_id WHERE s.merchant_id=$1 AND s.terminal_source_key IS NOT DISTINCT FROM $2::text AND s.module=$3 AND s.period_id IS NOT DISTINCT FROM $4::text AND j.status IN ('QUEUED','RUNNING') ORDER BY j.created_at`, merchantID, nilIfEmpty(terminalID), module, nilIfEmpty(periodID))
|
||||
if queryErr != nil {
|
||||
return Snapshot{}, fmt.Errorf("loading active insight refreshes: %w", queryErr)
|
||||
}
|
||||
defer jobs.Close()
|
||||
for jobs.Next() {
|
||||
var jobID, state string
|
||||
var section Section
|
||||
if err := jobs.Scan(&jobID, &state, §ion); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
refresh := RefreshState{State: state, JobID: jobID, EventsURL: "/api/v1/analytics/ai-insight-refreshes/" + jobID + "/events"}
|
||||
result.RefreshState = refresh
|
||||
if section == SectionFull {
|
||||
for _, item := range refreshableSections(module) {
|
||||
result.SectionRefresh[item] = refresh
|
||||
}
|
||||
} else {
|
||||
result.SectionRefresh[section] = refresh
|
||||
}
|
||||
}
|
||||
if err := jobs.Err(); err != nil {
|
||||
return Snapshot{}, err
|
||||
}
|
||||
if result.RefreshState.State == "" {
|
||||
result.RefreshState.State = "IDLE"
|
||||
}
|
||||
return result, nil
|
||||
@@ -262,7 +385,7 @@ func (r *Repository) EventsAfter(ctx context.Context, merchantID int64, jobID st
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
const snapshotSelect = `SELECT s.id,s.module,s.revision,s.status,s.terminal_source_key,s.as_of_date,s.data_through_date,s.freshness,s.data_lag_days,s.confidence,s.ai_response,s.deterministic_evidence,s.created_at FROM ai_insight_snapshots s`
|
||||
const snapshotSelect = `SELECT s.id,s.module,s.revision,s.status,s.terminal_source_key,s.period_id,s.as_of_date,s.data_through_date,s.freshness,s.data_lag_days,s.confidence,coalesce(s.ai_response,s.deterministic_copy),s.deterministic_evidence,s.created_at FROM ai_insight_snapshots s`
|
||||
|
||||
type rowScanner interface{ Scan(...any) error }
|
||||
type queryer interface {
|
||||
@@ -272,9 +395,10 @@ type queryer interface {
|
||||
func scanSnapshot(row rowScanner) (Snapshot, error) {
|
||||
var s Snapshot
|
||||
var terminal *string
|
||||
var periodID *string
|
||||
var asOf, through time.Time
|
||||
var copyJSON []byte
|
||||
err := row.Scan(&s.InsightID, &s.Module, &s.Revision, &s.Status, &terminal, &asOf, &through, &s.Freshness, &s.DataLagDays, &s.Confidence, ©JSON, &s.Evidence, &s.GeneratedAt)
|
||||
err := row.Scan(&s.InsightID, &s.Module, &s.Revision, &s.Status, &terminal, &periodID, &asOf, &through, &s.Freshness, &s.DataLagDays, &s.Confidence, ©JSON, &s.Evidence, &s.GeneratedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Snapshot{}, ErrNotFound
|
||||
}
|
||||
@@ -284,6 +408,7 @@ func scanSnapshot(row rowScanner) (Snapshot, error) {
|
||||
if terminal != nil {
|
||||
s.Scope.TerminalID = terminal
|
||||
}
|
||||
s.Scope.PeriodID = periodID
|
||||
s.AsOfDate, s.ForecastStart = asOf.Format(time.DateOnly), asOf.Format(time.DateOnly)
|
||||
s.DataThroughDate = through.Format(time.DateOnly)
|
||||
_ = json.Unmarshal(copyJSON, &s.Copy)
|
||||
@@ -292,7 +417,7 @@ func scanSnapshot(row rowScanner) (Snapshot, error) {
|
||||
|
||||
func getJobWith(ctx context.Context, q queryer, merchantID int64, jobID string) (Job, error) {
|
||||
var job Job
|
||||
err := q.QueryRow(ctx, `SELECT id,snapshot_id,status,created_at,updated_at FROM ai_insight_jobs WHERE merchant_id=$1 AND id=$2`, merchantID, jobID).Scan(&job.JobID, &job.SnapshotID, &job.State, &job.CreatedAt, &job.UpdatedAt)
|
||||
err := q.QueryRow(ctx, `SELECT id,snapshot_id,module,section_id,status,created_at,updated_at FROM ai_insight_jobs WHERE merchant_id=$1 AND id=$2`, merchantID, jobID).Scan(&job.JobID, &job.SnapshotID, &job.Module, &job.Section, &job.State, &job.CreatedAt, &job.UpdatedAt)
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return Job{}, ErrNotFound
|
||||
}
|
||||
@@ -303,6 +428,14 @@ func getJobWith(ctx context.Context, q queryer, merchantID int64, jobID string)
|
||||
return job, nil
|
||||
}
|
||||
|
||||
func refreshableSections(module Module) []Section {
|
||||
sections := []Section{SectionPast, SectionLatestObserved, SectionFuture7Days, SectionFuture30Days, SectionFindings, SectionOptions}
|
||||
if module == ModuleGrowth {
|
||||
sections = append(sections, SectionFuture90Days)
|
||||
}
|
||||
return sections
|
||||
}
|
||||
|
||||
func insertEvent(ctx context.Context, tx pgx.Tx, jobID string, merchantID int64, eventType string, payload any) error {
|
||||
encoded, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
@@ -327,3 +460,10 @@ func nullableDate(enabled bool, value string) any {
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func nullableTime(enabled bool, value time.Time) any {
|
||||
if !enabled {
|
||||
return nil
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user