fix: deliver validated AI insight responses
This commit is contained in:
@@ -46,6 +46,28 @@ func TestMatchUpcomingEventsPrefersFamilyThenTags(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildOpportunitiesReturnsThreeNearestCommercialEventsWithoutHistoricalWins(t *testing.T) {
|
||||
start := date("2026-08-21")
|
||||
forecast := Forecast{Confidence: ConfidenceMedium}
|
||||
for day := start; day.Before(start.AddDate(0, 0, 120)); day = day.AddDate(0, 0, 1) {
|
||||
forecast.Points = append(forecast.Points, ForecastPoint{
|
||||
Date: day, PaidSessions: Range{P10: 80, P50: 100, P90: 120},
|
||||
PaidAmountRial: Range{P10: 800, P50: 1000, P90: 1200},
|
||||
})
|
||||
}
|
||||
catalog := filterCatalog(testCatalog(), "مراکز آموزشی مجازی")
|
||||
got := buildOpportunities(nil, catalog, forecast, start, 3)
|
||||
if len(got) != 3 {
|
||||
t.Fatalf("got %d opportunities, want 3: %+v", len(got), got)
|
||||
}
|
||||
want := []string{"back-to-school-1405", "autumn-opening-1405", "halloween-2026"}
|
||||
for index, id := range want {
|
||||
if got[index].EventID != id {
|
||||
t.Fatalf("opportunity %d = %q, want %q", index, got[index].EventID, id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildForecastReturnsOrderedNinetyDayIntegerRanges(t *testing.T) {
|
||||
start := date("2026-07-01")
|
||||
history := make([]DailyPoint, 0, 112)
|
||||
|
||||
@@ -2,69 +2,12 @@ package growth
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const officialCalendarSource = "https://calendar.ut.ac.ir/"
|
||||
const curatedCommercialSource = "CURATED_COMMERCIAL_1405_V1"
|
||||
const officialCalendarVersion = "IR_OFFICIAL_1404_1405_REVIEWED_2026-08-21"
|
||||
|
||||
var importedMerchantCategories = []string{
|
||||
"ارائه دهنده خدمات اینترنت",
|
||||
"مراکز آموزشی مجازی",
|
||||
"کیف و کفش فروشی",
|
||||
"خدمات شبکههای کامپیوتری و اینترنت",
|
||||
"فروشگاه لوازم آرایشی و بهداشتی",
|
||||
}
|
||||
|
||||
// Catalog1405 returns a defensive copy of the reviewed initial catalog. Dates are
|
||||
// Gregorian Tehran-local calendar dates and cover the imported 1405 history plus
|
||||
// the first 90 forecast days after 2026-06-30.
|
||||
func Catalog1405() []Event {
|
||||
events := []Event{
|
||||
officialEvent("fathers-day-1404", "روز پدر", "2026-01-03", "2026-01-03", "appreciation", true, []string{"هدیه", "قدردانی"}),
|
||||
officialEvent("mabath-1404", "مبعث پیامبر اکرم", "2026-01-17", "2026-01-17", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
|
||||
officialEvent("imam-mahdi-birthday-1404", "نیمه شعبان", "2026-02-04", "2026-02-04", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
|
||||
officialEvent("revolution-day-1404", "پیروزی انقلاب اسلامی", "2026-02-11", "2026-02-11", "national-holiday", true, []string{"تعطیل", "مناسبت-ملی"}),
|
||||
officialEvent("imam-ali-martyrdom-1404", "شهادت امام علی", "2026-03-11", "2026-03-11", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
|
||||
officialEvent("oil-nationalization-1404", "ملی شدن صنعت نفت", "2026-03-19", "2026-03-19", "national-holiday", true, []string{"تعطیل", "مناسبت-ملی"}),
|
||||
officialEvent("nowruz-1405", "نوروز", "2026-03-21", "2026-03-24", "new-year", true, []string{"هدیه", "خرید", "سال-نو", "تعطیل"}),
|
||||
officialEvent("islamic-republic-day-1405", "روز جمهوری اسلامی", "2026-04-01", "2026-04-01", "national-holiday", true, []string{"تعطیل", "مناسبت-ملی"}),
|
||||
officialEvent("nature-day-1405", "روز طبیعت", "2026-04-02", "2026-04-02", "leisure", true, []string{"تفریح", "خرید", "تعطیل"}),
|
||||
officialEvent("imam-sadegh-martyrdom-1405", "شهادت امام جعفر صادق", "2026-04-14", "2026-04-14", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
|
||||
officialEvent("teachers-day-1405", "روز معلم", "2026-05-02", "2026-05-02", "appreciation", false, []string{"هدیه", "آموزش", "قدردانی"}),
|
||||
officialEvent("eid-al-adha-1405", "عید قربان", "2026-05-27", "2026-05-27", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
|
||||
officialEvent("khomeini-ghadir-1405", "رحلت امام خمینی و عید غدیر", "2026-06-04", "2026-06-04", "official-holiday", true, []string{"تعطیل", "مناسبت-ملی", "مناسبت-مذهبی"}),
|
||||
officialEvent("khordad-15-1405", "قیام پانزده خرداد", "2026-06-05", "2026-06-05", "national-holiday", true, []string{"تعطیل", "مناسبت-ملی"}),
|
||||
officialEvent("tasua-1405", "تاسوعای حسینی", "2026-06-24", "2026-06-24", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
|
||||
officialEvent("ashura-1405", "عاشورای حسینی", "2026-06-25", "2026-06-25", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
|
||||
commercialEvent("summer-midseason-1405", "میانه فصل تابستان", "2026-07-15", "2026-07-17", "seasonal-shopping", []string{"خرید", "تابستان", "پیشنهاد-فصلی"}, importedMerchantCategories),
|
||||
officialEvent("arbaeen-1405", "اربعین حسینی", "2026-08-04", "2026-08-04", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
|
||||
officialEvent("prophet-demise-1405", "رحلت پیامبر اکرم و شهادت امام حسن", "2026-08-12", "2026-08-12", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
|
||||
officialEvent("doctors-day-1405", "روز پزشک", "2026-08-23", "2026-08-23", "appreciation", false, []string{"هدیه", "قدردانی", "حرفهای"}),
|
||||
officialEvent("prophet-birthday-1405", "میلاد پیامبر اکرم و امام جعفر صادق", "2026-08-30", "2026-08-30", "religious-holiday", true, []string{"تعطیل", "مناسبت-مذهبی"}),
|
||||
commercialEvent("back-to-school-1405", "آمادگی بازگشایی مدارس", "2026-09-10", "2026-09-22", "education-season", []string{"آموزش", "خرید", "مدرسه"}, []string{"مراکز آموزشی مجازی", "کیف و کفش فروشی"}),
|
||||
commercialEvent("autumn-opening-1405", "آغاز فصل پاییز", "2026-09-23", "2026-09-28", "seasonal-shopping", []string{"خرید", "پاییز", "پیشنهاد-فصلی"}, importedMerchantCategories),
|
||||
}
|
||||
result := make([]Event, len(events))
|
||||
for index, event := range events {
|
||||
result[index] = event
|
||||
result[index].Tags = slices.Clone(event.Tags)
|
||||
result[index].Categories = slices.Clone(event.Categories)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func officialEvent(id, title, start, end, family string, holiday bool, tags []string) Event {
|
||||
return Event{ID: id, TitleFA: title, StartDate: mustCatalogDate(start), EndDate: mustCatalogDate(end), Family: family, Tags: slices.Clone(tags), Source: officialCalendarSource, SourceVersion: officialCalendarVersion, Kind: EventOfficial, OfficialHoliday: holiday, Universal: true}
|
||||
}
|
||||
|
||||
func commercialEvent(id, title, start, end, family string, tags, categories []string) Event {
|
||||
return Event{ID: id, TitleFA: title, StartDate: mustCatalogDate(start), EndDate: mustCatalogDate(end), Family: family, Tags: slices.Clone(tags), Source: curatedCommercialSource, SourceVersion: CatalogVersion, Categories: slices.Clone(categories), Kind: EventCommercial, Universal: false}
|
||||
}
|
||||
|
||||
// ValidateCatalog validates the database-backed catalog after it has been
|
||||
// mapped into the deterministic growth model. Production catalog contents are
|
||||
// intentionally not embedded here; calendar_events is the sole source.
|
||||
func ValidateCatalog(events []Event) error {
|
||||
seen := make(map[string]struct{}, len(events))
|
||||
for index, event := range events {
|
||||
@@ -93,11 +36,3 @@ func ValidateCatalog(events []Event) error {
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func mustCatalogDate(value string) time.Time {
|
||||
parsed, err := time.Parse(time.DateOnly, value)
|
||||
if err != nil {
|
||||
panic("invalid reviewed event catalog date: " + value)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
|
||||
@@ -2,20 +2,19 @@ package growth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCatalog1405IsValidAndDefensivelyCopied(t *testing.T) {
|
||||
first := Catalog1405()
|
||||
if err := ValidateCatalog(first); err != nil {
|
||||
t.Fatalf("catalog should be valid: %v", err)
|
||||
}
|
||||
first[0].Tags[0] = "mutated"
|
||||
second := Catalog1405()
|
||||
if second[0].Tags[0] == "mutated" {
|
||||
t.Fatal("catalog tags escaped through a shared backing array")
|
||||
func testCatalog() []Event {
|
||||
category := []string{"مراکز آموزشی مجازی", "کیف و کفش فروشی"}
|
||||
return []Event{
|
||||
{ID: "history", TitleFA: "رویداد تاریخی", StartDate: date("2026-01-03"), EndDate: date("2026-01-03"), Family: "history", Tags: []string{"history"}, Source: "test", SourceVersion: "1", Kind: EventOfficial, Universal: true},
|
||||
{ID: "back-to-school-1405", TitleFA: "بازگشایی", StartDate: date("2026-09-10"), EndDate: date("2026-09-22"), Family: "education", Tags: []string{"education"}, Source: "test", SourceVersion: "1", Categories: category, Kind: EventCommercial},
|
||||
{ID: "autumn-opening-1405", TitleFA: "پاییز", StartDate: date("2026-09-23"), EndDate: date("2026-09-28"), Family: "seasonal", Tags: []string{"seasonal"}, Source: "test", SourceVersion: "1", Categories: category, Kind: EventCommercial},
|
||||
{ID: "halloween-2026", TitleFA: "هالووین", StartDate: date("2026-10-31"), EndDate: date("2026-10-31"), Family: "commercial", Tags: []string{"campaign"}, Source: "test", SourceVersion: "1", Categories: category, Kind: EventCommercial},
|
||||
{ID: "black-friday-2026", TitleFA: "بلکفرایدی", StartDate: date("2026-11-27"), EndDate: date("2026-11-27"), Family: "commercial", Tags: []string{"campaign"}, Source: "test", SourceVersion: "1", Categories: category, Kind: EventCommercial},
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateCatalogRejectsDuplicateAndIncompleteEvents(t *testing.T) {
|
||||
event := officialEvent("duplicate", "رویداد", "2026-01-01", "2026-01-01", "family", false, []string{"tag"})
|
||||
event := testCatalog()[0]
|
||||
if err := ValidateCatalog([]Event{event, event}); err == nil {
|
||||
t.Fatal("expected duplicate id error")
|
||||
}
|
||||
@@ -25,87 +24,8 @@ func TestValidateCatalogRejectsDuplicateAndIncompleteEvents(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogCoversImportedHistoryAndForecastHorizon(t *testing.T) {
|
||||
catalog := Catalog1405()
|
||||
if catalog[0].StartDate.After(date("2026-01-03")) || catalog[len(catalog)-1].EndDate.Before(date("2026-09-28")) {
|
||||
t.Fatalf("catalog coverage is incomplete: %s..%s", catalog[0].StartDate, catalog[len(catalog)-1].EndDate)
|
||||
}
|
||||
var scoped bool
|
||||
for _, event := range catalog {
|
||||
if event.ID == "back-to-school-1405" {
|
||||
scoped = !event.Universal && len(event.Categories) == 2
|
||||
}
|
||||
}
|
||||
if !scoped {
|
||||
t.Fatal("back-to-school event must preserve explicit category scope")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeEventEffectExcludesCatalogEventsFromBaseline(t *testing.T) {
|
||||
target := Event{ID: "target", StartDate: date("2026-05-02"), EndDate: date("2026-05-02")}
|
||||
excluded := Event{ID: "other", StartDate: target.StartDate.AddDate(0, 0, -7), EndDate: target.StartDate.AddDate(0, 0, -7)}
|
||||
history := make([]DailyPoint, 0, 9)
|
||||
for week := 1; week <= 8; week++ {
|
||||
count := int64(30)
|
||||
if week == 1 {
|
||||
count = 1_000
|
||||
}
|
||||
history = append(history, DailyPoint{Date: target.StartDate.AddDate(0, 0, -7*week), SessionCount: count, PaidSessionCount: count, PaidAmountRial: count * 100})
|
||||
}
|
||||
history = append(history, DailyPoint{Date: target.StartDate, SessionCount: 50, PaidSessionCount: 50, PaidAmountRial: 5_000})
|
||||
effect := AnalyzeEventEffect(history, target, []Event{excluded})
|
||||
if effect.ObservationCount != 7 || effect.BaselineSessionCount != 30 || !effect.MaterialWin {
|
||||
t.Fatalf("catalog event polluted baseline: %+v", effect)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAnalyzeEventEffectUsesCompleteComparableWindows(t *testing.T) {
|
||||
target := Event{ID: "two-day", StartDate: date("2026-05-02"), EndDate: date("2026-05-03")}
|
||||
history := []DailyPoint{
|
||||
{Date: date("2026-04-25"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000},
|
||||
{Date: date("2026-04-26"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000},
|
||||
{Date: date("2026-04-18"), SessionCount: 1_000, PaidSessionCount: 1_000, PaidAmountRial: 100_000}, // fragmented week, next day absent
|
||||
{Date: date("2026-04-11"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000},
|
||||
{Date: date("2026-04-12"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000},
|
||||
{Date: date("2026-04-04"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000},
|
||||
{Date: date("2026-04-05"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000},
|
||||
{Date: date("2026-03-28"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000},
|
||||
{Date: date("2026-03-29"), SessionCount: 60, PaidSessionCount: 50, PaidAmountRial: 5_000},
|
||||
{Date: target.StartDate, SessionCount: 80, PaidSessionCount: 75, PaidAmountRial: 7_500},
|
||||
{Date: target.EndDate, SessionCount: 80, PaidSessionCount: 75, PaidAmountRial: 7_500},
|
||||
}
|
||||
effect := AnalyzeEventEffect(history, target, nil)
|
||||
if effect.ObservationCount != 4 || effect.BaselineSessionCount != 120 || effect.ActualSessionCount != 160 || effect.PaidSessionUpliftBPS != 5_000 {
|
||||
t.Fatalf("unexpected complete-window effect: %+v", effect)
|
||||
}
|
||||
}
|
||||
|
||||
func TestForecastKeepsPoorBacktestVisibleWithLowConfidence(t *testing.T) {
|
||||
start := date("2026-07-01")
|
||||
history := make([]DailyPoint, 0, 140)
|
||||
for i := 140; i > 0; i-- {
|
||||
value := int64(100)
|
||||
if i%2 == 0 {
|
||||
value = 1_000
|
||||
}
|
||||
history = append(history, DailyPoint{Date: start.AddDate(0, 0, -i), SessionCount: value, PaidSessionCount: value, PaidAmountRial: value * 1_000})
|
||||
}
|
||||
forecast := BuildForecast(history, nil, start, 90)
|
||||
if forecast.Backtest.Origins < 4 || forecast.Confidence != ConfidenceLow || len(forecast.Points) != 90 {
|
||||
t.Fatalf("poor model should remain visible with low confidence: %+v", forecast)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBacktestUsesProductionTrendPredictorAcrossFourteenDayHorizons(t *testing.T) {
|
||||
start := date("2026-07-01")
|
||||
history := make([]DailyPoint, 0, 140)
|
||||
for index := 0; index < 140; index++ {
|
||||
block := index / 28
|
||||
value := int64(100 + block*20)
|
||||
history = append(history, DailyPoint{Date: start.AddDate(0, 0, index-140), SessionCount: value, PaidSessionCount: value, PaidAmountRial: value * 1_000})
|
||||
}
|
||||
forecast := BuildForecast(history, nil, start, 90)
|
||||
if forecast.Backtest.Origins < 4 || forecast.Confidence == ConfidenceInsufficientData {
|
||||
t.Fatalf("trend-aware rolling backtest should be usable: %+v", forecast.Backtest)
|
||||
func TestDatabaseMappedCatalogCanBeValidated(t *testing.T) {
|
||||
if err := ValidateCatalog(testCatalog()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,20 +68,37 @@ func buildOpportunities(wins []EventEffect, catalog []Event, forecast Forecast,
|
||||
}
|
||||
}
|
||||
}
|
||||
// Always surface high-intent commercial moments. Historical evidence enriches
|
||||
// the estimate when available, but its absence must not hide events merchants
|
||||
// routinely plan campaigns for.
|
||||
for _, event := range catalog {
|
||||
if event.Kind != EventCommercial || dateBefore(event.EndDate, start) {
|
||||
continue
|
||||
}
|
||||
if _, exists := best[event.ID]; exists {
|
||||
continue
|
||||
}
|
||||
point, ok := forecastByDate[dayKey(event.StartDate)]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
best[event.ID] = Opportunity{
|
||||
EvidenceID: EvidenceID("opportunity", event.ID), EventID: event.ID,
|
||||
EventTitleFA: event.TitleFA, Date: normalizeDate(event.StartDate),
|
||||
ExpectedSessions: point.PaidSessions, ExpectedAmountRial: point.PaidAmountRial,
|
||||
}
|
||||
}
|
||||
result := make([]Opportunity, 0, len(best))
|
||||
for _, opportunity := range best {
|
||||
result = append(result, opportunity)
|
||||
}
|
||||
slices.SortFunc(result, func(a, b Opportunity) int {
|
||||
if a.SimilarityBPS != b.SimilarityBPS {
|
||||
if a.SimilarityBPS > b.SimilarityBPS {
|
||||
return -1
|
||||
}
|
||||
return 1
|
||||
}
|
||||
if comparison := a.Date.Compare(b.Date); comparison != 0 {
|
||||
return comparison
|
||||
}
|
||||
if a.SimilarityBPS != b.SimilarityBPS {
|
||||
return int(b.SimilarityBPS - a.SimilarityBPS)
|
||||
}
|
||||
return strings.Compare(a.EventID, b.EventID)
|
||||
})
|
||||
if len(result) > limit {
|
||||
|
||||
23
internal/growth/ranking_test.go
Normal file
23
internal/growth/ranking_test.go
Normal file
@@ -0,0 +1,23 @@
|
||||
package growth
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestRankEventPrefersMerchantFitAndHistoricalEvidence(t *testing.T) {
|
||||
base := Event{ImportanceBPS: 8_000, ConfidenceBPS: 9_000, Categories: []string{"فروشگاه"}, Tags: []string{"وبسایت"}, LeadDays: 14, CommercialPosture: "PROMOTE"}
|
||||
strong := RankEvent(base, RankInput{MerchantTerms: []string{"فروشگاه", "وبسایت"}, WithinPreparationWindow: true, HistoricalUpliftBPS: 12_000, HistoricalEligible: true})
|
||||
weak := RankEvent(base, RankInput{MerchantTerms: []string{"نامرتبط"}})
|
||||
if strong.ScoreBPS <= weak.ScoreBPS {
|
||||
t.Fatalf("merchant fit and history must increase rank: strong=%d weak=%d", strong.ScoreBPS, weak.ScoreBPS)
|
||||
}
|
||||
if len(strong.Factors) < 3 {
|
||||
t.Fatalf("rank factors = %+v, want actionable factors", strong.Factors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRankEventNeverPromotesMourningOrNoPromotionEvents(t *testing.T) {
|
||||
for _, event := range []Event{{CommercialPosture: "NO_PROMOTION"}, {CommercialPosture: "PROMOTE", Tone: "MOURNING"}} {
|
||||
if got := RankEvent(event, RankInput{WithinPreparationWindow: true}); got.Promotable {
|
||||
t.Fatalf("event %+v is promotable", event)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -59,10 +59,12 @@ type DeterministicReport struct {
|
||||
}
|
||||
|
||||
func BuildDeterministicReport(input ReportInput) DeterministicReport {
|
||||
catalog := filterCatalog(input.Catalog, input.Category)
|
||||
// Adapters provide an already-reviewed, scope-relevant catalog. Filtering it
|
||||
// again by exact category discards broadly applicable commercial events.
|
||||
catalog := input.Catalog
|
||||
forecast := BuildForecast(input.History, catalog, input.ForecastStart, input.HorizonDays)
|
||||
wins := historicalWins(input.History, catalog, input.ForecastStart)
|
||||
opportunities := buildOpportunities(wins, catalog, forecast, input.ForecastStart, 5)
|
||||
opportunities := buildOpportunities(wins, catalog, forecast, input.ForecastStart, 3)
|
||||
risks := DetectRisks(forecast, 3)
|
||||
if wins == nil {
|
||||
wins = []EventEffect{}
|
||||
|
||||
@@ -3,8 +3,7 @@ package growth
|
||||
import "time"
|
||||
|
||||
const (
|
||||
CatalogVersion = "1405.2"
|
||||
AnalyticsVersion = "1.2.0"
|
||||
AnalyticsVersion = "1.3.0"
|
||||
DefaultModelConfigVersion = "avalai-responses-v1-tools4-turns2-output1600"
|
||||
|
||||
MinBaselineObservations = 4
|
||||
|
||||
Reference in New Issue
Block a user