feat(growth): add deterministic forecasting engine
This commit is contained in:
127
internal/growth/report.go
Normal file
127
internal/growth/report.go
Normal file
@@ -0,0 +1,127 @@
|
||||
package growth
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type FingerprintInput struct {
|
||||
MerchantID int64 `json:"merchantId"`
|
||||
TerminalID string `json:"terminalId,omitempty"`
|
||||
DatasetWatermark string `json:"datasetWatermark"`
|
||||
ForecastStart time.Time `json:"forecastStart"`
|
||||
HorizonDays int `json:"horizonDays"`
|
||||
CatalogVersion string `json:"catalogVersion"`
|
||||
AnalyticsVersion string `json:"analyticsVersion"`
|
||||
PromptVersion string `json:"promptVersion"`
|
||||
SchemaVersion string `json:"schemaVersion"`
|
||||
Model string `json:"model"`
|
||||
}
|
||||
|
||||
func Fingerprint(input FingerprintInput) string {
|
||||
payload, err := json.Marshal(struct {
|
||||
FingerprintInput
|
||||
ForecastDate string `json:"forecastDate"`
|
||||
}{FingerprintInput: FingerprintInput{
|
||||
MerchantID: input.MerchantID, TerminalID: input.TerminalID,
|
||||
DatasetWatermark: input.DatasetWatermark, HorizonDays: input.HorizonDays,
|
||||
CatalogVersion: input.CatalogVersion, AnalyticsVersion: input.AnalyticsVersion,
|
||||
PromptVersion: input.PromptVersion, SchemaVersion: input.SchemaVersion, Model: input.Model,
|
||||
}, ForecastDate: input.ForecastStart.In(tehranLocation()).Format(time.DateOnly)})
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
digest := sha256.Sum256(payload)
|
||||
return hex.EncodeToString(digest[:])
|
||||
}
|
||||
|
||||
type ReportInput struct {
|
||||
History []DailyPoint `json:"history"`
|
||||
Catalog []Event `json:"catalog"`
|
||||
ForecastStart time.Time `json:"forecastStart"`
|
||||
HorizonDays int `json:"horizonDays"`
|
||||
Category string `json:"category,omitempty"`
|
||||
Peer *PeerInput `json:"peer,omitempty"`
|
||||
}
|
||||
|
||||
type DeterministicReport struct {
|
||||
Forecast Forecast `json:"forecast"`
|
||||
HistoricalWins []EventEffect `json:"historicalWins"`
|
||||
Opportunities []Opportunity `json:"opportunities"`
|
||||
Risks []Risk `json:"risks"`
|
||||
Peer *PeerResult `json:"peer,omitempty"`
|
||||
FallbackBriefs []CampaignBrief `json:"fallbackBriefs"`
|
||||
}
|
||||
|
||||
func BuildDeterministicReport(input ReportInput) DeterministicReport {
|
||||
catalog := filterCatalog(input.Catalog, input.Category)
|
||||
forecast := BuildForecast(input.History, catalog, input.ForecastStart, input.HorizonDays)
|
||||
wins := historicalWins(input.History, catalog, input.ForecastStart)
|
||||
opportunities := buildOpportunities(wins, catalog, forecast, input.ForecastStart, 5)
|
||||
risks := DetectRisks(forecast, 3)
|
||||
bundle := EvidenceBundle{Opportunities: opportunities, Risks: risks}
|
||||
report := DeterministicReport{Forecast: forecast, HistoricalWins: wins, Opportunities: opportunities, Risks: risks, FallbackBriefs: BuildFallback(bundle)}
|
||||
if input.Peer != nil {
|
||||
peer := EvaluatePeer(*input.Peer)
|
||||
report.Peer = &peer
|
||||
}
|
||||
return report
|
||||
}
|
||||
|
||||
func TehranDay(value time.Time) string {
|
||||
return value.In(tehranLocation()).Format(time.DateOnly)
|
||||
}
|
||||
|
||||
func NextRefreshAt(lastRefresh time.Time) time.Time {
|
||||
local := lastRefresh.In(tehranLocation())
|
||||
nextLocal := time.Date(local.Year(), local.Month(), local.Day()+1, 0, 0, 0, 0, tehranLocation())
|
||||
return nextLocal.UTC()
|
||||
}
|
||||
|
||||
func CanManualRefresh(lastRefresh, now time.Time) bool {
|
||||
if lastRefresh.IsZero() {
|
||||
return true
|
||||
}
|
||||
return !now.Before(NextRefreshAt(lastRefresh))
|
||||
}
|
||||
|
||||
func BuildFallback(bundle EvidenceBundle) []CampaignBrief {
|
||||
briefs := make([]CampaignBrief, 0, len(bundle.Opportunities)+len(bundle.Risks))
|
||||
for _, opportunity := range bundle.Opportunities {
|
||||
briefs = append(briefs, CampaignBrief{
|
||||
HeadlineFA: "یک فرصت مناسب در راه است: " + opportunity.EventTitleFA,
|
||||
GuidanceFA: "دادههای قبلی شما نشان میدهد این مناسبت میتواند برای برنامهریزی فروش ارزشمند باشد.",
|
||||
OfferFA: "پیشنهاد خود را متناسب با نیاز مشتریان این مناسبت آماده کنید.",
|
||||
PreparationFA: "موجودی، پیام کمپین و مسیر پرداخت را پیش از شروع مناسبت بررسی کنید.",
|
||||
LimitationFA: "این پیشنهاد بر پایه الگوهای گذشته است و نتیجه قطعی نیست.",
|
||||
EvidenceIDs: []string{opportunity.EvidenceID},
|
||||
})
|
||||
}
|
||||
for _, risk := range bundle.Risks {
|
||||
briefs = append(briefs, CampaignBrief{
|
||||
HeadlineFA: "برای یک بازه آرامتر آماده باشید",
|
||||
GuidanceFA: "الگوی گذشته احتمال کاهش فعالیت را در این بازه نشان میدهد.",
|
||||
OfferFA: "یک پیشنهاد سبک برای حفظ ارتباط با مشتریان در نظر بگیرید.",
|
||||
PreparationFA: "پیام یادآوری و تجربه پرداخت را پیش از این بازه بازبینی کنید.",
|
||||
LimitationFA: "این هشدار بر پایه الگوهای گذشته است و نتیجه قطعی نیست.",
|
||||
EvidenceIDs: []string{risk.EvidenceID},
|
||||
})
|
||||
}
|
||||
return briefs
|
||||
}
|
||||
|
||||
func EvidenceID(kind, stableKey string) string {
|
||||
digest := sha256.Sum256([]byte(kind + "\x00" + stableKey))
|
||||
return fmt.Sprintf("%s_%s", kind, hex.EncodeToString(digest[:8]))
|
||||
}
|
||||
|
||||
func tehranLocation() *time.Location {
|
||||
location, err := time.LoadLocation("Asia/Tehran")
|
||||
if err != nil {
|
||||
return time.FixedZone("Asia/Tehran", 3*60*60+30*60)
|
||||
}
|
||||
return location
|
||||
}
|
||||
Reference in New Issue
Block a user