fix: deliver validated AI insight responses

This commit is contained in:
2026-08-22 15:47:38 +08:00
parent ca55c2fb3d
commit 3fba4261ee
68 changed files with 6870 additions and 493 deletions

View File

@@ -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)

View File

@@ -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
}

View File

@@ -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)
}
}

View File

@@ -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 {

View 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)
}
}
}

View File

@@ -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{}

View File

@@ -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