feat: harden growth report orchestration

This commit is contained in:
2026-08-21 00:43:33 +03:30
parent dd0ae15a95
commit d00700ffb8
5 changed files with 54 additions and 14 deletions

View File

@@ -7,6 +7,7 @@ import (
"encoding/json"
"errors"
"fmt"
"log/slog"
"time"
"git.amiirkhl.ir/starcoach-hackathon/zarinpal-analytics-backend/internal/growth"
@@ -41,7 +42,7 @@ func (s *Service) Create(ctx context.Context, userID, merchantID int64, request
if err != nil {
return Record{}, false, err
}
fingerprint := growth.Fingerprint(growth.FingerprintInput{MerchantID: merchantID, TerminalID: request.TerminalID, DatasetWatermark: watermark, ForecastStart: start, HorizonDays: 90, CatalogVersion: growth.CatalogVersion, AnalyticsVersion: growth.AnalyticsVersion, PromptVersion: s.config.PromptVersion, SchemaVersion: s.config.SchemaVersion, Model: s.config.Model})
fingerprint := growth.Fingerprint(growth.FingerprintInput{MerchantID: merchantID, TerminalID: request.TerminalID, DatasetWatermark: watermark, ForecastStart: start, HorizonDays: 90, CatalogVersion: growth.CatalogVersion, AnalyticsVersion: growth.AnalyticsVersion, PromptVersion: s.config.PromptVersion, SchemaVersion: s.config.SchemaVersion, Model: s.config.Model, ModelConfigVersion: growth.DefaultModelConfigVersion})
var refreshDay *time.Time
if request.Refresh {
day := tehranMidnight(now)
@@ -49,6 +50,13 @@ func (s *Service) Create(ctx context.Context, userID, merchantID int64, request
}
record, cached, err := s.repository.CreateOrReuse(ctx, CreateRecord{ID: newReportID(), MerchantID: merchantID, TerminalID: request.TerminalID, Fingerprint: fingerprint, DatasetWatermark: watermark, ForecastStartDate: &start, CatalogVersion: growth.CatalogVersion, AnalyticsVersion: growth.AnalyticsVersion, PromptVersion: s.config.PromptVersion, SchemaVersion: s.config.SchemaVersion, Model: s.config.Model, ManualRefresh: request.Refresh, RefreshDay: refreshDay, IdempotencyKey: idempotencyKey, RequestHash: RequestHash(merchantID, request.TerminalID, request.Refresh)})
if err != nil {
if errors.Is(err, ErrRefreshLimited) {
next, quotaErr := s.repository.NextRefreshAt(ctx, merchantID, now)
if quotaErr != nil {
return Record{}, false, quotaErr
}
return Record{}, false, &RefreshLimitError{NextRefreshAt: next}
}
return Record{}, false, err
}
if !cached {
@@ -109,22 +117,29 @@ func (p *Processor) ProcessNext(ctx context.Context) error {
}
copy := analysis.Fallback
status := StatusPartial
failure := "AI_SKIPPED"
failure := "AI_DISABLED_OR_UNAVAILABLE"
if analysis.ShouldCallAI && p.aiEnabled && p.avalai != nil {
if err := p.coordinator.ReserveBudget(ctx, 1600, p.now()); err == nil {
reservedTokens := int64((len(analysis.Encoded)+3)/4*2 + 1600)
if err := p.coordinator.ReserveBudget(ctx, reservedTokens, p.now()); err == nil {
release, enterErr := p.coordinator.Enter(ctx)
if enterErr == nil {
generated, usage, aiErr := p.avalai.GenerateWithTools(ctx, analysis.Encoded, analysis.KnownEvidence, NewEvidenceTools(analysis))
release()
if aiErr == nil {
slog.Info("growth AI generation complete", "report_id", record.ReportID, "input_tokens", usage.InputTokens, "output_tokens", usage.OutputTokens, "latency_ms", usage.LatencyMS)
return p.repository.Complete(ctx, record.ReportID, StatusReady, analysis.Encoded, &generated, usage, "")
}
slog.Warn("growth AI output rejected", "report_id", record.ReportID, "reason", "AI_REJECTED")
failure = "AI_REJECTED"
}
} else {
failure = "AI_BUDGET_EXHAUSTED"
slog.Warn("growth AI skipped", "report_id", record.ReportID, "reason", failure)
}
}
if !analysis.ShouldCallAI || !p.aiEnabled {
slog.Info("growth deterministic fallback used", "report_id", record.ReportID, "reason", failure)
}
return p.repository.Complete(ctx, record.ReportID, status, analysis.Encoded, &copy, Usage{}, failure)
}