From 79d991a7e92bdbcf9c2504bad9ee22777395a40e Mon Sep 17 00:00:00 2001 From: phamnazage-jpg Date: Wed, 13 May 2026 21:10:11 +0800 Subject: [PATCH] feat(report): add model-level event headlines --- scripts/generate_daily_report.go | 429 ++++++++++++++++++++++++++ scripts/generate_daily_report_test.go | 123 ++++++++ 2 files changed, 552 insertions(+) diff --git a/scripts/generate_daily_report.go b/scripts/generate_daily_report.go index 6f84047..6c13313 100644 --- a/scripts/generate_daily_report.go +++ b/scripts/generate_daily_report.go @@ -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 diff --git a/scripts/generate_daily_report_test.go b/scripts/generate_daily_report_test.go index e39a8b2..392a394 100644 --- a/scripts/generate_daily_report_test.go +++ b/scripts/generate_daily_report_test.go @@ -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) + } +}