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

@@ -1,6 +1,8 @@
package growthreport
import (
"fmt"
"strings"
"testing"
"time"
@@ -36,14 +38,152 @@ func TestUpcomingEventsKeepsFiveChronologicalEligibleCandidates(t *testing.T) {
func TestFallbackEventRecommendationsUseTheFirstThreeCandidates(t *testing.T) {
t.Parallel()
events := []UpcomingEvent{{EvidenceID: "event:first"}, {EvidenceID: "event:second"}, {EvidenceID: "event:third"}, {EvidenceID: "event:fourth"}}
events := []UpcomingEvent{
{EvidenceID: "event:first", Date: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), InsightBasis: InsightBasisHistorical, HistoricalEvidence: &HistoricalEvidence{MaterialDecline: true}, RankingScore: 90},
{EvidenceID: "event:second", Date: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC), InsightBasis: InsightBasisBusinessProfile, ProfileEvidence: &ProfileEvidence{MatchedSignals: []string{"فروش آنلاین"}}, RankingScore: 60},
{EvidenceID: "event:third", Date: time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC), InsightBasis: InsightBasisBusinessProfile, ProfileEvidence: &ProfileEvidence{}, RankingScore: 30},
{EvidenceID: "event:fourth", Date: time.Date(2026, 1, 4, 0, 0, 0, 0, time.UTC), RankingScore: 1},
}
got := fallbackEventRecommendations(events)
if len(got) != 3 || got[0].EvidenceID != "event:first" || got[2].EvidenceID != "event:third" {
t.Fatalf("fallbackEventRecommendations() = %+v", got)
}
if got[0].Stance != StanceCaution || got[0].InsightBasis != InsightBasisHistorical {
t.Fatalf("historical decline fallback = %+v, want historical caution", got[0])
}
total := 0
for index, item := range got {
total += item.PriorityPercent
if item.PriorityPercent <= 0 || index > 0 && got[index-1].PriorityPercent < item.PriorityPercent {
t.Fatalf("fallback percentages are not positive descending: %+v", got)
}
}
if total != 100 {
t.Fatalf("fallback percentage sum = %d, want 100", total)
}
for _, item := range got {
if item.RelevanceReason == "" {
t.Fatal("fallback recommendation lacks a relevance reason")
}
}
}
func TestFallbackEventRecommendationsUsesStableEqualShares(t *testing.T) {
t.Parallel()
events := []UpcomingEvent{
{EvidenceID: "event:a", Date: time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC), InsightBasis: InsightBasisBusinessProfile, RankingScore: 10},
{EvidenceID: "event:b", Date: time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC), InsightBasis: InsightBasisBusinessProfile, RankingScore: 10},
{EvidenceID: "event:c", Date: time.Date(2026, 1, 3, 0, 0, 0, 0, time.UTC), InsightBasis: InsightBasisBusinessProfile, RankingScore: 10},
}
got := fallbackEventRecommendations(events)
if got[0].PriorityPercent != 34 || got[1].PriorityPercent != 33 || got[2].PriorityPercent != 33 {
t.Fatalf("equal fallback shares = %+v, want 34/33/33", got)
}
}
func TestFallbackEventRecommendationsNeverPromotesNonPromotableTone(t *testing.T) {
t.Parallel()
events := []UpcomingEvent{
{EvidenceID: "event:a", InsightBasis: InsightBasisBusinessProfile, CommercialPosture: "PROMOTE", Tone: "MOURNING", RankingScore: 30},
{EvidenceID: "event:b", InsightBasis: InsightBasisBusinessProfile, CommercialPosture: "PROMOTE", RankingScore: 20},
{EvidenceID: "event:c", InsightBasis: InsightBasisBusinessProfile, CommercialPosture: "PROMOTE", RankingScore: 10},
}
got := fallbackEventRecommendations(events)
if got[0].Stance == StancePromote {
t.Fatalf("non-promotable tone received PROMOTE fallback: %+v", got[0])
}
}
func TestApplyEventEvidencePrefersEligibleExactHistory(t *testing.T) {
t.Parallel()
prior := growth.Event{ID: "prior", StartDate: time.Date(2025, 11, 28, 0, 0, 0, 0, time.UTC), EndDate: time.Date(2025, 11, 29, 0, 0, 0, 0, time.UTC)}
effect := growth.EventEffect{Eligible: true, ObservationCount: 6, PaidSessionUpliftBPS: -2300, AmountUpliftBPS: -1200, MaterialDecline: true}
event := UpcomingEvent{EvidenceID: "event:black-friday", InsightBasis: InsightBasisBusinessProfile, ProfileEvidence: &ProfileEvidence{MatchedSignals: []string{"وب‌سایت"}}, RankingScore: 100}
got := applyEventEvidence(event, &prior, effect, growth.SuppressionInsufficientHistory)
if got.InsightBasis != InsightBasisHistorical || got.HistoricalEvidence == nil || got.ProfileEvidence != nil {
t.Fatalf("applyEventEvidence() = %+v, want historical-only evidence", got)
}
if got.HistoricalEvidence.PriorStartDate != prior.StartDate || got.HistoricalEvidence.PaidSessionUpliftBPS != -2300 || !got.HistoricalEvidence.MaterialDecline {
t.Fatalf("historical evidence = %+v", got.HistoricalEvidence)
}
if got.RankingScore <= event.RankingScore {
t.Fatalf("historical evidence did not increase ranking score: got %d, base %d", got.RankingScore, event.RankingScore)
}
}
func TestApplyEventEvidenceFallsBackWithSuppressionReason(t *testing.T) {
t.Parallel()
event := UpcomingEvent{EvidenceID: "event:yalda", InsightBasis: InsightBasisBusinessProfile, ProfileEvidence: &ProfileEvidence{MatchedSignals: []string{"فروش آنلاین"}}}
got := applyEventEvidence(event, nil, growth.EventEffect{}, growth.SuppressionInsufficientHistory)
if got.InsightBasis != InsightBasisBusinessProfile || got.HistoricalEvidence != nil || got.ProfileEvidence == nil {
t.Fatalf("applyEventEvidence() = %+v, want profile-only evidence", got)
}
if got.ProfileEvidence.HistoryUnavailableReason != growth.SuppressionInsufficientHistory {
t.Fatalf("history unavailable reason = %q", got.ProfileEvidence.HistoryUnavailableReason)
}
}
func TestMatchedProfileSignalsDoesNotFabricateCategoryMatch(t *testing.T) {
t.Parallel()
event := growth.Event{Categories: []string{"رستوران"}, Tags: []string{"حضوری"}}
context := MerchantContext{Category: "فروشگاه پوشاک", Niches: []string{"پوشاک کودک"}, SalesChannels: []string{"وب‌سایت"}}
got := matchedProfileSignals(event, context)
if len(got) != 0 {
t.Fatalf("matchedProfileSignals() = %+v, want no fabricated match", got)
}
}
func TestRecommendationFallbackRequiresAtLeastThreeCandidates(t *testing.T) {
t.Parallel()
for _, count := range []int{0, 1, 2} {
events := make([]UpcomingEvent, count)
fallback, shouldCallAI := recommendationFallback(events)
if fallback != nil || shouldCallAI {
t.Fatalf("recommendationFallback(%d events) = (%+v, %v), want nil,false", count, fallback, shouldCallAI)
}
}
for _, count := range []int{3, 4, 5} {
events := make([]UpcomingEvent, count)
for index := range events {
events[index] = UpcomingEvent{EvidenceID: fmt.Sprintf("event:%d", index), InsightBasis: InsightBasisBusinessProfile, RankingScore: int64(count - index)}
}
fallback, shouldCallAI := recommendationFallback(events)
if fallback == nil || len(fallback.EventRecommendations) != 3 || !shouldCallAI {
t.Fatalf("recommendationFallback(%d events) = (%+v, %v), want three recommendations,true", count, fallback, shouldCallAI)
}
}
}
func TestMergeMerchantContextsDeduplicatesCurrentProfilesDeterministically(t *testing.T) {
t.Parallel()
got := mergeMerchantContexts("فروشگاه", []MerchantContext{
{BusinessDescriptions: []string{"فروش پوشاک"}, Niches: []string{"پوشاک", "هدیه"}, SalesChannels: []string{"وب‌سایت"}},
{BusinessDescriptions: []string{"فروش هدیه", "فروش پوشاک"}, Niches: []string{"هدیه"}, SalesChannels: []string{"اینستاگرام", "وب‌سایت"}},
})
if strings.Join(got.BusinessDescriptions, ",") != "فروش پوشاک,فروش هدیه" || strings.Join(got.Niches, ",") != "پوشاک,هدیه" || strings.Join(got.SalesChannels, ",") != "وب‌سایت,اینستاگرام" {
t.Fatalf("mergeMerchantContexts() = %+v", got)
}
}
func TestMergeMerchantContextsBoundsModelProfilePayload(t *testing.T) {
t.Parallel()
profiles := make([]MerchantContext, 0, 7)
for index := range 7 {
profiles = append(profiles, MerchantContext{BusinessDescriptions: []string{strings.Repeat(string(rune('آ'+index)), 400)}, Niches: []string{fmt.Sprintf("حوزه-%d", index)}, SalesChannels: []string{fmt.Sprintf("کانال-%d", index)}})
}
got := mergeMerchantContexts("فروشگاه", profiles)
if len(got.BusinessDescriptions) != 5 {
t.Fatalf("business descriptions = %d, want bounded to 5", len(got.BusinessDescriptions))
}
for _, description := range got.BusinessDescriptions {
if len([]rune(description)) > 320 {
t.Fatalf("description contains %d runes, want at most 320", len([]rune(description)))
}
}
}