feat: rank growth events with grounded evidence

This commit is contained in:
2026-08-22 18:44:59 +08:00
parent 7f9c16be4b
commit 1a8a61cfe7
20 changed files with 2555 additions and 132 deletions

View File

@@ -33,6 +33,22 @@ func TestAnalyzeEventEffectSuppressesSmallBaseline(t *testing.T) {
}
}
func TestMostRecentExactOccurrenceRequiresOccasionAndVariant(t *testing.T) {
t.Parallel()
upcoming := Event{ID: "black-friday-2026", OccasionKey: "black-friday", EventVariant: "CAMPAIGN_WINDOW", StartDate: time.Date(2026, 11, 27, 0, 0, 0, 0, time.UTC)}
catalog := []Event{
{ID: "wrong-variant", OccasionKey: "black-friday", EventVariant: "PRIMARY", StartDate: time.Date(2025, 11, 28, 0, 0, 0, 0, time.UTC), EndDate: time.Date(2025, 11, 28, 0, 0, 0, 0, time.UTC)},
{ID: "old", OccasionKey: "black-friday", EventVariant: "CAMPAIGN_WINDOW", StartDate: time.Date(2024, 11, 29, 0, 0, 0, 0, time.UTC), EndDate: time.Date(2024, 11, 29, 0, 0, 0, 0, time.UTC)},
{ID: "recent", OccasionKey: "black-friday", EventVariant: "CAMPAIGN_WINDOW", StartDate: time.Date(2025, 11, 28, 0, 0, 0, 0, time.UTC), EndDate: time.Date(2025, 11, 28, 0, 0, 0, 0, time.UTC)},
{ID: "wrong-key", OccasionKey: "cyber-monday", EventVariant: "CAMPAIGN_WINDOW", StartDate: time.Date(2025, 12, 1, 0, 0, 0, 0, time.UTC), EndDate: time.Date(2025, 12, 1, 0, 0, 0, 0, time.UTC)},
}
got, ok := MostRecentExactOccurrence(upcoming, catalog)
if !ok || got.ID != "recent" {
t.Fatalf("MostRecentExactOccurrence() = (%+v, %v), want recent exact occurrence", got, ok)
}
}
func TestMatchUpcomingEventsPrefersFamilyThenTags(t *testing.T) {
historical := Event{ID: "teacher", Family: "appreciation", Tags: []string{"gift", "education"}}
upcoming := []Event{

View File

@@ -73,6 +73,57 @@ func AnalyzeEventEffect(history []DailyPoint, event Event, catalog []Event) Even
return effect
}
// MostRecentExactOccurrence returns only a completed occurrence with the same
// canonical occasion identity and variant. Similar titles, tags, and families
// are deliberately ignored so they cannot be represented as merchant history
// for the upcoming event.
func MostRecentExactOccurrence(upcoming Event, catalog []Event) (Event, bool) {
if strings.TrimSpace(upcoming.OccasionKey) == "" || strings.TrimSpace(upcoming.EventVariant) == "" {
return Event{}, false
}
var result Event
found := false
for _, candidate := range catalog {
if candidate.OccasionKey != upcoming.OccasionKey || candidate.EventVariant != upcoming.EventVariant || !dateBefore(candidate.EndDate, upcoming.StartDate) {
continue
}
if !found || dateAfter(candidate.EndDate, result.EndDate) || dayKey(candidate.EndDate) == dayKey(result.EndDate) && candidate.ID < result.ID {
result = candidate
found = true
}
}
return result, found
}
func RankEvent(event Event, input RankInput) EventRank {
rank := EventRank{Promotable: event.CommercialPosture != "NO_PROMOTION" && event.CommercialPosture != "CAUTION" && event.Tone != "MOURNING" && event.Tone != "MEMORIAL"}
rank.ScoreBPS = event.ImportanceBPS + event.ConfidenceBPS + event.RelevanceBPS
merchantTerms := normalizedSet(input.MerchantTerms)
for value := range normalizedSet(append(slices.Clone(event.Categories), event.Tags...)) {
if _, ok := merchantTerms[value]; ok {
rank.ScoreBPS += 2_000
rank.Factors = append(rank.Factors, value)
}
}
slices.Sort(rank.Factors)
if input.WithinPreparationWindow {
rank.ScoreBPS += 1_000
rank.Factors = append(rank.Factors, "PREPARATION_WINDOW")
}
if input.HistoricalEligible {
rank.ScoreBPS += 20_000 + abs64(input.HistoricalUpliftBPS)/10
rank.Factors = append(rank.Factors, "HISTORICAL_EVIDENCE")
}
return rank
}
func abs64(value int64) int64 {
if value < 0 {
return -value
}
return value
}
func MatchScoreBPS(historical, upcoming Event) (int64, string) {
if historical.Family != "" && strings.EqualFold(strings.TrimSpace(historical.Family), strings.TrimSpace(upcoming.Family)) {
return 10_000, "FAMILY"

View File

@@ -3,8 +3,8 @@ package growth
import "time"
const (
AnalyticsVersion = "1.3.0"
DefaultModelConfigVersion = "avalai-responses-v1-tools4-turns2-output1600"
AnalyticsVersion = "1.4.0"
DefaultModelConfigVersion = "avalai-responses-v1-ranked-events-tools4-turns2-output1600"
MinBaselineObservations = 4
MinBaselineSessions = 100
@@ -48,18 +48,39 @@ const (
)
type Event struct {
ID string `json:"id"`
TitleFA string `json:"titleFa"`
StartDate time.Time `json:"startDate"`
EndDate time.Time `json:"endDate"`
Family string `json:"family"`
Tags []string `json:"tags"`
Source string `json:"source"`
SourceVersion string `json:"sourceVersion"`
Categories []string `json:"categories"`
Kind EventKind `json:"kind"`
OfficialHoliday bool `json:"officialHoliday"`
Universal bool `json:"universal"`
ID string `json:"id"`
OccasionKey string `json:"occasionKey"`
EventVariant string `json:"eventVariant"`
TitleFA string `json:"titleFa"`
StartDate time.Time `json:"startDate"`
EndDate time.Time `json:"endDate"`
Family string `json:"family"`
Tags []string `json:"tags"`
Source string `json:"source"`
SourceVersion string `json:"sourceVersion"`
Categories []string `json:"categories"`
Kind EventKind `json:"kind"`
OfficialHoliday bool `json:"officialHoliday"`
Universal bool `json:"universal"`
Tone string `json:"tone,omitempty"`
CommercialPosture string `json:"commercialPosture,omitempty"`
LeadDays int `json:"leadDays,omitempty"`
RelevanceBPS int64 `json:"relevanceBps,omitempty"`
ConfidenceBPS int64 `json:"confidenceBps,omitempty"`
ImportanceBPS int64 `json:"importanceBps,omitempty"`
}
type RankInput struct {
MerchantTerms []string
WithinPreparationWindow bool
HistoricalUpliftBPS int64
HistoricalEligible bool
}
type EventRank struct {
ScoreBPS int64
Factors []string
Promotable bool
}
type EventEffect struct {