feat(report): add model-level event headlines
This commit is contained in:
@@ -179,6 +179,7 @@ type ReportV3 struct {
|
||||
HeadlineItems []HeadlineItem
|
||||
SceneSections []SceneSection
|
||||
AppendixLinks []AppendixLink
|
||||
ModelEvents []ModelEvent
|
||||
}
|
||||
|
||||
type DailySignals struct {
|
||||
@@ -212,6 +213,23 @@ type HeadlineItem struct {
|
||||
Tone string
|
||||
}
|
||||
|
||||
type ModelEvent struct {
|
||||
EventType string
|
||||
ModelName string
|
||||
ProviderName string
|
||||
OperatorName string
|
||||
TrustLabel string
|
||||
Baseline string
|
||||
Summary string
|
||||
Currency string
|
||||
OldInputPrice float64
|
||||
NewInputPrice float64
|
||||
OldOutputPrice float64
|
||||
NewOutputPrice float64
|
||||
PriceChangePct float64
|
||||
Priority int
|
||||
}
|
||||
|
||||
type Recommendation struct {
|
||||
Name string
|
||||
Provider string
|
||||
@@ -494,6 +512,11 @@ func generateReportDataV3(db *sql.DB, date string) (*ReportV3, error) {
|
||||
} else {
|
||||
report.DailySignals = signals
|
||||
}
|
||||
if events, err := loadModelEvents(db, date); err != nil {
|
||||
logger.Warn("加载模型级事件失败", "error", err)
|
||||
} else {
|
||||
report.ModelEvents = events
|
||||
}
|
||||
decorateReportV1(report)
|
||||
return report, nil
|
||||
}
|
||||
@@ -729,6 +752,298 @@ func loadDailySignals(db *sql.DB, date string) (DailySignals, error) {
|
||||
return signals, nil
|
||||
}
|
||||
|
||||
func loadModelEvents(db *sql.DB, date string) ([]ModelEvent, error) {
|
||||
var events []ModelEvent
|
||||
|
||||
newModelEvents, err := loadNewModelEvents(db, date)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events = append(events, newModelEvents...)
|
||||
|
||||
priceEvents, err := loadPriceChangeEvents(db, date)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
events = append(events, priceEvents...)
|
||||
|
||||
sort.Slice(events, func(i, j int) bool {
|
||||
if events[i].Priority != events[j].Priority {
|
||||
return events[i].Priority > events[j].Priority
|
||||
}
|
||||
return events[i].ModelName < events[j].ModelName
|
||||
})
|
||||
|
||||
return dedupeModelEvents(events), nil
|
||||
}
|
||||
|
||||
func loadNewModelEvents(db *sql.DB, date string) ([]ModelEvent, error) {
|
||||
rows, err := db.Query(`
|
||||
WITH latest_prices AS (
|
||||
SELECT
|
||||
rp.model_id,
|
||||
COALESCE(o.name, 'Unknown') AS operator_name,
|
||||
COALESCE(o.type, 'reseller') AS operator_type,
|
||||
rp.currency,
|
||||
rp.input_price_per_mtok,
|
||||
rp.output_price_per_mtok,
|
||||
rp.is_free,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY rp.model_id
|
||||
ORDER BY rp.effective_date DESC NULLS LAST, rp.id DESC
|
||||
) AS rn
|
||||
FROM region_pricing rp
|
||||
LEFT JOIN operator o ON rp.operator_id = o.id
|
||||
)
|
||||
SELECT
|
||||
COALESCE(NULLIF(m.name, ''), m.external_id) AS model_name,
|
||||
COALESCE(mp.name, split_part(m.external_id, '/', 1)) AS provider_name,
|
||||
COALESCE(lp.operator_name, 'OpenRouter') AS operator_name,
|
||||
COALESCE(lp.operator_type, 'reseller') AS operator_type,
|
||||
COALESCE(lp.currency, 'USD') AS currency,
|
||||
COALESCE(lp.input_price_per_mtok, 0) AS input_price,
|
||||
COALESCE(lp.output_price_per_mtok, 0) AS output_price,
|
||||
COALESCE(lp.is_free, false) AS is_free,
|
||||
COALESCE(m.context_length, 0) AS context_length,
|
||||
COALESCE(mp.country, 'unknown') AS provider_country
|
||||
FROM models m
|
||||
LEFT JOIN model_provider mp ON m.provider_id = mp.id
|
||||
LEFT JOIN latest_prices lp ON lp.model_id = m.id AND lp.rn = 1
|
||||
WHERE m.deleted_at IS NULL
|
||||
AND DATE(m.created_at) = $1::date
|
||||
ORDER BY m.created_at DESC, m.id DESC
|
||||
LIMIT 8
|
||||
`, date)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var events []ModelEvent
|
||||
for rows.Next() {
|
||||
var (
|
||||
modelName string
|
||||
providerName string
|
||||
operatorName string
|
||||
operatorType string
|
||||
currency string
|
||||
inputPrice float64
|
||||
outputPrice float64
|
||||
isFree bool
|
||||
contextLength int
|
||||
providerCountry string
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&modelName,
|
||||
&providerName,
|
||||
&operatorName,
|
||||
&operatorType,
|
||||
¤cy,
|
||||
&inputPrice,
|
||||
&outputPrice,
|
||||
&isFree,
|
||||
&contextLength,
|
||||
&providerCountry,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
model := ModelInfo{
|
||||
Name: modelName,
|
||||
ProviderName: providerName,
|
||||
ProviderCountry: providerCountry,
|
||||
ContextLength: contextLength,
|
||||
InputPrice: inputPrice,
|
||||
OutputPrice: outputPrice,
|
||||
Currency: currency,
|
||||
IsFree: isFree,
|
||||
OperatorName: operatorName,
|
||||
OperatorType: operatorType,
|
||||
}
|
||||
|
||||
summary := "新模型进入情报池,值得重新评估当前默认选择。"
|
||||
if isFree {
|
||||
summary = fmt.Sprintf("新模型首日可免费试用,需注意其免费来源属于%s。", classifyFreeSource(model))
|
||||
} else if contextLength >= 1024*256 {
|
||||
summary = fmt.Sprintf("新模型带来 %s 长上下文,值得复查 Agent 和代码场景。", formatContextWindowCompact(contextLength))
|
||||
}
|
||||
|
||||
events = append(events, ModelEvent{
|
||||
EventType: "new_model",
|
||||
ModelName: modelName,
|
||||
ProviderName: providerName,
|
||||
OperatorName: operatorName,
|
||||
TrustLabel: buildTrustLabel(model),
|
||||
Baseline: "首次出现",
|
||||
Summary: summary,
|
||||
Currency: currency,
|
||||
NewInputPrice: inputPrice,
|
||||
NewOutputPrice: outputPrice,
|
||||
Priority: 85 + minInt(contextLength/(1024*128), 10),
|
||||
})
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
func loadPriceChangeEvents(db *sql.DB, date string) ([]ModelEvent, error) {
|
||||
rows, err := db.Query(`
|
||||
WITH latest_prices AS (
|
||||
SELECT
|
||||
rp.model_id,
|
||||
COALESCE(o.name, 'Unknown') AS operator_name,
|
||||
COALESCE(o.type, 'reseller') AS operator_type,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY rp.model_id
|
||||
ORDER BY rp.effective_date DESC NULLS LAST, rp.id DESC
|
||||
) AS rn
|
||||
FROM region_pricing rp
|
||||
LEFT JOIN operator o ON rp.operator_id = o.id
|
||||
)
|
||||
SELECT
|
||||
COALESCE(NULLIF(m.name, ''), m.external_id) AS model_name,
|
||||
COALESCE(mp.name, split_part(m.external_id, '/', 1)) AS provider_name,
|
||||
COALESCE(lp.operator_name, 'OpenRouter') AS operator_name,
|
||||
COALESCE(lp.operator_type, 'reseller') AS operator_type,
|
||||
ph.currency,
|
||||
COALESCE(ph.old_input_price, 0),
|
||||
COALESCE(ph.new_input_price, 0),
|
||||
COALESCE(ph.old_output_price, 0),
|
||||
COALESCE(ph.new_output_price, 0),
|
||||
COALESCE(mp.country, 'unknown') AS provider_country
|
||||
FROM pricing_history ph
|
||||
JOIN models m ON ph.model_id = m.id
|
||||
LEFT JOIN model_provider mp ON m.provider_id = mp.id
|
||||
LEFT JOIN latest_prices lp ON lp.model_id = m.id AND lp.rn = 1
|
||||
WHERE DATE(ph.changed_at) = $1::date
|
||||
ORDER BY ph.changed_at DESC, ph.id DESC
|
||||
LIMIT 16
|
||||
`, date)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
var events []ModelEvent
|
||||
for rows.Next() {
|
||||
var (
|
||||
modelName string
|
||||
providerName string
|
||||
operatorName string
|
||||
operatorType string
|
||||
currency string
|
||||
oldInputPrice float64
|
||||
newInputPrice float64
|
||||
oldOutputPrice float64
|
||||
newOutputPrice float64
|
||||
providerCountry string
|
||||
)
|
||||
if err := rows.Scan(
|
||||
&modelName,
|
||||
&providerName,
|
||||
&operatorName,
|
||||
&operatorType,
|
||||
¤cy,
|
||||
&oldInputPrice,
|
||||
&newInputPrice,
|
||||
&oldOutputPrice,
|
||||
&newOutputPrice,
|
||||
&providerCountry,
|
||||
); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
changePct := signedPriceChangePct(oldInputPrice, newInputPrice, oldOutputPrice, newOutputPrice)
|
||||
if changePct == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
model := ModelInfo{
|
||||
Name: modelName,
|
||||
ProviderName: providerName,
|
||||
ProviderCountry: providerCountry,
|
||||
Currency: currency,
|
||||
OperatorName: operatorName,
|
||||
OperatorType: operatorType,
|
||||
}
|
||||
|
||||
eventType := "price_increase"
|
||||
label := "价格上调"
|
||||
summary := "价格上调已足以影响默认成本,需要确认备用模型。"
|
||||
if changePct < 0 {
|
||||
eventType = "price_cut"
|
||||
label = "价格下调"
|
||||
summary = "价格下降已足以影响默认选型,值得重新评估同类模型。"
|
||||
}
|
||||
|
||||
events = append(events, ModelEvent{
|
||||
EventType: eventType,
|
||||
ModelName: modelName,
|
||||
ProviderName: providerName,
|
||||
OperatorName: operatorName,
|
||||
TrustLabel: buildTrustLabel(model),
|
||||
Baseline: fmt.Sprintf("较昨日 %+.0f%%", changePct),
|
||||
Summary: summary,
|
||||
Currency: currency,
|
||||
OldInputPrice: oldInputPrice,
|
||||
NewInputPrice: newInputPrice,
|
||||
OldOutputPrice: oldOutputPrice,
|
||||
NewOutputPrice: newOutputPrice,
|
||||
PriceChangePct: changePct,
|
||||
Priority: 70 + minInt(int(abs(changePct)), 25),
|
||||
})
|
||||
|
||||
_ = label
|
||||
}
|
||||
return events, rows.Err()
|
||||
}
|
||||
|
||||
func dedupeModelEvents(events []ModelEvent) []ModelEvent {
|
||||
seen := make(map[string]struct{})
|
||||
result := make([]ModelEvent, 0, len(events))
|
||||
for _, event := range events {
|
||||
key := event.EventType + "|" + event.ModelName
|
||||
if _, exists := seen[key]; exists {
|
||||
continue
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
result = append(result, event)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func signedPriceChangePct(oldInput, newInput, oldOutput, newOutput float64) float64 {
|
||||
inputPct := signedChange(oldInput, newInput)
|
||||
outputPct := signedChange(oldOutput, newOutput)
|
||||
if abs(inputPct) >= abs(outputPct) {
|
||||
return inputPct
|
||||
}
|
||||
return outputPct
|
||||
}
|
||||
|
||||
func signedChange(oldValue, newValue float64) float64 {
|
||||
if oldValue == 0 {
|
||||
if newValue == 0 {
|
||||
return 0
|
||||
}
|
||||
return 100
|
||||
}
|
||||
return ((newValue - oldValue) / oldValue) * 100
|
||||
}
|
||||
|
||||
func minInt(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func abs(v float64) float64 {
|
||||
if v < 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
func decorateReportV1(r *ReportV3) {
|
||||
if r == nil {
|
||||
return
|
||||
@@ -750,6 +1065,7 @@ func decorateReportV1(r *ReportV3) {
|
||||
r.MarketLabels = buildMarketLabels(r)
|
||||
r.HeroSummary, r.HeroEvidence = buildHeroSummary(r)
|
||||
r.SceneSections = buildSceneSections(r)
|
||||
r.ModelEvents = enrichModelEvents(r)
|
||||
r.ActionItems = buildActionItems(r)
|
||||
r.HeadlineItems = buildHeadlineItems(r)
|
||||
r.AppendixLinks = []AppendixLink{
|
||||
@@ -759,6 +1075,54 @@ func decorateReportV1(r *ReportV3) {
|
||||
}
|
||||
}
|
||||
|
||||
func enrichModelEvents(r *ReportV3) []ModelEvent {
|
||||
events := append([]ModelEvent{}, r.ModelEvents...)
|
||||
existing := make(map[string]struct{}, len(events))
|
||||
for _, event := range events {
|
||||
existing[event.EventType+"|"+event.ModelName] = struct{}{}
|
||||
}
|
||||
|
||||
addFreeHighlight := func(model ModelInfo, priority int) {
|
||||
key := "free_highlight|" + model.Name
|
||||
if _, exists := existing[key]; exists {
|
||||
return
|
||||
}
|
||||
existing[key] = struct{}{}
|
||||
events = append(events, ModelEvent{
|
||||
EventType: "free_highlight",
|
||||
ModelName: model.Name,
|
||||
ProviderName: model.ProviderName,
|
||||
OperatorName: model.OperatorName,
|
||||
TrustLabel: buildTrustLabel(model),
|
||||
Baseline: "今日快照",
|
||||
Summary: buildModelEvidence(model),
|
||||
Currency: model.Currency,
|
||||
Priority: priority,
|
||||
})
|
||||
}
|
||||
|
||||
for _, model := range r.FreeModels {
|
||||
if classifyFreeSource(model) == "官方免费" {
|
||||
addFreeHighlight(model, 72)
|
||||
break
|
||||
}
|
||||
}
|
||||
for _, model := range r.FreeModels {
|
||||
if classifyFreeSource(model) == "聚合免费" {
|
||||
addFreeHighlight(model, 68)
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
sort.Slice(events, func(i, j int) bool {
|
||||
if events[i].Priority != events[j].Priority {
|
||||
return events[i].Priority > events[j].Priority
|
||||
}
|
||||
return events[i].ModelName < events[j].ModelName
|
||||
})
|
||||
return events
|
||||
}
|
||||
|
||||
func buildFreeSourceBreakdown(models []ModelInfo) []FreeSourceStat {
|
||||
counts := map[string]int{
|
||||
"官方免费": 0,
|
||||
@@ -863,6 +1227,10 @@ func buildHeroSummary(r *ReportV3) (string, string) {
|
||||
}
|
||||
|
||||
func buildHeadlineItems(r *ReportV3) []HeadlineItem {
|
||||
if items := buildHeadlineItemsFromEvents(r.ModelEvents); len(items) > 0 {
|
||||
return items
|
||||
}
|
||||
|
||||
var items []HeadlineItem
|
||||
|
||||
if r.DailySignals.NewModels > 0 {
|
||||
@@ -914,6 +1282,67 @@ func buildHeadlineItems(r *ReportV3) []HeadlineItem {
|
||||
return items
|
||||
}
|
||||
|
||||
func buildHeadlineItemsFromEvents(events []ModelEvent) []HeadlineItem {
|
||||
if len(events) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
sort.Slice(events, func(i, j int) bool {
|
||||
if events[i].Priority != events[j].Priority {
|
||||
return events[i].Priority > events[j].Priority
|
||||
}
|
||||
return events[i].ModelName < events[j].ModelName
|
||||
})
|
||||
|
||||
var items []HeadlineItem
|
||||
usedModels := make(map[string]struct{})
|
||||
for _, event := range dedupeModelEvents(events) {
|
||||
if _, exists := usedModels[event.ModelName]; exists {
|
||||
continue
|
||||
}
|
||||
usedModels[event.ModelName] = struct{}{}
|
||||
items = append(items, headlineItemFromModelEvent(event))
|
||||
if len(items) >= 3 {
|
||||
break
|
||||
}
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func headlineItemFromModelEvent(event ModelEvent) HeadlineItem {
|
||||
item := HeadlineItem{
|
||||
Title: event.ModelName,
|
||||
Summary: event.Summary,
|
||||
Baseline: event.Baseline,
|
||||
TrustLabel: event.TrustLabel,
|
||||
Tone: "neutral",
|
||||
}
|
||||
|
||||
switch event.EventType {
|
||||
case "new_model":
|
||||
item.Label = "新模型"
|
||||
item.Title = fmt.Sprintf("%s 进入今日情报池", event.ModelName)
|
||||
item.Tone = "info"
|
||||
case "price_cut":
|
||||
item.Label = "价格下调"
|
||||
item.Title = fmt.Sprintf("%s 成本下调 %.0f%%", event.ModelName, abs(event.PriceChangePct))
|
||||
item.Tone = "success"
|
||||
case "price_increase":
|
||||
item.Label = "价格上调"
|
||||
item.Title = fmt.Sprintf("%s 成本上调 %.0f%%", event.ModelName, abs(event.PriceChangePct))
|
||||
item.Tone = "caution"
|
||||
case "free_highlight":
|
||||
item.Label = "免费机会"
|
||||
item.Title = fmt.Sprintf("%s 当前可免费试用", event.ModelName)
|
||||
item.Tone = "warning"
|
||||
default:
|
||||
item.Label = "观察重点"
|
||||
item.Title = fmt.Sprintf("%s 值得关注", event.ModelName)
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
|
||||
func buildActionItems(r *ReportV3) []ActionItem {
|
||||
var actions []ActionItem
|
||||
|
||||
|
||||
@@ -182,6 +182,29 @@ func TestBuildFreeSourceBreakdown(t *testing.T) {
|
||||
|
||||
func TestDecorateReportV1BuildsHotDaySummary(t *testing.T) {
|
||||
report := sampleReportForV1()
|
||||
report.ModelEvents = []ModelEvent{
|
||||
{
|
||||
EventType: "new_model",
|
||||
ModelName: "DeepSeek-V4-Flash",
|
||||
ProviderName: "DeepSeek",
|
||||
OperatorName: "OpenRouter",
|
||||
TrustLabel: "聚合来源",
|
||||
Baseline: "首次出现",
|
||||
Summary: "新模型进入情报池,值得重新评估低成本编码默认选择。",
|
||||
Priority: 95,
|
||||
},
|
||||
{
|
||||
EventType: "price_cut",
|
||||
ModelName: "qwen-vl-max",
|
||||
ProviderName: "Alibaba",
|
||||
OperatorName: "DashScope",
|
||||
TrustLabel: "官方来源",
|
||||
Baseline: "较昨日 -18%",
|
||||
Summary: "价格下降已足以影响视觉模型默认选择。",
|
||||
PriceChangePct: -18,
|
||||
Priority: 90,
|
||||
},
|
||||
}
|
||||
|
||||
decorateReportV1(report)
|
||||
|
||||
@@ -200,6 +223,9 @@ func TestDecorateReportV1BuildsHotDaySummary(t *testing.T) {
|
||||
if report.ActionItems[0].Evidence == "" {
|
||||
t.Fatalf("expected action item evidence to be populated")
|
||||
}
|
||||
if !strings.Contains(report.HeadlineItems[0].Title, "DeepSeek-V4-Flash") {
|
||||
t.Fatalf("expected first headline to come from model events, got %+v", report.HeadlineItems[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecorateReportV1BuildsCalmDaySummary(t *testing.T) {
|
||||
@@ -285,6 +311,18 @@ func TestGenerateMarkdownV3IncludesTencentSubscriptionSection(t *testing.T) {
|
||||
func TestGenerateHTMLV3IncludesTencentSubscriptionSection(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "daily_report.html")
|
||||
report := sampleReportForV1()
|
||||
report.ModelEvents = []ModelEvent{
|
||||
{
|
||||
EventType: "new_model",
|
||||
ModelName: "DeepSeek-V4-Flash",
|
||||
ProviderName: "DeepSeek",
|
||||
OperatorName: "OpenRouter",
|
||||
TrustLabel: "聚合来源",
|
||||
Baseline: "首次出现",
|
||||
Summary: "新模型进入情报池,值得重新评估低成本编码默认选择。",
|
||||
Priority: 95,
|
||||
},
|
||||
}
|
||||
report.TencentSubscriptionPlans = []SubscriptionPlanInfo{
|
||||
{
|
||||
PlanName: "通用 Token Plan Lite",
|
||||
@@ -314,6 +352,8 @@ func TestGenerateHTMLV3IncludesTencentSubscriptionSection(t *testing.T) {
|
||||
"今日一句话结论",
|
||||
"三条行动建议",
|
||||
"今日头条",
|
||||
"DeepSeek-V4-Flash",
|
||||
"首次出现",
|
||||
"场景推荐",
|
||||
"完整数据附录",
|
||||
"官方免费",
|
||||
@@ -326,3 +366,86 @@ func TestGenerateHTMLV3IncludesTencentSubscriptionSection(t *testing.T) {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHeadlineItemsUsesModelEvents(t *testing.T) {
|
||||
report := sampleReportForV1()
|
||||
report.ModelEvents = []ModelEvent{
|
||||
{
|
||||
EventType: "price_cut",
|
||||
ModelName: "glm-5",
|
||||
ProviderName: "Zhipu",
|
||||
OperatorName: "Zhipu",
|
||||
TrustLabel: "官方来源",
|
||||
Baseline: "较昨日 -25%",
|
||||
Summary: "价格下降已足以影响中文通用场景默认选型。",
|
||||
PriceChangePct: -25,
|
||||
Priority: 100,
|
||||
},
|
||||
{
|
||||
EventType: "new_model",
|
||||
ModelName: "DeepSeek-V4-Flash",
|
||||
ProviderName: "DeepSeek",
|
||||
OperatorName: "OpenRouter",
|
||||
TrustLabel: "聚合来源",
|
||||
Baseline: "首次出现",
|
||||
Summary: "新模型进入情报池,值得重新评估低成本编码默认选择。",
|
||||
Priority: 90,
|
||||
},
|
||||
}
|
||||
|
||||
items := buildHeadlineItems(report)
|
||||
|
||||
if len(items) < 2 {
|
||||
t.Fatalf("expected at least 2 headline items, got %d", len(items))
|
||||
}
|
||||
if !strings.Contains(items[0].Title, "glm-5") {
|
||||
t.Fatalf("expected price_cut event to rank first, got %+v", items[0])
|
||||
}
|
||||
if items[0].Baseline != "较昨日 -25%" {
|
||||
t.Fatalf("expected event baseline to be preserved, got %+v", items[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildHeadlineItemsDeduplicatesSameModel(t *testing.T) {
|
||||
report := sampleReportForV1()
|
||||
report.ModelEvents = []ModelEvent{
|
||||
{
|
||||
EventType: "price_cut",
|
||||
ModelName: "OpenAI: GPT-4o",
|
||||
ProviderName: "OpenAI",
|
||||
TrustLabel: "官方来源",
|
||||
Baseline: "较昨日 -20%",
|
||||
Summary: "价格下降影响默认成本。",
|
||||
PriceChangePct: -20,
|
||||
Priority: 95,
|
||||
},
|
||||
{
|
||||
EventType: "price_increase",
|
||||
ModelName: "OpenAI: GPT-4o",
|
||||
ProviderName: "OpenAI",
|
||||
TrustLabel: "官方来源",
|
||||
Baseline: "较昨日 +5%",
|
||||
Summary: "同日另有上调记录。",
|
||||
PriceChangePct: 5,
|
||||
Priority: 80,
|
||||
},
|
||||
{
|
||||
EventType: "new_model",
|
||||
ModelName: "Claude Opus 4.7",
|
||||
ProviderName: "Anthropic",
|
||||
TrustLabel: "聚合来源",
|
||||
Baseline: "首次出现",
|
||||
Summary: "新模型上线。",
|
||||
Priority: 70,
|
||||
},
|
||||
}
|
||||
|
||||
items := buildHeadlineItems(report)
|
||||
|
||||
if len(items) != 2 {
|
||||
t.Fatalf("expected 2 deduplicated headline items, got %d", len(items))
|
||||
}
|
||||
if strings.Contains(items[1].Title, "GPT-4o") {
|
||||
t.Fatalf("expected duplicate model event to be removed, got %+v", items)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user