feat(report): add model-level event headlines

This commit is contained in:
phamnazage-jpg
2026-05-13 21:10:11 +08:00
parent 85f37a4d95
commit 79d991a7e9
2 changed files with 552 additions and 0 deletions

View File

@@ -179,6 +179,7 @@ type ReportV3 struct {
HeadlineItems []HeadlineItem HeadlineItems []HeadlineItem
SceneSections []SceneSection SceneSections []SceneSection
AppendixLinks []AppendixLink AppendixLinks []AppendixLink
ModelEvents []ModelEvent
} }
type DailySignals struct { type DailySignals struct {
@@ -212,6 +213,23 @@ type HeadlineItem struct {
Tone string 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 { type Recommendation struct {
Name string Name string
Provider string Provider string
@@ -494,6 +512,11 @@ func generateReportDataV3(db *sql.DB, date string) (*ReportV3, error) {
} else { } else {
report.DailySignals = signals report.DailySignals = signals
} }
if events, err := loadModelEvents(db, date); err != nil {
logger.Warn("加载模型级事件失败", "error", err)
} else {
report.ModelEvents = events
}
decorateReportV1(report) decorateReportV1(report)
return report, nil return report, nil
} }
@@ -729,6 +752,298 @@ func loadDailySignals(db *sql.DB, date string) (DailySignals, error) {
return signals, nil 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,
&currency,
&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,
&currency,
&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) { func decorateReportV1(r *ReportV3) {
if r == nil { if r == nil {
return return
@@ -750,6 +1065,7 @@ func decorateReportV1(r *ReportV3) {
r.MarketLabels = buildMarketLabels(r) r.MarketLabels = buildMarketLabels(r)
r.HeroSummary, r.HeroEvidence = buildHeroSummary(r) r.HeroSummary, r.HeroEvidence = buildHeroSummary(r)
r.SceneSections = buildSceneSections(r) r.SceneSections = buildSceneSections(r)
r.ModelEvents = enrichModelEvents(r)
r.ActionItems = buildActionItems(r) r.ActionItems = buildActionItems(r)
r.HeadlineItems = buildHeadlineItems(r) r.HeadlineItems = buildHeadlineItems(r)
r.AppendixLinks = []AppendixLink{ 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 { func buildFreeSourceBreakdown(models []ModelInfo) []FreeSourceStat {
counts := map[string]int{ counts := map[string]int{
"官方免费": 0, "官方免费": 0,
@@ -863,6 +1227,10 @@ func buildHeroSummary(r *ReportV3) (string, string) {
} }
func buildHeadlineItems(r *ReportV3) []HeadlineItem { func buildHeadlineItems(r *ReportV3) []HeadlineItem {
if items := buildHeadlineItemsFromEvents(r.ModelEvents); len(items) > 0 {
return items
}
var items []HeadlineItem var items []HeadlineItem
if r.DailySignals.NewModels > 0 { if r.DailySignals.NewModels > 0 {
@@ -914,6 +1282,67 @@ func buildHeadlineItems(r *ReportV3) []HeadlineItem {
return items 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 { func buildActionItems(r *ReportV3) []ActionItem {
var actions []ActionItem var actions []ActionItem

View File

@@ -182,6 +182,29 @@ func TestBuildFreeSourceBreakdown(t *testing.T) {
func TestDecorateReportV1BuildsHotDaySummary(t *testing.T) { func TestDecorateReportV1BuildsHotDaySummary(t *testing.T) {
report := sampleReportForV1() 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) decorateReportV1(report)
@@ -200,6 +223,9 @@ func TestDecorateReportV1BuildsHotDaySummary(t *testing.T) {
if report.ActionItems[0].Evidence == "" { if report.ActionItems[0].Evidence == "" {
t.Fatalf("expected action item evidence to be populated") 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) { func TestDecorateReportV1BuildsCalmDaySummary(t *testing.T) {
@@ -285,6 +311,18 @@ func TestGenerateMarkdownV3IncludesTencentSubscriptionSection(t *testing.T) {
func TestGenerateHTMLV3IncludesTencentSubscriptionSection(t *testing.T) { func TestGenerateHTMLV3IncludesTencentSubscriptionSection(t *testing.T) {
path := filepath.Join(t.TempDir(), "daily_report.html") path := filepath.Join(t.TempDir(), "daily_report.html")
report := sampleReportForV1() report := sampleReportForV1()
report.ModelEvents = []ModelEvent{
{
EventType: "new_model",
ModelName: "DeepSeek-V4-Flash",
ProviderName: "DeepSeek",
OperatorName: "OpenRouter",
TrustLabel: "聚合来源",
Baseline: "首次出现",
Summary: "新模型进入情报池,值得重新评估低成本编码默认选择。",
Priority: 95,
},
}
report.TencentSubscriptionPlans = []SubscriptionPlanInfo{ report.TencentSubscriptionPlans = []SubscriptionPlanInfo{
{ {
PlanName: "通用 Token Plan Lite", 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)
}
}