diff --git a/docs/plans/2026-05-14-daily-report-v2-closeout-plan.md b/docs/plans/2026-05-14-daily-report-v2-closeout-plan.md
new file mode 100644
index 0000000..9b9fb04
--- /dev/null
+++ b/docs/plans/2026-05-14-daily-report-v2-closeout-plan.md
@@ -0,0 +1,143 @@
+# Daily Report V2 Closeout Implementation Plan
+
+> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
+
+**Goal:** 补齐日报 V2 情报版剩余 3 个收口项,让事件流覆盖营销活动、头条显式展示影响对象,并新增可重复执行的历史日报事件覆盖率验收脚本。
+
+**Architecture:** 继续复用 `scripts/generate_daily_report.go` 作为唯一日报语义层入口,不改数据库结构。`promo_campaign` 先以最小本地活动源接入,再复用现有 `ModelEvent -> HeadlineItem` 链路;头条影响对象通过扩展 `HeadlineItem` 数据结构和模板完成;覆盖率验收通过单独脚本直接查询数据库并调用现有历史重建入口完成。
+
+**Tech Stack:** Go 1.22、html/template、PostgreSQL、Bash
+
+---
+
+### Task 1: 为 V2 收口项补失败测试
+
+**Files:**
+- Modify: `scripts/generate_daily_report_test.go`
+
+**Step 1: 写失败测试**
+
+补 3 组测试:
+- `promo_campaign` 头条测试:验证会生成 `活动/营销` 类型头条,并保留来源、证据、基线
+- 头条影响对象测试:验证 `HeadlineItem` 有 `Audience`,HTML/Markdown 都会渲染
+- 覆盖率汇总测试:如果批量验收逻辑抽成辅助函数,则为通过率计算补单测
+
+**Step 2: 运行失败测试**
+
+Run: `go test -tags llm_script scripts/generate_daily_report.go scripts/generate_daily_report_test.go`
+
+Expected:
+- 新增断言失败
+- 失败原因是缺少 `promo_campaign` / `Audience` 相关实现
+
+**Step 3: Commit**
+
+```bash
+git add scripts/generate_daily_report_test.go
+git commit -m "test(report): cover v2 closeout requirements"
+```
+
+### Task 2: 接入 promo_campaign 事件流并渲染头条影响对象
+
+**Files:**
+- Modify: `scripts/generate_daily_report.go`
+- Modify: `scripts/generate_daily_report_test.go`
+
+**Step 1: 最小实现 promo 活动源**
+
+在 `scripts/generate_daily_report.go` 中新增本地活动源定义,至少包含:
+- 活动日期
+- 模型名或匹配键
+- 标题 / 摘要
+- 主来源
+- 证据说明
+- 影响对象
+- 优先级
+
+首批只接入最小样本,够覆盖 V2 能力:
+- `DeepSeek` 价格活动或发布期活动
+- 允许未来继续追加
+
+**Step 2: 把活动源并入事件流**
+
+在 `loadModelEvents` 中新增:
+- `loadPromoCampaignEvents(date string)` 或等价辅助函数
+- 统一走 `ModelEvent`
+- `EventType = "promo_campaign"`
+
+**Step 3: 扩 HeadlineItem 影响对象**
+
+新增:
+- `HeadlineItem.Audience`
+- `ModelEvent.Audience`
+
+并在:
+- `headlineItemFromModelEvent`
+- Markdown 头条输出
+- HTML 头条卡输出
+
+中渲染“影响对象”。
+
+**Step 4: 重新运行测试**
+
+Run: `go test -tags llm_script scripts/generate_daily_report.go scripts/generate_daily_report_test.go`
+
+Expected:
+- `promo_campaign`、`Audience` 相关测试通过
+
+**Step 5: Commit**
+
+```bash
+git add scripts/generate_daily_report.go scripts/generate_daily_report_test.go
+git commit -m "feat(report): close v2 event and audience gaps"
+```
+
+### Task 3: 新增 V2 历史日报事件覆盖率验收脚本
+
+**Files:**
+- Create: `scripts/verify_v2_event_coverage.sh`
+- Create or Modify: `scripts/report_event_coverage.go`
+- Modify: `scripts/generate_daily_report_test.go` 或新增脚本测试文件(仅在有价值时)
+
+**Step 1: 实现覆盖率统计脚本**
+
+目标:
+- 输入日期范围
+- 统计每个日期是否命中真正变化事件
+- 统计命中率
+- 失败阈值:小于 80%
+
+“真正变化事件”至少包括:
+- `official_release`
+- `promo_campaign`
+- `new_model`
+- `price_cut`
+- `price_increase`
+
+**Step 2: 使用历史日报入口做一次真实验收**
+
+Run:
+- `bash scripts/verify_v2_event_coverage.sh 2024-06-01 2026-05-14`
+
+Expected:
+- 输出总天数、命中天数、覆盖率
+- 覆盖率满足或明确暴露当前缺口
+
+**Step 3: 运行全量验证**
+
+Run:
+- `go test ./...`
+- `go run -tags llm_script scripts/generate_daily_report.go --date=2025-08-07`
+- `bash scripts/verify_v2_event_coverage.sh 2024-06-01 2026-05-14`
+
+Expected:
+- Go 测试通过
+- 历史日报仍可生成
+- 覆盖率脚本输出稳定
+
+**Step 4: Commit**
+
+```bash
+git add scripts/verify_v2_event_coverage.sh scripts/report_event_coverage.go scripts/generate_daily_report.go scripts/generate_daily_report_test.go
+git commit -m "feat(report): add v2 event coverage verification"
+```
diff --git a/scripts/generate_daily_report.go b/scripts/generate_daily_report.go
index 6a0e6de..d93887b 100644
--- a/scripts/generate_daily_report.go
+++ b/scripts/generate_daily_report.go
@@ -240,6 +240,7 @@ type HeadlineItem struct {
Label string
Title string
Summary string
+ Audience string
Baseline string
TrustLabel string
SourceKindLabel string
@@ -254,6 +255,7 @@ type ModelEvent struct {
ModelName string
ProviderName string
OperatorName string
+ Audience string
TrustLabel string
SourceKindLabel string
PrimarySource string
@@ -270,6 +272,21 @@ type ModelEvent struct {
Priority int
}
+type PromoCampaignDefinition struct {
+ Date string `json:"date"`
+ ModelName string `json:"model_name"`
+ ProviderName string `json:"provider_name"`
+ OperatorName string `json:"operator_name"`
+ Summary string `json:"summary"`
+ Audience string `json:"audience"`
+ Baseline string `json:"baseline"`
+ TrustLabel string `json:"trust_label"`
+ SourceKindLabel string `json:"source_kind_label"`
+ PrimarySource string `json:"primary_source"`
+ EvidenceDetail string `json:"evidence_detail"`
+ Priority int `json:"priority"`
+}
+
type Recommendation struct {
Name string
Provider string
@@ -807,6 +824,12 @@ func loadModelEvents(db *sql.DB, date string) ([]ModelEvent, error) {
}
events = append(events, releaseEvents...)
+ promoEvents, err := loadPromoCampaignEvents(date)
+ if err != nil {
+ return nil, err
+ }
+ events = append(events, promoEvents...)
+
priceEvents, err := loadPriceChangeEvents(db, date)
if err != nil {
return nil, err
@@ -823,6 +846,71 @@ func loadModelEvents(db *sql.DB, date string) ([]ModelEvent, error) {
return dedupeModelEvents(events), nil
}
+func loadPromoCampaignEvents(date string) ([]ModelEvent, error) {
+ definitions, err := loadPromoCampaignDefinitions()
+ if err != nil {
+ return nil, err
+ }
+
+ var events []ModelEvent
+ for _, definition := range definitions {
+ if definition.Date != date {
+ continue
+ }
+ events = append(events, ModelEvent{
+ EventType: "promo_campaign",
+ ModelName: definition.ModelName,
+ ProviderName: definition.ProviderName,
+ OperatorName: definition.OperatorName,
+ Audience: firstNonEmpty(definition.Audience, "适合计划利用活动窗口压低成本的团队"),
+ TrustLabel: firstNonEmpty(definition.TrustLabel, "官方来源 / 一级证据"),
+ SourceKindLabel: firstNonEmpty(definition.SourceKindLabel, "官方活动页"),
+ PrimarySource: definition.PrimarySource,
+ UpdatedAt: formatEventUpdatedAt("", definition.Date),
+ EvidenceDetail: definition.EvidenceDetail,
+ Baseline: firstNonEmpty(definition.Baseline, "活动窗口开启"),
+ Summary: definition.Summary,
+ Priority: maxInt(definition.Priority, 115),
+ })
+ }
+
+ return events, nil
+}
+
+func loadPromoCampaignDefinitions() ([]PromoCampaignDefinition, error) {
+ path, err := resolvePromoCampaignDataPath()
+ if err != nil {
+ if os.IsNotExist(err) {
+ return nil, nil
+ }
+ return nil, err
+ }
+
+ body, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+
+ var definitions []PromoCampaignDefinition
+ if err := json.Unmarshal(body, &definitions); err != nil {
+ return nil, err
+ }
+ return definitions, nil
+}
+
+func resolvePromoCampaignDataPath() (string, error) {
+ candidates := []string{
+ filepath.Join("scripts", "testdata", "report_promo_campaigns.json"),
+ filepath.Join("testdata", "report_promo_campaigns.json"),
+ }
+ for _, candidate := range candidates {
+ if _, err := os.Stat(candidate); err == nil {
+ return candidate, nil
+ }
+ }
+ return "", os.ErrNotExist
+}
+
func loadOfficialReleaseEvents(db *sql.DB, date string) ([]ModelEvent, error) {
rows, err := db.Query(`
WITH latest_prices AS (
@@ -907,6 +995,7 @@ func loadOfficialReleaseEvents(db *sql.DB, date string) ([]ModelEvent, error) {
ModelName: modelName,
ProviderName: providerName,
OperatorName: operatorName,
+ Audience: "适合需要复查默认选型与路线图判断的团队",
TrustLabel: buildReleaseTrustLabel(model, dateConfidence),
SourceKindLabel: buildReleaseSourceKindLabel(dateSourceKind, dateConfidence),
PrimarySource: sourceURL,
@@ -1020,6 +1109,7 @@ func loadNewModelEvents(db *sql.DB, date string) ([]ModelEvent, error) {
ModelName: modelName,
ProviderName: providerName,
OperatorName: operatorName,
+ Audience: "适合想尽快验证新模型价值的选型读者",
TrustLabel: buildTrustLabel(model),
SourceKindLabel: "模型快照",
PrimarySource: buildPrimarySource("region_pricing", operatorName),
@@ -1132,6 +1222,7 @@ func loadPriceChangeEvents(db *sql.DB, date string) ([]ModelEvent, error) {
ModelName: modelName,
ProviderName: providerName,
OperatorName: operatorName,
+ Audience: buildPriceEventAudience(changePct),
TrustLabel: buildTrustLabel(model),
SourceKindLabel: "价格快照",
PrimarySource: "pricing_history",
@@ -1191,6 +1282,13 @@ func minInt(a, b int) int {
return b
}
+func maxInt(a, b int) int {
+ if a > b {
+ return a
+ }
+ return b
+}
+
func abs(v float64) float64 {
if v < 0 {
return -v
@@ -1198,6 +1296,22 @@ func abs(v float64) float64 {
return v
}
+func buildPriceEventAudience(changePct float64) string {
+ if changePct < 0 {
+ return "适合以成本为先、准备趁降价重排默认选型的团队"
+ }
+ return "适合需要提前准备替代模型和预算回退方案的团队"
+}
+
+func firstNonEmpty(values ...string) string {
+ for _, value := range values {
+ if strings.TrimSpace(value) != "" {
+ return value
+ }
+ }
+ return ""
+}
+
func decorateReportV1(r *ReportV3) {
if r == nil {
return
@@ -1247,6 +1361,7 @@ func enrichModelEvents(r *ReportV3) []ModelEvent {
ModelName: model.Name,
ProviderName: model.ProviderName,
OperatorName: model.OperatorName,
+ Audience: "适合先试后买、但需要先判断免费来源的读者",
TrustLabel: buildTrustLabel(model),
SourceKindLabel: "免费策略快照",
PrimarySource: buildPrimarySource("free_snapshot", model.OperatorName),
@@ -1333,7 +1448,7 @@ func buildPageMode(signals DailySignals) string {
}
func buildPageModeWithEvents(signals DailySignals, events []ModelEvent) string {
- if hasEventType(events, "official_release") {
+ if hasEventType(events, "official_release") || hasEventType(events, "promo_campaign") {
return "hot"
}
if signals.NewModels == 0 && signals.PriceChanges == 0 {
@@ -1358,6 +1473,9 @@ func buildMarketLabels(r *ReportV3) []string {
if hasEventType(r.ModelEvents, "official_release") {
labels = append(labels, "官方发布")
}
+ if hasEventType(r.ModelEvents, "promo_campaign") {
+ labels = append(labels, "营销活动")
+ }
if r.DailySignals.NewModels > 0 {
labels = append(labels, "新模型日")
}
@@ -1389,6 +1507,10 @@ func buildHeroSummary(r *ReportV3) (string, string) {
return fmt.Sprintf("今天最值得关注的是 %s 已出现官方发布信号,优先复查它对默认选型的影响。", official.ModelName),
fmt.Sprintf("主来源:%s", official.PrimarySource)
}
+ if promo := firstEventByType(r.ModelEvents, "promo_campaign"); promo != nil {
+ return fmt.Sprintf("今天最值得关注的是 %s 已进入活动窗口,优先判断这次活动是否值得改变默认成本策略。", promo.ModelName),
+ fmt.Sprintf("主来源:%s", promo.PrimarySource)
+ }
switch r.PageMode {
case "hot":
return fmt.Sprintf(
@@ -1428,6 +1550,7 @@ func buildHeadlineItems(r *ReportV3) []HeadlineItem {
Label: "新模型",
Title: fmt.Sprintf("今日新增 %d 个模型进入情报池", r.DailySignals.NewModels),
Summary: "建议优先复查今天的新上架模型是否改变当前低成本或中文场景的最优解。",
+ Audience: "适合想快速筛出新增机会的读者",
Baseline: "首次出现",
TrustLabel: "数据库追踪",
Tone: "info",
@@ -1438,6 +1561,7 @@ func buildHeadlineItems(r *ReportV3) []HeadlineItem {
Label: "价格变化",
Title: fmt.Sprintf("今日检测到 %d 次价格变化", r.DailySignals.PriceChanges),
Summary: "价格变化已足以影响低成本选型,建议重新确认默认模型与备用模型。",
+ Audience: "适合以成本为先、需要重排默认选型的团队",
Baseline: "较昨日",
TrustLabel: "价格快照",
Tone: "success",
@@ -1449,6 +1573,7 @@ func buildHeadlineItems(r *ReportV3) []HeadlineItem {
Label: "免费策略",
Title: "免费机会主要来自聚合平台,不等于官方长期免费",
Summary: fmt.Sprintf("官方免费 %d 个,聚合免费 %d 个,待确认 %d 个。", r.DailySignals.OfficialFree, r.DailySignals.AggregatorFree, r.DailySignals.UnknownFree),
+ Audience: "适合想先试用、但不想误把聚合免费当官方免费的读者",
Baseline: "今日快照",
TrustLabel: "来源已分层",
Tone: "warning",
@@ -1460,6 +1585,7 @@ func buildHeadlineItems(r *ReportV3) []HeadlineItem {
Label: "观察重点",
Title: "今日无重大上新或显著调价",
Summary: "平静日优先关注稳定商用与长期成本,避免被噪音列表打断判断。",
+ Audience: "适合更重视稳定性和长期成本的团队",
Baseline: "较昨日",
TrustLabel: "日报编辑规则",
Tone: "neutral",
@@ -1477,6 +1603,11 @@ func buildHeadlineItemsFromEvents(events []ModelEvent) []HeadlineItem {
return nil
}
+ limit := 3
+ if hasEventType(events, "official_release") || hasEventType(events, "promo_campaign") {
+ limit = 4
+ }
+
sort.Slice(events, func(i, j int) bool {
if events[i].Priority != events[j].Priority {
return events[i].Priority > events[j].Priority
@@ -1492,7 +1623,7 @@ func buildHeadlineItemsFromEvents(events []ModelEvent) []HeadlineItem {
}
usedModels[event.ModelName] = struct{}{}
items = append(items, headlineItemFromModelEvent(event))
- if len(items) >= 3 {
+ if len(items) >= limit {
break
}
}
@@ -1503,6 +1634,7 @@ func headlineItemFromModelEvent(event ModelEvent) HeadlineItem {
item := HeadlineItem{
Title: event.ModelName,
Summary: event.Summary,
+ Audience: event.Audience,
Baseline: event.Baseline,
TrustLabel: event.TrustLabel,
SourceKindLabel: event.SourceKindLabel,
@@ -1534,6 +1666,10 @@ func headlineItemFromModelEvent(event ModelEvent) HeadlineItem {
item.Label = "价格上调"
item.Title = fmt.Sprintf("%s 成本上调 %.0f%%", event.ModelName, abs(event.PriceChangePct))
item.Tone = "caution"
+ case "promo_campaign":
+ item.Label = "营销活动"
+ item.Title = fmt.Sprintf("%s 进入活动窗口", event.ModelName)
+ item.Tone = "promo"
case "free_highlight":
item.Label = "免费机会"
item.Title = fmt.Sprintf("%s 当前可免费试用", event.ModelName)
@@ -1967,6 +2103,9 @@ func generateMarkdownV3(r *ReportV3, path string) error {
for _, item := range r.HeadlineItems {
fmt.Fprintf(f, "### %s · %s\n\n", item.Label, item.Title)
fmt.Fprintf(f, "- 影响: %s\n", item.Summary)
+ if item.Audience != "" {
+ fmt.Fprintf(f, "- 影响对象: %s\n", item.Audience)
+ }
if item.SourceKindLabel != "" {
fmt.Fprintf(f, "- 事件来源: %s\n", item.SourceKindLabel)
}
@@ -2535,6 +2674,7 @@ th {
{{.Label}}
{{.Title}}
{{.Summary}}
+ {{if .Audience}}影响对象:{{.Audience}}
{{end}}
{{if .SourceKindLabel}}事件来源:{{.SourceKindLabel}}
{{end}}
基线:{{.Baseline}}
可信度:{{.TrustLabel}}
diff --git a/scripts/generate_daily_report_test.go b/scripts/generate_daily_report_test.go
index 7154fc6..f4eaef3 100644
--- a/scripts/generate_daily_report_test.go
+++ b/scripts/generate_daily_report_test.go
@@ -394,6 +394,7 @@ func TestGenerateMarkdownV3IncludesTencentSubscriptionSection(t *testing.T) {
ModelName: "DeepSeek-V4-Flash",
ProviderName: "DeepSeek",
OperatorName: "OpenRouter",
+ Audience: "适合想尽快验证新模型价值的选型读者",
TrustLabel: "聚合来源",
Baseline: "首次出现",
Summary: "新模型进入情报池,值得重新评估低成本编码默认选择。",
@@ -403,6 +404,21 @@ func TestGenerateMarkdownV3IncludesTencentSubscriptionSection(t *testing.T) {
EvidenceDetail: "models.created_at = 今日,且已存在最新价格快照",
Priority: 95,
},
+ {
+ EventType: "promo_campaign",
+ ModelName: "DeepSeek-V3.2-Exp",
+ ProviderName: "DeepSeek",
+ OperatorName: "DeepSeek",
+ Audience: "适合计划趁活动窗口压低推理成本的团队",
+ TrustLabel: "官方来源 / 一级证据",
+ Baseline: "活动窗口开启",
+ Summary: "官方活动窗口出现后,值得重新评估低成本推理和批量调用方案。",
+ SourceKindLabel: "官方活动页",
+ PrimarySource: "https://api-docs.deepseek.com/news/news250929",
+ UpdatedAt: "2025-09-29 00:00",
+ EvidenceDetail: "官方活动页记录 V3.2-Exp 在活动窗口内价格下调 50%+",
+ Priority: 115,
+ },
}
decorateReportV1(report)
@@ -422,6 +438,8 @@ func TestGenerateMarkdownV3IncludesTencentSubscriptionSection(t *testing.T) {
"## 今日变化",
"## 场景推荐",
"## 完整数据附录",
+ "- 影响对象:",
+ "营销活动",
"主来源: OpenRouter / region_pricing",
"更新时间: 2026-05-13 09:30",
"判定依据: models.created_at = 今日,且已存在最新价格快照",
@@ -475,6 +493,7 @@ func TestGenerateHTMLV3IncludesTencentSubscriptionSection(t *testing.T) {
ModelName: "DeepSeek-V4-Flash",
ProviderName: "DeepSeek",
OperatorName: "OpenRouter",
+ Audience: "适合想尽快验证新模型价值的选型读者",
TrustLabel: "聚合来源",
Baseline: "首次出现",
Summary: "新模型进入情报池,值得重新评估低成本编码默认选择。",
@@ -484,6 +503,21 @@ func TestGenerateHTMLV3IncludesTencentSubscriptionSection(t *testing.T) {
EvidenceDetail: "models.created_at = 今日,且已存在最新价格快照",
Priority: 95,
},
+ {
+ EventType: "promo_campaign",
+ ModelName: "DeepSeek-V3.2-Exp",
+ ProviderName: "DeepSeek",
+ OperatorName: "DeepSeek",
+ Audience: "适合计划趁活动窗口压低推理成本的团队",
+ TrustLabel: "官方来源 / 一级证据",
+ Baseline: "活动窗口开启",
+ Summary: "官方活动窗口出现后,值得重新评估低成本推理和批量调用方案。",
+ SourceKindLabel: "官方活动页",
+ PrimarySource: "https://api-docs.deepseek.com/news/news250929",
+ UpdatedAt: "2025-09-29 00:00",
+ EvidenceDetail: "官方活动页记录 V3.2-Exp 在活动窗口内价格下调 50%+",
+ Priority: 115,
+ },
}
report.TencentSubscriptionPlans = []SubscriptionPlanInfo{
{
@@ -517,6 +551,8 @@ func TestGenerateHTMLV3IncludesTencentSubscriptionSection(t *testing.T) {
"DeepSeek-V4-Flash",
"一级官方发布",
"二级权威佐证",
+ "营销活动",
+ "影响对象",
"首次出现",
"主来源",
"更新时间",
@@ -662,6 +698,7 @@ func TestHeadlineItemFromModelEventIncludesEvidenceFields(t *testing.T) {
TrustLabel: "聚合来源",
Baseline: "首次出现",
Summary: "新模型进入情报池。",
+ Audience: "适合想尽快验证新模型价值的读者",
SourceKindLabel: "模型快照",
PrimarySource: "OpenRouter / region_pricing",
UpdatedAt: "2026-05-13 09:30",
@@ -680,6 +717,9 @@ func TestHeadlineItemFromModelEventIncludesEvidenceFields(t *testing.T) {
if item.EvidenceDetail == "" {
t.Fatalf("expected evidence detail to be populated, got %+v", item)
}
+ if item.Audience != "适合想尽快验证新模型价值的读者" {
+ t.Fatalf("expected audience to be propagated, got %+v", item)
+ }
}
func TestHeadlineItemFromOfficialReleaseEvent(t *testing.T) {
@@ -735,3 +775,31 @@ func TestHeadlineItemFromSecondaryReleaseEvent(t *testing.T) {
t.Fatalf("expected tone to be secondary-evidence, got %+v", item)
}
}
+
+func TestHeadlineItemFromPromoCampaignEvent(t *testing.T) {
+ item := headlineItemFromModelEvent(ModelEvent{
+ EventType: "promo_campaign",
+ ModelName: "DeepSeek-V3.2-Exp",
+ TrustLabel: "官方来源 / 一级证据",
+ Baseline: "活动窗口开启",
+ Summary: "官方活动窗口出现后,值得重新评估低成本推理方案。",
+ Audience: "适合计划趁活动窗口压低推理成本的团队",
+ SourceKindLabel: "官方活动页",
+ PrimarySource: "https://api-docs.deepseek.com/news/news250929",
+ UpdatedAt: "2025-09-29 00:00",
+ EvidenceDetail: "官方活动页记录 V3.2-Exp 在活动窗口内价格下调 50%+",
+ })
+
+ if item.Label != "营销活动" {
+ t.Fatalf("expected label to be 营销活动, got %+v", item)
+ }
+ if !strings.Contains(item.Title, "活动窗口") {
+ t.Fatalf("expected title to mention 活动窗口, got %+v", item)
+ }
+ if item.Tone != "promo" {
+ t.Fatalf("expected tone to be promo, got %+v", item)
+ }
+ if item.Audience != "适合计划趁活动窗口压低推理成本的团队" {
+ t.Fatalf("expected audience to be preserved, got %+v", item)
+ }
+}
diff --git a/scripts/report_event_coverage.go b/scripts/report_event_coverage.go
new file mode 100644
index 0000000..194f522
--- /dev/null
+++ b/scripts/report_event_coverage.go
@@ -0,0 +1,295 @@
+//go:build llm_script
+
+package main
+
+import (
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "os"
+ "path/filepath"
+ "sort"
+ "strings"
+ "time"
+
+ _ "github.com/lib/pq"
+)
+
+type CoverageDay struct {
+ Date string
+ OfficialRelease bool
+ PromoCampaign bool
+ NewModel bool
+ PriceChange bool
+}
+
+func (d CoverageDay) HasTrueEvent() bool {
+ return d.OfficialRelease || d.PromoCampaign || d.NewModel || d.PriceChange
+}
+
+func (d CoverageDay) EventLabels() []string {
+ var labels []string
+ if d.OfficialRelease {
+ labels = append(labels, "official_release")
+ }
+ if d.PromoCampaign {
+ labels = append(labels, "promo_campaign")
+ }
+ if d.NewModel {
+ labels = append(labels, "new_model")
+ }
+ if d.PriceChange {
+ labels = append(labels, "price_change")
+ }
+ return labels
+}
+
+type CoverageSummary struct {
+ TotalDays int
+ CoveredDays int
+ CoveragePct float64
+}
+
+type CoveragePromoDefinition struct {
+ Date string `json:"date"`
+}
+
+func main() {
+ if len(os.Args) != 3 {
+ fmt.Fprintln(os.Stderr, "用法: go run -tags llm_script scripts/report_event_coverage.go YYYY-MM-DD YYYY-MM-DD")
+ os.Exit(2)
+ }
+
+ startDate, endDate, err := parseCoverageArgs(os.Args[1], os.Args[2])
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "参数错误:", err)
+ os.Exit(2)
+ }
+
+ dsn := os.Getenv("DATABASE_URL")
+ if dsn == "" {
+ dsn = "postgres://long@/llm_intelligence?host=/var/run/postgresql"
+ }
+
+ db, err := sql.Open("postgres", dsn)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "连接数据库失败:", err)
+ os.Exit(1)
+ }
+ defer db.Close()
+
+ days, err := collectCoverageDays(db, startDate, endDate)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, "统计覆盖率失败:", err)
+ os.Exit(1)
+ }
+ if len(days) == 0 {
+ fmt.Fprintln(os.Stderr, "指定范围内没有已生成的日报记录")
+ os.Exit(1)
+ }
+
+ summary := summarizeCoverage(days)
+ fmt.Printf("V2 事件覆盖率报告 [%s, %s]\n", startDate, endDate)
+ for _, day := range days {
+ status := "MISS"
+ if day.HasTrueEvent() {
+ status = "PASS"
+ }
+ fmt.Printf("%s\t%s\t%s\n", day.Date, status, strings.Join(day.EventLabels(), ", "))
+ }
+ fmt.Printf("\n总日报天数: %d\n", summary.TotalDays)
+ fmt.Printf("命中真实变化事件天数: %d\n", summary.CoveredDays)
+ fmt.Printf("覆盖率: %.2f%%\n", summary.CoveragePct)
+
+ if summary.CoveragePct < 80 {
+ os.Exit(1)
+ }
+}
+
+func parseCoverageArgs(start, end string) (string, string, error) {
+ startDate, err := time.Parse("2006-01-02", start)
+ if err != nil {
+ return "", "", fmt.Errorf("开始日期格式不正确")
+ }
+ endDate, err := time.Parse("2006-01-02", end)
+ if err != nil {
+ return "", "", fmt.Errorf("结束日期格式不正确")
+ }
+ if endDate.Before(startDate) {
+ return "", "", fmt.Errorf("结束日期不能早于开始日期")
+ }
+ return startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), nil
+}
+
+func collectCoverageDays(db *sql.DB, startDate, endDate string) ([]CoverageDay, error) {
+ reportDates, err := loadGeneratedReportDates(db, startDate, endDate)
+ if err != nil {
+ return nil, err
+ }
+ promoDates, err := loadCoveragePromoDates()
+ if err != nil {
+ return nil, err
+ }
+
+ var days []CoverageDay
+ for _, date := range reportDates {
+ officialRelease, err := hasOfficialRelease(db, date)
+ if err != nil {
+ return nil, err
+ }
+ newModel, err := hasNewModel(db, date)
+ if err != nil {
+ return nil, err
+ }
+ priceChange, err := hasPriceChange(db, date)
+ if err != nil {
+ return nil, err
+ }
+ days = append(days, CoverageDay{
+ Date: date,
+ OfficialRelease: officialRelease,
+ PromoCampaign: promoDates[date],
+ NewModel: newModel,
+ PriceChange: priceChange,
+ })
+ }
+
+ sort.Slice(days, func(i, j int) bool {
+ return days[i].Date < days[j].Date
+ })
+ return days, nil
+}
+
+func loadGeneratedReportDates(db *sql.DB, startDate, endDate string) ([]string, error) {
+ rows, err := db.Query(`
+ SELECT DISTINCT report_date::text
+ FROM daily_report
+ WHERE report_date BETWEEN $1::date AND $2::date
+ AND status = 'generated'
+ ORDER BY report_date
+ `, startDate, endDate)
+ if err != nil {
+ return nil, err
+ }
+ defer rows.Close()
+
+ var dates []string
+ for rows.Next() {
+ var date string
+ if err := rows.Scan(&date); err != nil {
+ return nil, err
+ }
+ dates = append(dates, date)
+ }
+ return dates, rows.Err()
+}
+
+func hasOfficialRelease(db *sql.DB, date string) (bool, error) {
+ var exists bool
+ err := db.QueryRow(`
+ WITH latest_prices AS (
+ SELECT
+ rp.model_id,
+ 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 EXISTS (
+ SELECT 1
+ FROM models m
+ LEFT JOIN latest_prices lp ON lp.model_id = m.id AND lp.rn = 1
+ WHERE m.deleted_at IS NULL
+ AND m.release_date = $1::date
+ AND COALESCE(m.source_url, '') <> ''
+ AND COALESCE(lp.operator_type, 'reseller') IN ('official', 'cloud')
+ )
+ `, date).Scan(&exists)
+ return exists, err
+}
+
+func hasNewModel(db *sql.DB, date string) (bool, error) {
+ var exists bool
+ err := db.QueryRow(`
+ SELECT EXISTS (
+ SELECT 1
+ FROM models
+ WHERE deleted_at IS NULL
+ AND DATE(created_at) = $1::date
+ )
+ `, date).Scan(&exists)
+ return exists, err
+}
+
+func hasPriceChange(db *sql.DB, date string) (bool, error) {
+ var exists bool
+ err := db.QueryRow(`
+ SELECT EXISTS (
+ SELECT 1
+ FROM pricing_history
+ WHERE DATE(changed_at) = $1::date
+ AND (
+ COALESCE(old_input_price, 0) <> COALESCE(new_input_price, 0)
+ OR COALESCE(old_output_price, 0) <> COALESCE(new_output_price, 0)
+ )
+ )
+ `, date).Scan(&exists)
+ return exists, err
+}
+
+func loadCoveragePromoDates() (map[string]bool, error) {
+ path, err := resolveCoveragePromoDataPath()
+ if err != nil {
+ if os.IsNotExist(err) {
+ return map[string]bool{}, nil
+ }
+ return nil, err
+ }
+
+ body, err := os.ReadFile(path)
+ if err != nil {
+ return nil, err
+ }
+
+ var definitions []CoveragePromoDefinition
+ if err := json.Unmarshal(body, &definitions); err != nil {
+ return nil, err
+ }
+
+ dates := make(map[string]bool, len(definitions))
+ for _, definition := range definitions {
+ if strings.TrimSpace(definition.Date) != "" {
+ dates[definition.Date] = true
+ }
+ }
+ return dates, nil
+}
+
+func resolveCoveragePromoDataPath() (string, error) {
+ candidates := []string{
+ filepath.Join("scripts", "testdata", "report_promo_campaigns.json"),
+ filepath.Join("testdata", "report_promo_campaigns.json"),
+ }
+ for _, candidate := range candidates {
+ if _, err := os.Stat(candidate); err == nil {
+ return candidate, nil
+ }
+ }
+ return "", os.ErrNotExist
+}
+
+func summarizeCoverage(days []CoverageDay) CoverageSummary {
+ summary := CoverageSummary{TotalDays: len(days)}
+ for _, day := range days {
+ if day.HasTrueEvent() {
+ summary.CoveredDays++
+ }
+ }
+ if summary.TotalDays > 0 {
+ summary.CoveragePct = float64(summary.CoveredDays) * 100 / float64(summary.TotalDays)
+ }
+ return summary
+}
diff --git a/scripts/report_event_coverage_test.go b/scripts/report_event_coverage_test.go
new file mode 100644
index 0000000..6c987cd
--- /dev/null
+++ b/scripts/report_event_coverage_test.go
@@ -0,0 +1,29 @@
+//go:build llm_script
+
+package main
+
+import "testing"
+
+func TestParseCoverageArgsRejectsReverseRange(t *testing.T) {
+ if _, _, err := parseCoverageArgs("2026-05-14", "2024-06-01"); err == nil {
+ t.Fatalf("expected reverse range to fail")
+ }
+}
+
+func TestSummarizeCoverageCalculatesPercent(t *testing.T) {
+ summary := summarizeCoverage([]CoverageDay{
+ {Date: "2024-06-05", OfficialRelease: true},
+ {Date: "2024-10-25", PromoCampaign: true},
+ {Date: "2026-05-14"},
+ })
+
+ if summary.TotalDays != 3 {
+ t.Fatalf("total days = %d, want 3", summary.TotalDays)
+ }
+ if summary.CoveredDays != 2 {
+ t.Fatalf("covered days = %d, want 2", summary.CoveredDays)
+ }
+ if summary.CoveragePct < 66.6 || summary.CoveragePct > 66.7 {
+ t.Fatalf("coverage pct = %.2f, want about 66.67", summary.CoveragePct)
+ }
+}
diff --git a/scripts/testdata/report_promo_campaigns.json b/scripts/testdata/report_promo_campaigns.json
new file mode 100644
index 0000000..460f269
--- /dev/null
+++ b/scripts/testdata/report_promo_campaigns.json
@@ -0,0 +1,16 @@
+[
+ {
+ "date": "2025-09-29",
+ "model_name": "DeepSeek-V3.2-Exp",
+ "provider_name": "DeepSeek",
+ "operator_name": "DeepSeek",
+ "summary": "官方活动窗口出现后,值得重新评估低成本推理和批量调用方案。",
+ "audience": "适合计划趁活动窗口压低推理成本的团队",
+ "baseline": "活动窗口开启",
+ "trust_label": "官方来源 / 一级证据",
+ "source_kind_label": "官方活动页",
+ "primary_source": "https://api-docs.deepseek.com/news/news250929",
+ "evidence_detail": "官方活动页记录 V3.2-Exp 在活动窗口内价格下调 50%+",
+ "priority": 115
+ }
+]
diff --git a/scripts/verify_v2_event_coverage.sh b/scripts/verify_v2_event_coverage.sh
new file mode 100755
index 0000000..abc4969
--- /dev/null
+++ b/scripts/verify_v2_event_coverage.sh
@@ -0,0 +1,13 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+if [[ $# -ne 2 ]]; then
+ echo "用法: scripts/verify_v2_event_coverage.sh YYYY-MM-DD YYYY-MM-DD" >&2
+ exit 2
+fi
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
+
+cd "$REPO_ROOT"
+go run -tags llm_script scripts/report_event_coverage.go "$1" "$2"