From 85f37a4d9511ca9a58d29e0a4e0cf4f36e2b8425 Mon Sep 17 00:00:00 2001 From: phamnazage-jpg Date: Wed, 13 May 2026 20:13:02 +0800 Subject: [PATCH] feat(report): ship daily report v1 experience --- cmd/server/main.go | 156 +- cmd/server/main_test.go | 86 + .../2026-05-13-daily-report-ui-design.md | 992 ++++++++++ ...-13-daily-report-v1-implementation-plan.md | 296 +++ frontend/src/App.css | 160 ++ frontend/src/pages/Dashboard.tsx | 123 ++ scripts/generate_daily_report.go | 1725 ++++++++++++----- scripts/generate_daily_report_test.go | 307 ++- scripts/report_utils.sh | 120 ++ scripts/run_daily.sh | 59 +- scripts/run_real_pipeline.sh | 53 +- scripts/verify_phase3.sh | 17 +- scripts/verify_phase5.sh | 12 +- 13 files changed, 3541 insertions(+), 565 deletions(-) create mode 100644 docs/plans/2026-05-13-daily-report-ui-design.md create mode 100644 docs/plans/2026-05-13-daily-report-v1-implementation-plan.md create mode 100644 scripts/report_utils.sh diff --git a/cmd/server/main.go b/cmd/server/main.go index 298b546..8e2db2e 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -4,9 +4,12 @@ import ( "context" "database/sql" "encoding/json" + "fmt" "log" "net/http" "os" + "path/filepath" + "strings" "time" _ "github.com/lib/pq" @@ -24,7 +27,7 @@ type modelResponse struct { Currency string `json:"currency"` IsFree bool `json:"isFree"` Stale bool `json:"stale"` - DataConfidence string `json:"dataConfidence"` + DataConfidence string `json:"dataConfidence"` } type subscriptionPlanResponse struct { @@ -54,6 +57,21 @@ type apiEnvelope struct { type modelFetcher func(context.Context, *sql.DB) ([]modelResponse, error) type subscriptionPlanFetcher func(context.Context, *sql.DB) ([]subscriptionPlanResponse, error) +type latestReportFetcher func(context.Context, *sql.DB) (*latestReportResponse, error) + +type latestReportResponse struct { + ReportDate string `json:"reportDate"` + Status string `json:"status"` + ModelCount int `json:"modelCount"` + SummaryMD string `json:"summaryMD"` + MarkdownPath string `json:"markdownPath"` + HTMLPath string `json:"htmlPath"` + ArchiveMarkdownPath string `json:"archiveMarkdownPath"` + ArchiveHTMLPath string `json:"archiveHtmlPath"` + MarkdownURL string `json:"markdownUrl"` + HTMLURL string `json:"htmlUrl"` + UpdatedAt string `json:"updatedAt"` +} func main() { addr := os.Getenv("PORT") @@ -75,7 +93,7 @@ func main() { } } - mux := newMux(db, fetchModels, fetchSubscriptionPlans) + mux := newMux(db, fetchModels, fetchSubscriptionPlans, fetchLatestReport) log.Printf("server listening on :%s", addr) if err := http.ListenAndServe(":"+addr, mux); err != nil { @@ -83,7 +101,7 @@ func main() { } } -func newMux(db *sql.DB, fetchModelsFn modelFetcher, fetchPlansFn subscriptionPlanFetcher) *http.ServeMux { +func newMux(db *sql.DB, fetchModelsFn modelFetcher, fetchPlansFn subscriptionPlanFetcher, fetchLatestReportFn latestReportFetcher) *http.ServeMux { mux := http.NewServeMux() mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { if db == nil { @@ -122,6 +140,29 @@ func newMux(db *sql.DB, fetchModelsFn modelFetcher, fetchPlansFn subscriptionPla } writeJSON(w, http.StatusOK, apiEnvelope{Data: plans}) }) + mux.HandleFunc("/api/v1/reports/latest/html", func(w http.ResponseWriter, r *http.Request) { + serveLatestReportArtifact(w, r, db, fetchLatestReportFn, "html") + }) + mux.HandleFunc("/api/v1/reports/latest/markdown", func(w http.ResponseWriter, r *http.Request) { + serveLatestReportArtifact(w, r, db, fetchLatestReportFn, "markdown") + }) + mux.HandleFunc("/api/v1/reports/latest", func(w http.ResponseWriter, r *http.Request) { + if db == nil { + http.Error(w, "database not configured", http.StatusServiceUnavailable) + return + } + report, err := fetchLatestReportFn(r.Context(), db) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "latest report not found", http.StatusNotFound) + return + } + http.Error(w, "query failed", http.StatusInternalServerError) + log.Printf("fetch latest report failed: %v", err) + return + } + writeJSON(w, http.StatusOK, apiEnvelope{Data: report}) + }) return mux } @@ -129,15 +170,16 @@ func fetchModels(ctx context.Context, db *sql.DB) ([]modelResponse, error) { rows, err := db.QueryContext(ctx, ` WITH latest_prices AS ( SELECT - model_id, - input_price_per_mtok, - output_price_per_mtok, - currency, + rp.model_id, + rp.input_price_per_mtok, + rp.output_price_per_mtok, + rp.currency, + rp.is_free, ROW_NUMBER() OVER ( - PARTITION BY model_id - ORDER BY effective_date DESC NULLS LAST, id DESC + PARTITION BY rp.model_id + ORDER BY rp.effective_date DESC NULLS LAST, rp.id DESC ) AS rn - FROM model_prices + FROM region_pricing rp ) SELECT m.external_id, @@ -149,7 +191,7 @@ func fetchModels(ctx context.Context, db *sql.DB) ([]modelResponse, error) { lp.input_price_per_mtok, lp.output_price_per_mtok, COALESCE(lp.currency, 'USD'), - COALESCE(m.is_free, false), + COALESCE(lp.is_free, m.is_free, false), COALESCE(m.data_confidence, 'official') FROM models m LEFT JOIN model_provider mp ON mp.id = m.provider_id @@ -196,6 +238,98 @@ func fetchModels(ctx context.Context, db *sql.DB) ([]modelResponse, error) { return models, rows.Err() } +func fetchLatestReport(ctx context.Context, db *sql.DB) (*latestReportResponse, error) { + var report latestReportResponse + var markdownPath string + + err := db.QueryRowContext(ctx, ` + SELECT + TO_CHAR(report_date, 'YYYY-MM-DD'), + status, + COALESCE(model_count, 0), + COALESCE(summary_md, ''), + COALESCE(output_path, ''), + COALESCE(TO_CHAR(updated_at, 'YYYY-MM-DD"T"HH24:MI:SS'), '') + FROM daily_report + WHERE output_path IS NOT NULL + AND output_path <> '' + ORDER BY report_date DESC, updated_at DESC + LIMIT 1 + `).Scan( + &report.ReportDate, + &report.Status, + &report.ModelCount, + &report.SummaryMD, + &markdownPath, + &report.UpdatedAt, + ) + if err != nil { + return nil, err + } + + report.MarkdownPath = filepath.ToSlash(markdownPath) + report.HTMLPath = deriveReportHTMLPath(markdownPath, report.ReportDate) + report.ArchiveMarkdownPath = deriveReportArchivePath(markdownPath, report.ReportDate) + report.ArchiveHTMLPath = deriveReportArchivePath(report.HTMLPath, report.ReportDate) + report.MarkdownURL = "/api/v1/reports/latest/markdown" + report.HTMLURL = "/api/v1/reports/latest/html" + return &report, nil +} + +func serveLatestReportArtifact(w http.ResponseWriter, r *http.Request, db *sql.DB, fetchLatestReportFn latestReportFetcher, artifactType string) { + if db == nil { + http.Error(w, "database not configured", http.StatusServiceUnavailable) + return + } + + report, err := fetchLatestReportFn(r.Context(), db) + if err != nil { + if err == sql.ErrNoRows { + http.Error(w, "latest report not found", http.StatusNotFound) + return + } + http.Error(w, "query failed", http.StatusInternalServerError) + log.Printf("fetch latest report failed: %v", err) + return + } + + targetPath := report.MarkdownPath + if artifactType == "html" { + targetPath = report.HTMLPath + w.Header().Set("Content-Type", "text/html; charset=utf-8") + } else { + w.Header().Set("Content-Type", "text/markdown; charset=utf-8") + } + + if _, err := os.Stat(targetPath); err != nil { + http.Error(w, "report artifact not found", http.StatusNotFound) + return + } + + http.ServeFile(w, r, targetPath) +} + +func deriveReportHTMLPath(markdownPath, reportDate string) string { + reportFile := filepath.Base(markdownPath) + if reportFile == "." || reportFile == "" { + reportFile = fmt.Sprintf("daily_report_%s.md", reportDate) + } + htmlFile := strings.TrimSuffix(reportFile, filepath.Ext(reportFile)) + ".html" + reportDir := filepath.Dir(markdownPath) + if reportDir == "." || reportDir == "" { + reportDir = "reports/daily" + } + return filepath.ToSlash(filepath.Join(reportDir, "html", htmlFile)) +} + +func deriveReportArchivePath(reportPath, reportDate string) string { + reportFile := filepath.Base(reportPath) + if reportFile == "." || reportFile == "" { + reportFile = fmt.Sprintf("daily_report_%s.md", reportDate) + } + return filepath.ToSlash(filepath.Join("reports/daily", reportDate[:4], reportDate[5:7], reportFile)) +} + func fetchSubscriptionPlans(ctx context.Context, db *sql.DB) ([]subscriptionPlanResponse, error) { rows, err := db.QueryContext(ctx, ` SELECT diff --git a/cmd/server/main_test.go b/cmd/server/main_test.go index dba91bb..49d30fb 100644 --- a/cmd/server/main_test.go +++ b/cmd/server/main_test.go @@ -6,6 +6,7 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "os" "testing" ) @@ -38,6 +39,9 @@ func TestSubscriptionPlansHandlerReturnsEnvelope(t *testing.T) { }, }, nil }, + func(context.Context, *sql.DB) (*latestReportResponse, error) { + return nil, sql.ErrNoRows + }, ) req := httptest.NewRequest(http.MethodGet, "/api/v1/subscription-plans", nil) @@ -76,3 +80,85 @@ func TestSubscriptionPlansHandlerReturnsEnvelope(t *testing.T) { t.Fatalf("unexpected model scope length: %d", len(got.ModelScope)) } } + +func TestLatestReportHandlerReturnsEnvelope(t *testing.T) { + mux := newMux( + &sql.DB{}, + func(context.Context, *sql.DB) ([]modelResponse, error) { + return nil, nil + }, + func(context.Context, *sql.DB) ([]subscriptionPlanResponse, error) { + return nil, nil + }, + func(context.Context, *sql.DB) (*latestReportResponse, error) { + return &latestReportResponse{ + ReportDate: "2026-05-13", + Status: "generated", + ModelCount: 504, + MarkdownPath: "reports/daily/daily_report_2026-05-13.md", + HTMLPath: "reports/daily/html/daily_report_2026-05-13.html", + MarkdownURL: "/api/v1/reports/latest/markdown", + HTMLURL: "/api/v1/reports/latest/html", + }, nil + }, + ) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/reports/latest", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + + var payload struct { + Data latestReportResponse `json:"data"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + + if payload.Data.ReportDate != "2026-05-13" { + t.Fatalf("unexpected report date: %q", payload.Data.ReportDate) + } + if payload.Data.HTMLURL != "/api/v1/reports/latest/html" { + t.Fatalf("unexpected html url: %q", payload.Data.HTMLURL) + } +} + +func TestLatestReportHTMLHandlerServesArtifact(t *testing.T) { + tempDir := t.TempDir() + htmlPath := tempDir + "/daily_report_2026-05-13.html" + if err := os.WriteFile(htmlPath, []byte("ok"), 0644); err != nil { + t.Fatalf("write temp html: %v", err) + } + + mux := newMux( + &sql.DB{}, + func(context.Context, *sql.DB) ([]modelResponse, error) { + return nil, nil + }, + func(context.Context, *sql.DB) ([]subscriptionPlanResponse, error) { + return nil, nil + }, + func(context.Context, *sql.DB) (*latestReportResponse, error) { + return &latestReportResponse{ + ReportDate: "2026-05-13", + Status: "generated", + MarkdownPath: tempDir + "/daily_report_2026-05-13.md", + HTMLPath: htmlPath, + }, nil + }, + ) + + req := httptest.NewRequest(http.MethodGet, "/api/v1/reports/latest/html", nil) + rec := httptest.NewRecorder() + mux.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", rec.Code) + } + if body := rec.Body.String(); body != "ok" { + t.Fatalf("unexpected body: %q", body) + } +} diff --git a/docs/plans/2026-05-13-daily-report-ui-design.md b/docs/plans/2026-05-13-daily-report-ui-design.md new file mode 100644 index 0000000..f112ff8 --- /dev/null +++ b/docs/plans/2026-05-13-daily-report-ui-design.md @@ -0,0 +1,992 @@ +# 日报重构 UI 设计说明书 + +> 项目:LLM Intelligence Hub +> 日期:2026-05-13 +> 状态:设计基线 +> 适用范围:HTML 日报、前端 Dashboard 日报摘要入口、后续移动端 / Web 端统一视觉规范 + +## 1. 背景 + +当前日报已经能输出真实数据,但整体阅读体验更接近“数据库导出结果页”,而不是“每天值得打开的 AI 模型与价格情报产品”。 + +现状问题: + +- 首屏缺少“今日发生了什么”的判断,读者看完仍不知道重点。 +- 免费模型、推荐模型、分类概览之间存在重复,信息层级不清。 +- “免费”语义混淆,官方免费、聚合免费、活动免费没有视觉区分。 +- 移动端沿用了桌面表格逻辑,不适合快速浏览。 +- 页面缺少品牌气质,无法体现“高端移动资讯产品 + 专业选型工具”的特点。 + +本次 UI 重构不推翻底层数据库,不改动项目对模型、价格、平台、来源的原始采集事实;只在日报内容组织和页面呈现层重构“让用户愿意看、看得懂、看完能行动”的产品体验。 + +## 2. 设计目标 + +### 2.1 产品目标 + +新日报要同时服务两类用户: + +- 选型决策者:今天该试什么模型,哪个更划算,哪些来源更稳。 +- 行业情报读者:今天市场发生了什么变化,哪些发布、活动、价格战值得关注。 + +默认优先级:**两者兼顾,但优先选型**。 + +### 2.2 体验目标 + +新日报必须满足以下体验要求: + +- 用户在 30 秒内理解“今天最重要的变化”。 +- 用户在 1 分钟内得到“今天该优先关注谁”的结论。 +- 用户能一眼分辨“官方免费 / 聚合免费 / 活动免费 / 来源待验证”。 +- 移动端首屏不依赖长表格,也不依赖大段说明文字。 +- 页面视觉上要像“高端移动资讯产品”,而不是管理后台。 + +### 2.3 不做什么 + +- 不把首页继续做成全量模型清单。 +- 不在主阅读区堆长段分析文字。 +- 不把灰区或待验证来源直接混入主推荐。 +- 不为了炫技加入噪音型动画或低质装饰。 + +## 3. 内容策略 + +### 3.1 基本原则 + +日报采用以下原则: + +- 先结论,后证据。 +- 先变化,后存量。 +- 先行动建议,后完整数据。 +- 主区讲重点,附录讲完整。 + +### 3.2 主体结构 + +日报正文固定拆成四层: + +1. 今日结论区 +2. 今日变化区 +3. 今日选型推荐区 +4. 附录区 + +其中: + +- `今日结论区` 负责抓眼球和快速定调。 +- `今日变化区` 负责解释“为什么今天值得看”。 +- `今日选型推荐区` 负责直接输出可执行建议。 +- `附录区` 负责保留数据库完整性与查阅深度。 + +### 3.3 事件类型 + +日报重点事件按以下类型组织: + +- `new_model` +- `official_release` +- `price_cut` +- `price_increase` +- `free_policy_change` +- `promo_campaign` +- `source_risk_change` + +同一模型可以有多个事件标签,但在首页只选择一个主标签展示。 + +### 3.3.1 变化基线规则 + +为保证“今日变化”具备稳定解释力,所有变化事件必须绑定统一的比较基线,禁止只展示“变了”而不说明“相对什么变了”。 + +允许使用的变化基线: + +- `较昨日` +- `较上次有效价格` +- `7日内新低 / 新高` +- `首次出现` +- `官方首次发布` + +规则要求: + +- 所有 `price_cut` / `price_increase` 必须显示比较基线。 +- 所有 `new_model` 必须显示“首次出现”或“官方首次发布”。 +- 所有 `free_policy_change` 必须显示“由付费转免费”或“由免费转付费”的方向信息。 +- UI 上必须保留一个稳定的基线展示位,不允许只靠颜色表达变化。 + +推荐展示格式: + +- `较昨日 -18%` +- `较上次调价下降 ¥0.20/M` +- `7日内最低价` +- `首次出现在可信来源` + +### 3.4 免费模型与来源可信度 + +日报层必须在原始数据上额外生成语义标签: + +- `official_free` +- `aggregator_free` +- `promo_free` +- `trial_credit` +- `unknown_free` + +来源可信度分级: + +- `official_verified` +- `cloud_verified` +- `aggregator_verified` +- `self_hosted_gateway` +- `unverified_relay` + +规则要求: + +- 所有“免费”展示必须带来源类型徽标。 +- 主推荐区默认只允许 `official_verified`、`cloud_verified`、`aggregator_verified`。 +- `self_hosted_gateway` 与 `unverified_relay` 只进入观察区或风险提醒区,不进入主推荐。 + +### 3.4.1 来源证据露出规则 + +来源可信度标签不能只做视觉标识,必须给用户可追溯的证据入口。 + +对以下卡片强制增加来源证据位: + +- 头条事件卡 +- 推荐模型卡 +- 免费模型卡 +- 风险提示卡 + +每张卡片至少要能露出: + +- 主来源名称 +- 更新时间 +- 来源链接入口 + +可选露出: + +- 判定说明 +- 采集时间 +- 次级证据来源 + +交互建议: + +- 移动端默认展示“来源名称 + 更新时间”,点击展开二级抽屉或详情层查看完整来源信息。 +- Web 端可使用 tooltip、侧边详情层或内联折叠区展示判定说明。 + +目标: + +- 用户看到“官方来源”“聚合来源”“来源待验证”时,能够进一步确认其依据,而不是只看到一个不可解释的标签。 + +## 4. 视觉方向 + +### 4.1 总体方向 + +本项目 UI 气质定义为: + +**高端移动资讯产品 + 专业决策工具** + +不是传统 BI Dashboard,也不是普通资讯流,而是“AI 情报晨报 + 交易决策界面”的混合体。 + +### 4.2 风格关键词 + +- 高端资讯感 +- 科技商业感 +- 情报头条感 +- 专业但不冰冷 +- 可快速扫读 +- 强标签系统 + +### 4.3 视觉原则 + +- 浅底,不以深色为默认主题。 +- 重点靠信息层级抓眼球,不靠大图堆叠。 +- 大字号结论、大标签、少字说明。 +- 颜色必须承担信息语义,而不是纯装饰。 +- 卡片感明确,但避免“廉价卡片流”。 + +## 5. 视觉系统规范 + +### 5.1 颜色系统 + +主色建议: + +- 墨蓝:核心情报、正式发布、品牌主色 +- 祖母绿:降价、利好、值得试 +- 琥珀橙:活动、促销、观察项 +- 朱砂红:来源风险、灰区提醒、负面变化 +- 雾灰:背景、辅助信息、弱层级 + +颜色语义要求: + +- 用户看到颜色即可大致判断事件性质。 +- 同一类标签在所有模块中颜色必须一致。 + +### 5.2 字体系统 + +文字不能小,移动端优先规则如下: + +- 一句话结论:22px-26px +- 头条标题:18px-20px +- 卡片标题:18px +- 正文短句:15px-16px +- 标签:12px-13px +- 主阅读区正文禁止低于 14px + +设计要求: + +- 标题字要有媒体感和辨识度。 +- 正文字必须优先可读性。 +- 不使用普通后台式默认字体堆满页面。 + +### 5.3 标签系统 + +标签是核心视觉语言之一,必须标准化。 + +建议标签: + +- 官方发布 +- 聚合免费 +- 官方免费 +- 限时活动 +- 价格下调 +- 来源待验证 +- 适合编码 +- 适合 Agent +- 官方来源 +- 聚合来源 + +约束: + +- 每张卡最多展示 3 个标签。 +- 标签必须足够粗、足够醒目,但不应压过主标题。 + +### 5.4 图标系统 + +采用简洁功能型图标,不做插画主导。 + +建议图标语义: + +- 火焰:热点 +- 向下箭头:降价 +- 礼盒 / 闪电:活动 +- 盾牌:可信来源 +- 感叹号:风险提醒 + +### 5.5 动效规范 + +动效应提升高级感,不制造噪音。 + +建议: + +- 首屏卡片分层渐入 +- 标签轻微浮现 +- 卡片 hover / tap 有短促反馈 +- 折叠区展开用轻量过渡 + +禁止: + +- 大范围抖动 +- 夸张发光 +- 低质漂浮动效 +- 干扰阅读的连续动画 + +## 6. 移动端首页结构 + +移动端首页按“先结论,后机会,再证据”组织,共六个区块。 + +### 6.1 顶部情报头 + +内容: + +- 日期 +- 数据更新时间 +- 今日市场状态标签 +- 一句短副标题 + +目标: + +- 让用户第一眼知道这是一份“今日 AI 情报晨报”。 + +### 6.2 一句话结论卡 + +规则: + +- 只保留 1 条主结论 +- 控制在 28-40 个中文字符 +- 最多 2-3 行 +- 必须在手机首屏内出现 + +作用: + +- 让用户一眼知道“今天真正重要的变化是什么”。 + +### 6.3 三条行动建议 + +每条卡片固定结构: + +- 建议动作 +- 适用人群 +- 2-3 个原因标签 + +目标: + +- 用户无需先读完整日报,就能获得可执行建议。 + +### 6.4 今日头条卡片流 + +内容: + +- 新模型发布 +- 重要价格变化 +- 活动 / 免费策略变化 + +卡片必须包含: + +- 事件标签 +- 标题 +- 为什么重要 +- 来源可信度 + +### 6.5 场景推荐区 + +按场景分组: + +- 低成本编码 +- 中文通用 +- Agent / 工具调用 +- 视觉 / 多模态 + +每组最多展示 3 个候选。 + +### 6.6 附录入口 + +附录包含: + +- 完整免费模型 +- 完整价格表 +- 平台覆盖 +- 套餐信息 + +要求: + +- 默认收起 +- 不打断首页信息节奏 + +## 7. Web 端布局策略 + +Web 端不是移动端的放大版,而是“带更多证据的完整版”。 + +### 7.1 首页布局 + +建议: + +- 左侧:一句话结论 + 行动建议 + 今日头条 +- 右侧:关键指标 + 风险提示 + 今日市场状态 + +### 7.2 内容承载 + +- 场景推荐区适合用矩阵布局增强对比。 +- 附录区允许展开更多表格和来源说明。 +- 可加入锚点导航,支持快速跳到: + - 今日变化 + - 推荐 + - 免费来源 + - 附录 + +### 7.3 一致性 + +移动端与 Web 端必须共享同一套: + +- 一句话结论 +- 头条事件 +- 推荐场景 +- 来源可信度标签 + +## 8. 核心组件定义 + +建议优先设计以下组件: + +### 8.1 一句话结论卡 + +用途: + +- 承担首屏最大视觉锚点 + +要求: + +- 大字号 +- 极少文字 +- 强背景对比 + +### 8.2 行动建议卡 + +用途: + +- 输出“今天该做什么” + +要求: + +- 标题明确 +- 适用对象明确 +- 理由用标签表达 +- 必须保留 1 个“证据短句位” + +### 8.3 头条事件卡 + +用途: + +- 承载新发布、降价、活动等高信号事件 + +要求: + +- 强标题 +- 强标签 +- 强数字 + +### 8.4 推荐模型卡 + +用途: + +- 承载场景化推荐 + +要求: + +- 先显示模型名与用途 +- 再显示来源与价格 +- 不再用长表格表达 +- 必须保留 1 个“关键证据短句位” +- 必须保留来源证据入口 + +### 8.5 来源可信度标签 + +用途: + +- 解决“免费是真的吗”“这个来源可不可信”的核心疑问 + +要求: + +- 视觉语义强 +- 全局复用 + +### 8.6 风险提示卡 + +用途: + +- 承载灰区来源、待验证来源、活动时效风险 + +要求: + +- 在色彩和语义上明显区别于机会卡 + +### 8.7 证据短句位规范 + +为避免页面只剩“推荐结论”而缺少决策依据,行动建议卡和推荐模型卡都必须保留一个固定的证据短句位。 + +证据短句位要求: + +- 只允许 1 行 +- 长度控制在 10-24 个中文字符 +- 优先展示“今天为什么值得关注”的理由 + +推荐文案模板: + +- `较昨日低 18%` +- `官方免费额度已确认` +- `首次发布,支持 256K` +- `聚合免费,适合尝鲜` +- `活动价,截止 05-31` + +设计原则: + +- 证据短句不是说明文,而是高信号决策依据。 +- 证据短句必须和推荐动作或头条判断形成闭环。 + +## 9. 抓眼球规则 + +首页必须显著抓眼,但不依赖低质量视觉噪音。 + +强制规则: + +- 首屏不能以大表格开头。 +- 首屏必须有一条大字号结论。 +- 文字说明尽量短,不允许连续长段。 +- 卡片正文以短句为主,避免三行以上解释。 +- 每屏只承担一个阅读任务。 + +判断标准: + +- 用户不需要阅读完整页,扫一眼也知道今天的重点。 +- 用户不会因为文字太密或太小而放弃继续阅读。 + +## 10. 交付物清单 + +正式设计交付建议包括: + +### 10.1 视觉方向稿 + +- 首页氛围图 +- 结论卡 / 行动建议卡 / 头条卡风格样张 +- 颜色与字体方向 + +### 10.2 信息架构稿 + +- 移动端首页草图 +- Web 首页草图 +- 模块优先级说明 + +### 10.3 核心组件稿 + +- 一句话结论卡 +- 行动建议卡 +- 头条卡 +- 推荐卡 +- 标签体系 +- 风险提示卡 + +### 10.4 高保真页面稿 + +至少三套: + +- 移动端首页 +- Web 端首页 +- 附录 / 展开态 + +必做扩展: + +- 平静日版本 +- 热点日版本 + +说明: + +- 平静日版本用于当天重大变化较少时,首页自动转向“观察重点 + 稳定推荐”结构。 +- 热点日版本用于新模型、降价、活动较多时,首页强化头条和事件卡密度。 +- 这两种状态都必须在设计阶段覆盖,避免实现后在“无事发生的日子”退化为旧式榜单页。 + +## 11. 页面级线框与高保真说明 + +本章作为 V1 实现前的页面级设计基线,直接约束移动端首页、Web 首页、以及“平静日 / 热点日”的状态切换方式。实现阶段不得绕开本章退回到“统计块 + 长表格”的旧结构。 + +### 11.1 移动端首页线框 + +目标设备: + +- 设计基准宽度:390px +- 安全区左右边距:16px +- 卡片圆角建议:20px +- 卡片间距:12px +- 可点击区域最小尺寸:44px x 44px + +信息顺序固定如下: + +1. 顶部情报头 +2. 一句话结论卡 +3. 三条行动建议 +4. 今日头条卡片流 +5. 场景推荐区 +6. 附录入口 + +线框结构: + +```text ++--------------------------------------------------+ +| 日期 / 更新时间 / 市场状态标签 | +| 一句短副标题 | ++--------------------------------------------------+ +| 今日一句话结论 | +| 1 行标签:价格战 / 官方发布 / 聚合免费偏多 | ++--------------------------------------------------+ +| 建议卡 1 | +| 建议卡 2 | +| 建议卡 3 | ++--------------------------------------------------+ +| 今日头条 | +| 头条卡 1 | +| 头条卡 2 | +| 头条卡 3 | ++--------------------------------------------------+ +| 场景推荐 | +| 低成本编码 | +| 中文通用 | +| Agent / 工具调用 | +| 视觉 / 多模态 | ++--------------------------------------------------+ +| 附录入口:完整价格 / 完整免费 / 平台覆盖 | ++--------------------------------------------------+ +``` + +滚动节奏要求: + +- 首屏 1 到 1.5 屏内,必须完整出现“顶部情报头 + 一句话结论卡 + 至少 1 张行动建议卡”。 +- 第二屏必须进入“今日头条”或“场景推荐”,不能被长段说明占满。 +- 附录入口必须在前三次滑动内可见,但默认不展开长表格。 + +### 11.2 移动端首页高保真说明 + +#### 11.2.1 顶部情报头 + +固定字段: + +- 左侧:`05-13 Wed` +- 右侧:`08:35 更新` +- 下方标签:`价格战活跃`、`新模型日`、`免费策略波动` +- 最底一行:一句短副标题,控制在 18 个中文字符内 + +视觉要求: + +- 顶部区域不使用纯白平板,采用轻雾灰底 + 细粒度渐变。 +- 标签采用胶囊形,单个标签宽度不超过一行的 40%。 +- 日期和更新时间用较小字号,但不得低于 14px。 + +#### 11.2.2 一句话结论卡 + +内容结构固定: + +- 主结论:1 条,28-40 个中文字符 +- 辅助标签:最多 2 个 +- 可选证据短句:1 条,10-18 个中文字符 + +视觉层级: + +- 主结论字号:22px-26px,字重明显高于正文 +- 卡片背景优先使用墨蓝浅化渐变或暖灰底叠加高亮描边 +- 该卡片必须成为首屏最大视觉锚点 + +禁止事项: + +- 禁止在该卡片中堆 2 段以上解释文字 +- 禁止放 4 个以上标签 +- 禁止把统计数字作为主标题替代结论句 + +#### 11.2.3 行动建议卡 + +每张卡固定包含 4 行: + +1. 动作标题,例如 `今天先试它` +2. 适用人群,例如 `适合低成本代码生成` +3. 标签组,最多 3 个 +4. 证据短句位,必须存在 + +卡片高度建议: + +- 默认高度:112px-128px +- 标题最多 1 行 +- 适用人群最多 1 行 +- 证据短句最多 1 行 + +视觉要求: + +- 三张卡必须形成强弱关系,推荐优先级最高的一张使用更高对比色边框或更厚阴影。 +- 不允许三张卡完全同权展示,否则用户无法一眼分辨首选动作。 + +#### 11.2.4 今日头条卡 + +每张头条卡固定包含: + +- 事件标签 +- 标题 +- 影响短句 +- 比较基线或关键数字 +- 来源可信度标签 + +内容长度限制: + +- 标题最多 2 行 +- 影响短句最多 2 行 +- 关键数字必须大于正文层级 + +视觉要求: + +- `新发布` 优先用墨蓝 +- `价格下调` 优先用祖母绿 +- `活动 / 促销` 优先用琥珀橙 +- `来源风险` 优先用朱砂红 + +#### 11.2.5 场景推荐区 + +每个场景模块固定包含: + +- 场景标题 +- 1 个主推荐卡 +- 2 个次推荐条目 +- 1 个“查看更多”入口 + +展示规则: + +- 主推荐卡允许露出模型名、用途、来源类型、价格摘要、证据短句 +- 次推荐条目只露出模型名 + 1 个标签 + 1 个价格摘要 +- 同一模型在一个首页中最多出现 2 次 + +#### 11.2.6 附录入口 + +移动端首页只允许展示附录入口,不允许直接铺开完整表格。 + +入口结构: + +- 标题:`完整数据附录` +- 三个快捷入口:`完整价格`、`完整免费`、`平台覆盖` +- 一条解释短句:`适合深度比价时查看` + +### 11.3 Web 首页线框 + +目标设备: + +- 设计基准宽度:1440px +- 主内容最大宽度:1280px +- 栅格:12 列 +- 页面左右留白:48px-64px + +布局固定如下: + +- 左 7 列:一句话结论卡、行动建议、今日头条、场景推荐 +- 右 5 列:关键指标、市场状态、风险提醒、来源说明入口 + +线框结构: + +```text ++-----------------------------+---------------------------+ +| 顶部情报头 | 今日市场状态 / 风险摘要 | ++-----------------------------+---------------------------+ +| 一句话结论卡 | 关键指标卡组 | ++-----------------------------+---------------------------+ +| 三条行动建议 | 来源可信度说明 | ++-----------------------------+---------------------------+ +| 今日头条卡组 | 风险提示卡 | ++-----------------------------+---------------------------+ +| 场景推荐矩阵 | 锚点导航 / 附录入口 | ++---------------------------------------------------------+ +| 附录区:完整价格 / 完整免费 / 平台覆盖 / 来源证据 | ++---------------------------------------------------------+ +``` + +Web 端目标不是“更花”,而是“更方便横向比较”。因此: + +- 左列承担叙事和推荐。 +- 右列承担解释和证据。 +- 附录区承担完整查询,不干扰上半屏结论阅读。 + +### 11.4 Web 首页高保真说明 + +#### 11.4.1 顶部信息带 + +顶部允许比移动端多展示 1 组指标,但仍遵循“短句优先”: + +- 日期 +- 更新时间 +- 今日变化摘要:新增模型数、降价数、活动数 +- 市场状态标签 + +要求: + +- 顶部信息带总高度控制在 88px-112px +- 不允许做成传统 KPI 仪表盘 + +#### 11.4.2 关键指标卡组 + +右侧指标卡组只保留 4 张: + +- 今日新增模型 +- 今日重要降价 +- 官方免费数量 +- 聚合免费数量 + +展示要求: + +- 数字大,解释短 +- 每张卡必须有“指标含义”短标签 +- 禁止在 Web 首屏出现 8 张以上统计块 + +#### 11.4.3 场景推荐矩阵 + +Web 端场景推荐允许做成 2 x 2 矩阵: + +- 左上:低成本编码 +- 右上:中文通用 +- 左下:Agent / 工具调用 +- 右下:视觉 / 多模态 + +每格固定结构: + +- 场景标题 +- 主推荐 1 条 +- 次推荐 2 条 +- 来源说明入口 + +#### 11.4.4 来源与风险区 + +右侧必须有一个常驻区块,用于解释: + +- 今日哪些“免费”是官方免费 +- 哪些免费来自聚合平台 +- 哪些来源待验证 +- 哪些活动存在截止时间 + +该区块默认用短句摘要表达,并允许展开查看证据。 + +### 11.5 视觉层级固定规则 + +为防止实现时退化成“信息都一样重要”,页面层级固定如下: + +- `P0`:一句话结论卡 +- `P1`:第一张行动建议卡 + 第一条头条 +- `P2`:其余行动建议卡 + 其余头条卡 +- `P3`:场景推荐主卡 +- `P4`:附录入口、解释性文字、完整表格 + +实现要求: + +- 首屏同一时刻只能有 1 个 P0。 +- 每个区块最多存在 1 个强主色焦点。 +- P4 信息不能在视觉上压过 P1 / P2。 + +### 11.6 状态切换规则 + +首页必须支持三种状态,并由数据自动驱动: + +#### 11.6.1 常规日 + +触发条件: + +- 有 1-2 条重要变化 +- 或存在 1 条较强头条但整体事件密度不高 + +页面策略: + +- 保持标准六区块结构 +- 头条区展示 2-3 张卡 +- 场景推荐正常展开 + +#### 11.6.2 平静日 + +触发条件建议: + +- 重大变化事件少于 2 条 +- 且无 `official_release` +- 且无显著降价或活动 + +页面策略: + +- 结论卡改为“观察重点 + 稳定推荐”语气 +- 行动建议卡优先展示“稳定商用选择” +- 头条区减少到 1-2 张,并允许用“今日无重大上新 / 无显著调价”作为信息性卡片 +- 场景推荐上移,承担更多首页价值 + +禁止事项: + +- 禁止用旧榜单、旧大表格填满头条区 +- 禁止为了“看起来有内容”重复同一模型三次以上 + +#### 11.6.3 热点日 + +触发条件建议: + +- `official_release` >= 1 +- 或重大变化事件 >= 3 +- 或同日存在“新发布 + 降价 + 活动”组合 + +页面策略: + +- 顶部情报头增加“热点日”状态标签 +- 今日头条区允许扩展到 4 张卡 +- 第一条头条卡可升级为宽版主头条 +- 场景推荐保留,但默认折叠次推荐条目,避免首屏过长 + +### 11.7 页面状态验收要求 + +线框和高保真说明必须同时覆盖以下状态: + +- 移动端常规日 +- 移动端平静日 +- 移动端热点日 +- Web 端常规日 +- Web 端平静日 +- Web 端热点日 + +验收标准: + +- 任一状态下,用户在 30 秒内都能说出“今天值不值得关注”。 +- 平静日不会退化成榜单堆砌页。 +- 热点日不会因为信息过多而丢失主结论。 + +## 12. 版本路线图 + +### V1 可读版 + +目标: + +- 让日报从“数据导出页”变成“人能快速看懂的日报” + +包含: + +- 一句话结论 +- 3 条行动建议 +- 今日变化摘要 +- 免费来源类型标签 +- 附录后置 + +验收: + +- 用户 30 秒内说出今天最重要变化 +- 免费区 100% 带来源类型标签 +- 首屏不再是大表格 +- 存在“平静日状态”首页方案,且不使用重复榜单填充头条区 + +### V2 情报版 + +目标: + +- 让日报具备“每天值得打开”的新闻价值 + +包含: + +- 事件流 +- 今日头条 3 条 +- 活动 / 发布 / 降价打标 +- 来源可信度分级 + +验收: + +- 80% 日报至少有 1 条真正变化事件 +- 头条区每条都含事件类型、可信度、影响对象 +- 主推荐区不出现待验证来源 + +### V3 专业版 + +目标: + +- 形成可持续的 AI 模型与价格情报产品 + +包含: + +- 事件表 / 来源注册表 +- 风险分层 +- 趋势入口 +- 周报 / 专题扩展能力 + +验收: + +- 用户可把日报当作日常选型输入 +- 变化、活动、来源风险都能稳定进入产品 + +## 13. 实施建议 + +推荐实施顺序: + +1. 先完成移动端首页高保真设计 +2. 再扩展 Web 端版式 +3. 抽出组件规范 +4. 改造 HTML 日报模板 +5. 改造 Dashboard 日报入口与摘要视图 +6. 用真实日报数据回填验收 + +推荐工程路径: + +- 优先改造 `scripts/generate_daily_report.go` 中的 HTML 模板和内容编排 +- 复用现有 `/api/v1/reports/latest` 能力,在前端摘要入口中承载新版视觉模块 +- 后续再将事件与来源标签能力沉淀到独立模块 + +## 14. 结论 + +本次 UI 重构的目标不是单纯“美化日报”,而是把日报变成一个: + +- 愿意每天打开的高端移动资讯产品 +- 能快速做出选型判断的专业工具 +- 不牺牲底层数据库完整性的情报展示层 + +后续实现必须始终围绕三条主线: + +- 信息层级清楚 +- 来源可信度透明 +- 读者能快速行动 diff --git a/docs/plans/2026-05-13-daily-report-v1-implementation-plan.md b/docs/plans/2026-05-13-daily-report-v1-implementation-plan.md new file mode 100644 index 0000000..6794c34 --- /dev/null +++ b/docs/plans/2026-05-13-daily-report-v1-implementation-plan.md @@ -0,0 +1,296 @@ +# Daily Report V1 Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** 将当前“数据库导出式日报”改造成移动端优先、变化驱动、可快速决策的 V1 日报页面,并同步补齐 Dashboard 摘要入口。 + +**Architecture:** 保留现有数据库和采集链路不动,把改造集中在 `scripts/generate_daily_report.go` 的报告语义层与 HTML 模板层。先把“结论、行动建议、头条、免费来源标签、场景推荐”抽成可测试的构建函数,再重写 HTML 模板和前端摘要视图,最后用真实日报生成、Go 测试、前端构建联合验收。 + +**Tech Stack:** Go 1.22、html/template、PostgreSQL、React、TypeScript、CSS + +--- + +### Task 1: 为日报 V1 语义层补测试 + +**Files:** +- Modify: `scripts/generate_daily_report_test.go` +- Test: `scripts/generate_daily_report_test.go` + +**Step 1: 写失败测试** + +补 3 组测试: +- 免费来源标签分组测试:验证 `official_free`、`aggregator_free`、`unknown_free` +- V1 首页摘要测试:验证会输出一句话结论、行动建议、头条、附录快捷入口 +- 平静日状态测试:验证当事件不足时,首页出现“观察重点 + 稳定推荐”文案 + +**Step 2: 运行失败测试** + +Run: `go test -tags llm_script ./scripts -run 'TestGenerate|TestBuild'` + +Expected: +- 新增测试失败 +- 失败原因是缺少语义层函数或 HTML 中不存在新版文案 + +**Step 3: 最小实现语义函数** + +在 `scripts/generate_daily_report.go` 中新增: +- 免费来源分类辅助类型 +- 首页摘要结构 +- 头条 / 建议 / 推荐构建函数 + +**Step 4: 重新运行测试** + +Run: `go test -tags llm_script ./scripts -run 'TestGenerate|TestBuild'` + +Expected: +- 新增测试通过 + +**Step 5: Commit** + +```bash +git add scripts/generate_daily_report.go scripts/generate_daily_report_test.go +git commit -m "feat(report): add v1 report summary builders" +``` + +### Task 2: 重构日报数据结构以支撑 V1 页面 + +**Files:** +- Modify: `scripts/generate_daily_report.go` +- Test: `scripts/generate_daily_report_test.go` + +**Step 1: 写失败测试** + +为以下内容补断言: +- 免费模型按来源可信度分组显示 +- 推荐卡存在证据短句 +- 头条卡存在变化基线或“首次出现”标签 + +**Step 2: 运行失败测试** + +Run: `go test -tags llm_script ./scripts -run 'TestGenerateHTMLV3|TestBuild'` + +Expected: +- HTML 内容不包含新版结构字段 + +**Step 3: 最小实现** + +在 `ReportV3` 上新增 V1 所需衍生字段,例如: +- `HeroSummary` +- `ActionItems` +- `HeadlineItems` +- `SceneSections` +- `FreeBreakdown` +- `AppendixLinks` +- `PageMode` + +并在 `generateReportDataV3` 末尾统一填充。 + +**Step 4: 重新运行测试** + +Run: `go test -tags llm_script ./scripts -run 'TestGenerateHTMLV3|TestBuild'` + +Expected: +- 数据结构相关测试通过 + +**Step 5: Commit** + +```bash +git add scripts/generate_daily_report.go scripts/generate_daily_report_test.go +git commit -m "feat(report): enrich daily report v1 view model" +``` + +### Task 3: 重写 HTML 模板为移动端优先 V1 首页 + +**Files:** +- Modify: `scripts/generate_daily_report.go` +- Test: `scripts/generate_daily_report_test.go` + +**Step 1: 写失败测试** + +断言 HTML 包含以下结构关键词: +- `今日一句话结论` +- `三条行动建议` +- `今日头条` +- `场景推荐` +- `完整数据附录` +- 免费来源标签:`官方免费`、`聚合免费`、`待确认` + +**Step 2: 运行失败测试** + +Run: `go test -tags llm_script ./scripts -run 'TestGenerateHTMLV3Includes'` + +Expected: +- 失败,说明旧模板仍是统计卡 + 表格 + +**Step 3: 最小实现** + +重写 `generateHTMLV3`: +- 使用移动端优先布局 +- 加入结论卡、行动建议卡、头条卡、场景推荐、附录入口 +- 保留必要的完整表格,但下沉到附录区 +- 按设计文档落地颜色、字号、标签、平静日/热点日状态 + +**Step 4: 重新运行测试** + +Run: `go test -tags llm_script ./scripts -run 'TestGenerateHTMLV3Includes'` + +Expected: +- 模板结构测试通过 + +**Step 5: Commit** + +```bash +git add scripts/generate_daily_report.go scripts/generate_daily_report_test.go +git commit -m "feat(report): redesign html daily report for v1" +``` + +### Task 4: 调整 Markdown 让结构与新版日报一致 + +**Files:** +- Modify: `scripts/generate_daily_report.go` +- Test: `scripts/generate_daily_report_test.go` + +**Step 1: 写失败测试** + +补充 Markdown 断言: +- 顶部出现“今日结论”“今日行动建议”“今日变化” +- 免费区出现来源分类摘要 + +**Step 2: 运行失败测试** + +Run: `go test -tags llm_script ./scripts -run 'TestGenerateMarkdownV3'` + +Expected: +- 旧 Markdown 不包含新版结构 + +**Step 3: 最小实现** + +改写 `generateMarkdownV3` 的章节顺序,至少与 HTML 保持: +- 结论 +- 行动建议 +- 变化摘要 +- 场景推荐 +- 附录 + +**Step 4: 重新运行测试** + +Run: `go test -tags llm_script ./scripts -run 'TestGenerateMarkdownV3'` + +Expected: +- Markdown 测试通过 + +**Step 5: Commit** + +```bash +git add scripts/generate_daily_report.go scripts/generate_daily_report_test.go +git commit -m "feat(report): align markdown report with v1 structure" +``` + +### Task 5: 补 Dashboard 摘要卡以对齐新版日报 + +**Files:** +- Modify: `frontend/src/pages/Dashboard.tsx` +- Modify: `frontend/src/App.css` + +**Step 1: 写失败测试或构建前检查** + +由于当前前端未配置页面测试,先以类型检查和构建作为验收门槛,并在实现前明确 UI 目标: +- Dashboard 出现一句话摘要 +- 显示报告日期、状态、HTML / Markdown 入口 +- 展示“固定路径回退”提示 +- 视觉上更接近新版日报入口卡 + +**Step 2: 最小实现** + +在 `Dashboard.tsx` 中: +- 扩展 `LatestReport` 展示字段 +- 生成更强的信息层级摘要 +- 调整布局让入口更接近移动端日报卡片语义 + +在 `App.css` 中: +- 为日报入口补新版卡片层级 +- 优化移动端字号和按钮布局 + +**Step 3: 运行构建** + +Run: `cd frontend && npm run build` + +Expected: +- 构建通过 + +**Step 4: Commit** + +```bash +git add frontend/src/pages/Dashboard.tsx frontend/src/App.css +git commit -m "feat(frontend): align dashboard report card with v1 report" +``` + +### Task 6: 真实生成和联调验证 + +**Files:** +- Modify: `reports/daily/*`(生成产物) +- Verify: `scripts/generate_daily_report.go` +- Verify: `scripts/verify_phase3.sh` + +**Step 1: 运行脚本测试** + +Run: `go test -tags llm_script ./scripts` + +Expected: +- 脚本相关测试通过 + +**Step 2: 运行后端测试** + +Run: `go test ./...` + +Expected: +- 全部通过 + +**Step 3: 运行前端构建** + +Run: `cd frontend && npm run build` + +Expected: +- 构建通过 + +**Step 4: 真实生成日报** + +Run: `go run -tags llm_script ./scripts/generate_daily_report.go` + +Expected: +- 生成新版 md/html +- 主产物与归档产物都更新 + +**Step 5: 门禁验证** + +Run: `bash scripts/verify_phase3.sh` + +Expected: +- `PHASE_RESULT: PASS` + +**Step 6: Commit** + +```bash +git add scripts/generate_daily_report.go scripts/generate_daily_report_test.go frontend/src/pages/Dashboard.tsx frontend/src/App.css reports/daily +git commit -m "feat(report): ship daily report v1 experience" +``` + +### Task 7: 推送到仓库 + +**Files:** +- Verify: working tree + +**Step 1: 检查状态** + +Run: `git status --short` + +Expected: +- 只剩可接受的既有脏文件或已知产物 + +**Step 2: 推送** + +Run: `git push` + +Expected: +- 推送成功 + diff --git a/frontend/src/App.css b/frontend/src/App.css index e249c13..7213543 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -200,6 +200,150 @@ background: #fff; } +.report-section { + margin-bottom: 24px; + padding: 20px; + border: 1px solid #bfdbfe; + border-radius: 12px; + background: linear-gradient(180deg, #eff6ff 0%, #ffffff 100%); +} + +.report-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + margin-bottom: 16px; +} + +.report-header h3 { + margin: 0; + color: #111827; +} + +.report-header p { + margin: 6px 0 0; + color: #6b7280; + font-size: 14px; +} + +.report-status { + display: inline-flex; + align-items: center; + padding: 8px 12px; + border-radius: 999px; + font-size: 13px; + font-weight: 600; + text-transform: lowercase; +} + +.report-status-generated { + background: #dbeafe; + color: #1d4ed8; +} + +.report-status-other { + background: #e5e7eb; + color: #374151; +} + +.report-card { + padding: 18px; + border: 1px solid #dbeafe; + border-radius: 18px; + background: + radial-gradient(circle at top left, rgba(37, 99, 235, 0.10), transparent 35%), + rgba(255, 255, 255, 0.94); + box-shadow: 0 12px 30px rgba(37, 99, 235, 0.08); +} + +.report-hero { + padding: 18px; + border-radius: 16px; + background: linear-gradient(135deg, #123c63 0%, #24507a 100%); + color: #ffffff; + margin-bottom: 16px; +} + +.report-eyebrow { + font-size: 12px; + letter-spacing: 0.08em; + text-transform: uppercase; + opacity: 0.78; + margin-bottom: 8px; +} + +.report-summary { + font-size: 22px; + line-height: 1.35; + font-weight: 800; +} + +.report-meta { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 14px; + color: #4b5563; + font-size: 14px; +} + +.report-highlights { + display: grid; + gap: 10px; + margin-bottom: 14px; +} + +.report-highlight { + display: grid; + gap: 4px; + padding: 12px 14px; + border-radius: 12px; + background: rgba(239, 246, 255, 0.92); + border: 1px solid #dbeafe; +} + +.report-highlight strong { + color: #1e3a8a; + font-size: 13px; +} + +.report-highlight span { + color: #475569; + font-size: 14px; +} + +.report-actions { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.report-link { + display: inline-flex; + align-items: center; + justify-content: center; + padding: 10px 14px; + border: 1px solid #93c5fd; + border-radius: 8px; + color: #1d4ed8; + text-decoration: none; + font-weight: 600; + background: #ffffff; +} + +.report-link-primary { + background: #2563eb; + border-color: #2563eb; + color: #ffffff; +} + +.report-note { + margin-top: 12px; + color: #6b7280; + font-size: 13px; +} + .subscription-section { padding: 20px; border: 1px solid #fde68a; @@ -302,6 +446,22 @@ flex-direction: column; } + .report-header { + flex-direction: column; + } + + .report-summary { + font-size: 24px; + } + + .report-actions { + flex-direction: column; + } + + .report-link { + width: 100%; + } + .subscription-summary { white-space: normal; } diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index e326214..7f69d10 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -12,6 +12,46 @@ import { type SubscriptionPlan, } from '../lib/models' +type LatestReport = { + reportDate: string + status: string + modelCount: number + summaryMD: string + markdownUrl: string + htmlUrl: string + updatedAt: string +} + +function formatLocalReportDate(date: Date) { + const year = date.getFullYear() + const month = `${date.getMonth() + 1}`.padStart(2, '0') + const day = `${date.getDate()}`.padStart(2, '0') + return `${year}-${month}-${day}` +} + +function buildFallbackLatestReport(): LatestReport { + const reportDate = formatLocalReportDate(new Date()) + return { + reportDate, + status: 'generated', + modelCount: 0, + summaryMD: '最新日报入口可用,后端元数据暂未返回摘要。', + markdownUrl: `/reports/daily/daily_report_${reportDate}.md`, + htmlUrl: `/reports/daily/html/daily_report_${reportDate}.html`, + updatedAt: '', + } +} + +function summarizeLatestReport(report: LatestReport) { + if (report.summaryMD.trim()) { + return report.summaryMD.trim() + } + if (report.modelCount > 0) { + return `最新日报已生成,覆盖 ${report.modelCount} 个模型,可直接查看完整 HTML 页面。` + } + return '最新日报入口已准备好,可直接打开 HTML 或 Markdown 查看。' +} + function Dashboard() { const chartRef = useRef(null) const [modelCount, setModelCount] = useState(0) @@ -20,6 +60,8 @@ function Dashboard() { const [subscriptionPlans, setSubscriptionPlans] = useState([]) const [planCount, setPlanCount] = useState(0) const [planMinPrice, setPlanMinPrice] = useState(0) + const [latestReport, setLatestReport] = useState(null) + const [reportFallback, setReportFallback] = useState(false) useEffect(() => { let chart: echarts.ECharts | null = null @@ -108,8 +150,40 @@ function Dashboard() { } } + const loadLatestReport = async () => { + try { + const response = await fetch('/api/v1/reports/latest') + if (!response.ok) { + throw new Error(`latest report request failed: ${response.status}`) + } + const payload = await response.json() + const report = payload?.data + if (!report?.reportDate || !report?.htmlUrl || !report?.markdownUrl) { + throw new Error('latest report payload invalid') + } + if (!disposed) { + setLatestReport({ + reportDate: report.reportDate, + status: report.status || 'generated', + modelCount: Number(report.modelCount || 0), + summaryMD: report.summaryMD || '', + markdownUrl: report.markdownUrl, + htmlUrl: report.htmlUrl, + updatedAt: report.updatedAt || '', + }) + setReportFallback(false) + } + } catch { + if (!disposed) { + setLatestReport(buildFallbackLatestReport()) + setReportFallback(true) + } + } + } + void loadModels() void loadSubscriptionPlans() + void loadLatestReport() return () => { disposed = true @@ -141,6 +215,55 @@ function Dashboard() {
+
+
+
+

📰 最新日报

+

移动端优先的情报首页已经上线,这里直接给你最快的入口。

+
+ {latestReport && ( + + {latestReport.status} + + )} +
+ {latestReport ? ( +
+
+
今日一句话结论
+
{summarizeLatestReport(latestReport)}
+
+
+ 报告日期 {latestReport.reportDate} + {latestReport.modelCount > 0 && {latestReport.modelCount} 个模型} + {latestReport.updatedAt && 更新于 {latestReport.updatedAt}} +
+
+
+ 推荐阅读 + 先看 HTML 首页,再按需打开 Markdown 原文。 +
+
+ 适合场景 + 今天要快速选型,或想知道免费来源是否可靠。 +
+
+ + {reportFallback && ( +
当前使用固定路径回退入口,后端报告元数据暂不可用。
+ )} +
+ ) : ( +
最新日报暂不可用。
+ )} +
diff --git a/scripts/generate_daily_report.go b/scripts/generate_daily_report.go index a16531c..6f84047 100644 --- a/scripts/generate_daily_report.go +++ b/scripts/generate_daily_report.go @@ -9,6 +9,7 @@ import ( "encoding/json" "fmt" "html/template" + "io" "log/slog" "os" "path/filepath" @@ -93,7 +94,10 @@ func run() error { } // 2. 创建目录 - outDir := "reports/daily" + outDir := os.Getenv("REPORT_OUTPUT_DIR") + if outDir == "" { + outDir = "reports/daily" + } os.MkdirAll(outDir, 0755) os.MkdirAll(outDir+"/html", 0755) @@ -109,8 +113,13 @@ func run() error { return err } - // 5. 保存到 daily_report 表 - if err := saveDailyReportV3(db, report, mdPath); err != nil { + // 5. 归档主产物,确保运行脚本和门禁使用统一路径约定 + if err := archiveReportArtifacts(date, mdPath, htmlPath); err != nil { + return fmt.Errorf("归档日报失败: %w", err) + } + + // 6. 同步写入日报状态与运行轨迹 + if err := saveReportTrackingV3(db, report, mdPath); err != nil { logger.Warn("保存日报记录失败", "error", err) } @@ -131,33 +140,100 @@ const ( ) type ModelInfo struct { - ID, Name, ProviderName string - ProviderCountry string - ContextLength int + ID, Name, ProviderName string + ProviderCountry string + ContextLength int InputPrice, OutputPrice float64 - Currency string - IsFree bool - OperatorName string - OperatorType string // cloud / reseller / official - Region string - Modality string - SceneTags []SceneTag + Currency string + IsFree bool + OperatorName string + OperatorType string // cloud / reseller / official + Region string + Modality string + SceneTags []SceneTag } type ReportV3 struct { - Date string - TotalModels int - FreeModels []ModelInfo - FreeTop20 []ModelInfo // 免费模型前20个(展示用) - IntlTop5 []ModelInfo // 国际前5(付费低价) - DomesticTop10 []ModelInfo // 国内前10(付费低价) - TopContext []ModelInfo // 大上下文TOP10 + Date string + GeneratedAt string + TotalModels int + AllModels []ModelInfo + FreeModels []ModelInfo + FreeTop20 []ModelInfo // 免费模型前20个(展示用) + IntlTop5 []ModelInfo // 国际前5(付费低价) + DomesticTop10 []ModelInfo // 国内前10(付费低价) + TopContext []ModelInfo // 大上下文TOP10 TencentSubscriptionPlans []SubscriptionPlanInfo - Operators []OperatorInfo - Resellers []OperatorInfo - QualitySummary DataQualitySummary - HasCNYData bool - HasDomesticData bool + Operators []OperatorInfo + Resellers []OperatorInfo + QualitySummary DataQualitySummary + HasCNYData bool + HasDomesticData bool + DailySignals DailySignals + PageMode string + MarketLabels []string + HeroSummary string + HeroEvidence string + FreeBreakdown []FreeSourceStat + ActionItems []ActionItem + HeadlineItems []HeadlineItem + SceneSections []SceneSection + AppendixLinks []AppendixLink +} + +type DailySignals struct { + NewModels int + PriceChanges int + OfficialFree int + AggregatorFree int + UnknownFree int +} + +type FreeSourceStat struct { + Label string + Description string + Tone string + Count int +} + +type ActionItem struct { + Title string + Audience string + Evidence string + Tags []string +} + +type HeadlineItem struct { + Label string + Title string + Summary string + Baseline string + TrustLabel string + Tone string +} + +type Recommendation struct { + Name string + Provider string + Operator string + Usage string + PriceSummary string + Evidence string + TrustLabel string + Tags []string +} + +type SceneSection struct { + Title string + Description string + Lead Recommendation + Others []Recommendation +} + +type AppendixLink struct { + Title string + Description string + Anchor string } type OperatorInfo struct { @@ -235,7 +311,7 @@ func generateReportDataV3(db *sql.DB, date string) (*ReportV3, error) { var allModels []ModelInfo var freeModels []ModelInfo - var intlModels []ModelInfo // 国际模型(US/EU/unknown) + var intlModels []ModelInfo // 国际模型(US/EU/unknown) var domesticModels []ModelInfo // 国内模型(CN) providerSet := make(map[string]struct{}) @@ -261,11 +337,11 @@ func generateReportDataV3(db *sql.DB, date string) (*ReportV3, error) { } // 国家分类 - 国内官方平台 vs OpenRouter上的国内模型 - isDomesticOfficial := (m.ProviderCountry == "CN" || m.ProviderCountry == "cn") && + isDomesticOfficial := (m.ProviderCountry == "CN" || m.ProviderCountry == "cn") && (m.OperatorType == "official" || m.OperatorType == "cloud") - isDomesticReseller := (m.ProviderCountry == "CN" || m.ProviderCountry == "cn") && + isDomesticReseller := (m.ProviderCountry == "CN" || m.ProviderCountry == "cn") && m.OperatorType == "reseller" - + if isDomesticOfficial { domesticModels = append(domesticModels, m) } else if isDomesticReseller { @@ -391,18 +467,20 @@ func generateReportDataV3(db *sql.DB, date string) (*ReportV3, error) { return nil, err } - return &ReportV3{ - Date: date, - TotalModels: len(allModels), - FreeModels: freeModels, - FreeTop20: freeTop20, - IntlTop5: intlTop5, - DomesticTop10: domesticTop10, + report := &ReportV3{ + Date: date, + GeneratedAt: time.Now().Format(time.RFC3339), + TotalModels: len(allModels), + AllModels: allModels, + FreeModels: freeModels, + FreeTop20: freeTop20, + IntlTop5: intlTop5, + DomesticTop10: domesticTop10, TencentSubscriptionPlans: tencentPlans, - Operators: operators, - Resellers: resellers, - HasCNYData: cny > 0, - HasDomesticData: len(domesticModels) > 0, + Operators: operators, + Resellers: resellers, + HasCNYData: cny > 0, + HasDomesticData: len(domesticModels) > 0, QualitySummary: DataQualitySummary{ Total: len(allModels), Fresh: fresh, @@ -410,7 +488,14 @@ func generateReportDataV3(db *sql.DB, date string) (*ReportV3, error) { CNY: cny, USD: usd, }, - }, nil + } + if signals, err := loadDailySignals(db, date); err != nil { + logger.Warn("加载日报变化信号失败", "error", err) + } else { + report.DailySignals = signals + } + decorateReportV1(report) + return report, nil } func loadTencentSubscriptionPlans(db *sql.DB) ([]SubscriptionPlanInfo, error) { @@ -594,20 +679,20 @@ func deriveSceneTags(name, modality string, capabilities []string) []SceneTag { // 代码模型 if strings.Contains(lowerName, "codex") || strings.Contains(lowerName, "coder") || - strings.Contains(lowerName, "code") || strings.Contains(modality, "code") { + strings.Contains(lowerName, "code") || strings.Contains(modality, "code") { tags = append(tags, SceneCode) } // 推理模型 if strings.Contains(lowerName, "o1") || strings.Contains(lowerName, "o3") || - strings.Contains(lowerName, "o4") || strings.Contains(lowerName, "reasoning") || - strings.Contains(lowerName, "r1") || strings.Contains(lowerName, "thinking") { + strings.Contains(lowerName, "o4") || strings.Contains(lowerName, "reasoning") || + strings.Contains(lowerName, "r1") || strings.Contains(lowerName, "thinking") { tags = append(tags, SceneReasoning) } // 视觉模型 if strings.Contains(modality, "vision") || strings.Contains(modality, "multimodal") || - strings.Contains(lowerName, "vl") || strings.Contains(lowerName, "vision") { + strings.Contains(lowerName, "vl") || strings.Contains(lowerName, "vision") { tags = append(tags, SceneVision) } @@ -621,9 +706,500 @@ func deriveSceneTags(name, modality string, capabilities []string) []SceneTag { return tags } +func loadDailySignals(db *sql.DB, date string) (DailySignals, error) { + signals := DailySignals{} + + if err := db.QueryRow(` + SELECT COUNT(*) + FROM models + WHERE deleted_at IS NULL + AND DATE(created_at) = $1::date + `, date).Scan(&signals.NewModels); err != nil { + return signals, err + } + + if err := db.QueryRow(` + SELECT COUNT(*) + FROM pricing_history + WHERE DATE(changed_at) = $1::date + `, date).Scan(&signals.PriceChanges); err != nil { + return signals, err + } + + return signals, nil +} + +func decorateReportV1(r *ReportV3) { + if r == nil { + return + } + + r.FreeBreakdown = buildFreeSourceBreakdown(r.FreeModels) + for _, item := range r.FreeBreakdown { + switch item.Label { + case "官方免费": + r.DailySignals.OfficialFree = item.Count + case "聚合免费": + r.DailySignals.AggregatorFree = item.Count + case "待确认": + r.DailySignals.UnknownFree = item.Count + } + } + + r.PageMode = buildPageMode(r.DailySignals) + r.MarketLabels = buildMarketLabels(r) + r.HeroSummary, r.HeroEvidence = buildHeroSummary(r) + r.SceneSections = buildSceneSections(r) + r.ActionItems = buildActionItems(r) + r.HeadlineItems = buildHeadlineItems(r) + r.AppendixLinks = []AppendixLink{ + {Title: "完整价格", Description: "查看完整模型价格表", Anchor: "#appendix-pricing"}, + {Title: "完整免费", Description: "查看全部免费模型与来源", Anchor: "#appendix-free"}, + {Title: "平台覆盖", Description: "查看官方平台与聚合平台覆盖", Anchor: "#appendix-platforms"}, + } +} + +func buildFreeSourceBreakdown(models []ModelInfo) []FreeSourceStat { + counts := map[string]int{ + "官方免费": 0, + "聚合免费": 0, + "待确认": 0, + } + for _, model := range models { + counts[classifyFreeSource(model)]++ + } + + order := []FreeSourceStat{ + {Label: "官方免费", Description: "官方或云厂商直接提供免费能力", Tone: "official", Count: counts["官方免费"]}, + {Label: "聚合免费", Description: "主流聚合平台提供免费路由或免费变体", Tone: "aggregator", Count: counts["聚合免费"]}, + {Label: "待确认", Description: "免费机制或来源仍需进一步核验", Tone: "caution", Count: counts["待确认"]}, + } + + var result []FreeSourceStat + for _, item := range order { + if item.Count > 0 { + result = append(result, item) + } + } + return result +} + +func classifyFreeSource(model ModelInfo) string { + switch model.OperatorType { + case "official", "cloud": + return "官方免费" + case "reseller": + if isVerifiedAggregator(model.OperatorName) { + return "聚合免费" + } + } + return "待确认" +} + +func isVerifiedAggregator(name string) bool { + normalized := strings.ToLower(strings.TrimSpace(name)) + switch normalized { + case "openrouter", "siliconflow", "fireworks", "groq": + return true + default: + return false + } +} + +func buildPageMode(signals DailySignals) string { + if signals.NewModels == 0 && signals.PriceChanges == 0 { + return "calm" + } + if signals.NewModels+signals.PriceChanges >= 3 { + return "hot" + } + return "standard" +} + +func buildMarketLabels(r *ReportV3) []string { + labels := []string{} + switch r.PageMode { + case "hot": + labels = append(labels, "热点日") + case "calm": + labels = append(labels, "平静日") + default: + labels = append(labels, "常规日") + } + if r.DailySignals.NewModels > 0 { + labels = append(labels, "新模型日") + } + if r.DailySignals.PriceChanges > 0 { + labels = append(labels, "价格波动") + } + if r.DailySignals.AggregatorFree > r.DailySignals.OfficialFree { + labels = append(labels, "聚合免费偏多") + } else if r.DailySignals.OfficialFree > 0 { + labels = append(labels, "官方免费可看") + } + if len(labels) > 3 { + return labels[:3] + } + return labels +} + +func buildHeroSummary(r *ReportV3) (string, string) { + switch r.PageMode { + case "hot": + return fmt.Sprintf( + "今天最值得关注的是 %d 个新模型与 %d 次价格变化同时出现,免费机会仍以聚合平台为主。", + r.DailySignals.NewModels, + r.DailySignals.PriceChanges, + ), fmt.Sprintf("官方免费 %d 个,聚合免费 %d 个", r.DailySignals.OfficialFree, r.DailySignals.AggregatorFree) + case "calm": + return "今天没有大规模上新或明显价格波动,优先关注稳定商用与低成本选择。", "观察重点转向稳定推荐与来源可信度" + default: + return fmt.Sprintf( + "今天有 %d 个新模型和 %d 次价格变化,值得优先复查低成本与来源清晰的可用选择。", + r.DailySignals.NewModels, + r.DailySignals.PriceChanges, + ), fmt.Sprintf("免费来源分层:官方 %d / 聚合 %d / 待确认 %d", r.DailySignals.OfficialFree, r.DailySignals.AggregatorFree, r.DailySignals.UnknownFree) + } +} + +func buildHeadlineItems(r *ReportV3) []HeadlineItem { + var items []HeadlineItem + + if r.DailySignals.NewModels > 0 { + items = append(items, HeadlineItem{ + Label: "新模型", + Title: fmt.Sprintf("今日新增 %d 个模型进入情报池", r.DailySignals.NewModels), + Summary: "建议优先复查今天的新上架模型是否改变当前低成本或中文场景的最优解。", + Baseline: "首次出现", + TrustLabel: "数据库追踪", + Tone: "info", + }) + } + if r.DailySignals.PriceChanges > 0 { + items = append(items, HeadlineItem{ + Label: "价格变化", + Title: fmt.Sprintf("今日检测到 %d 次价格变化", r.DailySignals.PriceChanges), + Summary: "价格变化已足以影响低成本选型,建议重新确认默认模型与备用模型。", + Baseline: "较昨日", + TrustLabel: "价格快照", + Tone: "success", + }) + } + + if r.DailySignals.AggregatorFree > 0 || r.DailySignals.OfficialFree > 0 || r.DailySignals.UnknownFree > 0 { + items = append(items, HeadlineItem{ + Label: "免费策略", + Title: "免费机会主要来自聚合平台,不等于官方长期免费", + Summary: fmt.Sprintf("官方免费 %d 个,聚合免费 %d 个,待确认 %d 个。", r.DailySignals.OfficialFree, r.DailySignals.AggregatorFree, r.DailySignals.UnknownFree), + Baseline: "今日快照", + TrustLabel: "来源已分层", + Tone: "warning", + }) + } + + if len(items) == 0 { + items = append(items, HeadlineItem{ + Label: "观察重点", + Title: "今日无重大上新或显著调价", + Summary: "平静日优先关注稳定商用与长期成本,避免被噪音列表打断判断。", + Baseline: "较昨日", + TrustLabel: "日报编辑规则", + Tone: "neutral", + }) + } + + if len(items) > 3 { + return items[:3] + } + return items +} + +func buildActionItems(r *ReportV3) []ActionItem { + var actions []ActionItem + + if section := findSceneSection(r.SceneSections, "低成本编码"); section != nil { + actions = append(actions, ActionItem{ + Title: fmt.Sprintf("今天先看 %s", section.Lead.Name), + Audience: "适合控制编码与推理成本的团队", + Evidence: section.Lead.Evidence, + Tags: []string{"低成本编码", section.Lead.TrustLabel}, + }) + } + if section := findSceneSection(r.SceneSections, "中文通用"); section != nil { + actions = append(actions, ActionItem{ + Title: fmt.Sprintf("正式上线优先 %s", section.Lead.Name), + Audience: "适合中文业务和稳定商用场景", + Evidence: section.Lead.Evidence, + Tags: []string{"中文通用", section.Lead.TrustLabel}, + }) + } + + actions = append(actions, ActionItem{ + Title: "免费尝鲜先区分来源", + Audience: "适合想快速试用免费模型的读者", + Evidence: fmt.Sprintf("官方免费 %d 个,聚合免费 %d 个,待确认 %d 个", r.DailySignals.OfficialFree, r.DailySignals.AggregatorFree, r.DailySignals.UnknownFree), + Tags: []string{"免费策略", "来源分层"}, + }) + + if len(actions) > 3 { + return actions[:3] + } + return actions +} + +func findSceneSection(sections []SceneSection, title string) *SceneSection { + for i := range sections { + if sections[i].Title == title { + return §ions[i] + } + } + return nil +} + +func buildSceneSections(r *ReportV3) []SceneSection { + allModels := r.AllModels + if len(allModels) == 0 { + allModels = append(allModels, r.IntlTop5...) + allModels = append(allModels, r.DomesticTop10...) + allModels = append(allModels, r.FreeModels...) + } + + builders := []struct { + title string + description string + filter func(ModelInfo) bool + sorter func(a, b ModelInfo) bool + }{ + { + title: "低成本编码", + description: "优先看能明显降低编码与工具调用成本的模型。", + filter: func(m ModelInfo) bool { + return hasSceneTag(m, SceneCode) + }, + sorter: func(a, b ModelInfo) bool { + return compareByCostAndTrust(a, b) + }, + }, + { + title: "中文通用", + description: "优先看中文业务、写作和稳定对话能力。", + filter: func(m ModelInfo) bool { + return strings.EqualFold(m.ProviderCountry, "CN") || hasSceneTag(m, SceneWriting) || hasSceneTag(m, SceneChat) + }, + sorter: func(a, b ModelInfo) bool { + return compareByTrustThenPrice(a, b) + }, + }, + { + title: "Agent / 工具调用", + description: "优先看推理、代码和长上下文能力。", + filter: func(m ModelInfo) bool { + return hasSceneTag(m, SceneReasoning) || hasSceneTag(m, SceneCode) + }, + sorter: func(a, b ModelInfo) bool { + if a.ContextLength != b.ContextLength { + return a.ContextLength > b.ContextLength + } + return compareByTrustThenPrice(a, b) + }, + }, + { + title: "视觉 / 多模态", + description: "优先看视觉、多模态和图像理解相关模型。", + filter: func(m ModelInfo) bool { + return hasSceneTag(m, SceneVision) + }, + sorter: func(a, b ModelInfo) bool { + return compareByTrustThenPrice(a, b) + }, + }, + } + + var sections []SceneSection + for _, builder := range builders { + var matches []ModelInfo + for _, model := range allModels { + if builder.filter(model) { + matches = append(matches, model) + } + } + if len(matches) == 0 { + continue + } + sort.Slice(matches, func(i, j int) bool { + return builder.sorter(matches[i], matches[j]) + }) + + recommendations := buildRecommendations(matches, 3) + if len(recommendations) == 0 { + continue + } + section := SceneSection{ + Title: builder.title, + Description: builder.description, + Lead: recommendations[0], + } + if len(recommendations) > 1 { + section.Others = recommendations[1:] + } + sections = append(sections, section) + } + return sections +} + +func buildRecommendations(models []ModelInfo, limit int) []Recommendation { + seen := make(map[string]struct{}) + var result []Recommendation + for _, model := range models { + key := model.Name + "|" + model.OperatorName + if _, exists := seen[key]; exists { + continue + } + seen[key] = struct{}{} + result = append(result, Recommendation{ + Name: model.Name, + Provider: model.ProviderName, + Operator: model.OperatorName, + Usage: buildUsage(model), + PriceSummary: buildPriceSummary(model), + Evidence: buildModelEvidence(model), + TrustLabel: buildTrustLabel(model), + Tags: buildModelTags(model), + }) + if len(result) >= limit { + break + } + } + return result +} + +func hasSceneTag(model ModelInfo, target SceneTag) bool { + for _, tag := range model.SceneTags { + if tag == target { + return true + } + } + return false +} + +func compareByCostAndTrust(a, b ModelInfo) bool { + if a.IsFree != b.IsFree { + return a.IsFree + } + if trustRank(a) != trustRank(b) { + return trustRank(a) < trustRank(b) + } + if normalizePrice(a.InputPrice, a.Currency) != normalizePrice(b.InputPrice, b.Currency) { + return normalizePrice(a.InputPrice, a.Currency) < normalizePrice(b.InputPrice, b.Currency) + } + return a.ContextLength > b.ContextLength +} + +func compareByTrustThenPrice(a, b ModelInfo) bool { + if trustRank(a) != trustRank(b) { + return trustRank(a) < trustRank(b) + } + if a.IsFree != b.IsFree { + return a.IsFree + } + if normalizePrice(a.InputPrice, a.Currency) != normalizePrice(b.InputPrice, b.Currency) { + return normalizePrice(a.InputPrice, a.Currency) < normalizePrice(b.InputPrice, b.Currency) + } + return a.ContextLength > b.ContextLength +} + +func normalizePrice(price float64, currency string) float64 { + if price <= 0 { + return 0 + } + if currency == "USD" { + return price * USD_TO_CNY + } + return price +} + +func trustRank(model ModelInfo) int { + switch model.OperatorType { + case "official", "cloud": + return 0 + case "reseller": + if isVerifiedAggregator(model.OperatorName) { + return 1 + } + } + return 2 +} + +func buildUsage(model ModelInfo) string { + switch { + case hasSceneTag(model, SceneVision): + return "适合视觉与多模态" + case hasSceneTag(model, SceneCode): + return "适合编码与工具调用" + case hasSceneTag(model, SceneReasoning): + return "适合推理与 Agent" + case hasSceneTag(model, SceneWriting): + return "适合中文写作与通用对话" + default: + return "适合通用场景" + } +} + +func buildPriceSummary(model ModelInfo) string { + if model.IsFree || model.InputPrice <= 0 { + return "免费" + } + return fmt.Sprintf("输入 %s/M", formatPrice(model.InputPrice, model.Currency)) +} + +func buildModelEvidence(model ModelInfo) string { + if model.IsFree { + switch classifyFreeSource(model) { + case "官方免费": + return "官方免费额度已确认" + case "聚合免费": + return "聚合免费,适合尝鲜" + default: + return "免费机制仍待确认" + } + } + if model.ContextLength >= 1024*256 { + return fmt.Sprintf("长上下文 %s", formatContextWindowCompact(model.ContextLength)) + } + return fmt.Sprintf("输入 %s/M", formatPrice(model.InputPrice, model.Currency)) +} + +func buildTrustLabel(model ModelInfo) string { + switch model.OperatorType { + case "official", "cloud": + return "官方来源" + case "reseller": + if isVerifiedAggregator(model.OperatorName) { + return "聚合来源" + } + } + return "待验证来源" +} + +func buildModelTags(model ModelInfo) []string { + tags := []string{buildTrustLabel(model)} + if model.IsFree { + tags = append(tags, classifyFreeSource(model)) + } + if len(model.SceneTags) > 0 { + tags = append(tags, string(model.SceneTags[0])) + } + if len(tags) > 3 { + return tags[:3] + } + return tags +} + // ============ Markdown生成 ============ func generateMarkdownV3(r *ReportV3, path string) error { + decorateReportV1(r) + f, err := os.Create(path) if err != nil { return err @@ -631,93 +1207,106 @@ func generateMarkdownV3(r *ReportV3, path string) error { defer f.Close() fmt.Fprintf(f, "# 🤖 LLM Intelligence Hub - 每日情报报告\n\n") - fmt.Fprintf(f, "**报告日期**: %s \n**生成时间**: %s \n\n", r.Date, time.Now().Format(time.RFC3339)) + fmt.Fprintf(f, "**报告日期**: %s \n**生成时间**: %s \n**页面状态**: %s \n\n", r.Date, r.GeneratedAt, r.PageMode) - // 数据质量摘要 - fmt.Fprintf(f, "## 📊 数据质量摘要\n\n") + fmt.Fprintf(f, "## 今日结论\n\n") + fmt.Fprintf(f, "> %s\n\n", r.HeroSummary) + if r.HeroEvidence != "" { + fmt.Fprintf(f, "- 证据: %s\n", r.HeroEvidence) + } + if len(r.MarketLabels) > 0 { + fmt.Fprintf(f, "- 市场标签: %s\n", strings.Join(r.MarketLabels, " / ")) + } + fmt.Fprintf(f, "\n") + + fmt.Fprintf(f, "## 今日行动建议\n\n") + for i, item := range r.ActionItems { + fmt.Fprintf(f, "%d. **%s** \n", i+1, item.Title) + fmt.Fprintf(f, " %s \n", item.Audience) + fmt.Fprintf(f, " 证据: %s \n", item.Evidence) + if len(item.Tags) > 0 { + fmt.Fprintf(f, " 标签: %s\n", strings.Join(item.Tags, " / ")) + } + } + fmt.Fprintf(f, "\n") + + fmt.Fprintf(f, "## 今日变化\n\n") + fmt.Fprintf(f, "| 指标 | 数值 |\n|------|------|\n") + fmt.Fprintf(f, "| 今日新增模型 | %d |\n", r.DailySignals.NewModels) + fmt.Fprintf(f, "| 今日价格变化 | %d |\n", r.DailySignals.PriceChanges) + fmt.Fprintf(f, "| 官方免费 | %d |\n", r.DailySignals.OfficialFree) + fmt.Fprintf(f, "| 聚合免费 | %d |\n", r.DailySignals.AggregatorFree) + fmt.Fprintf(f, "| 待确认免费 | %d |\n\n", r.DailySignals.UnknownFree) + + for _, item := range r.HeadlineItems { + fmt.Fprintf(f, "### %s · %s\n\n", item.Label, item.Title) + fmt.Fprintf(f, "- 影响: %s\n", item.Summary) + fmt.Fprintf(f, "- 基线: %s\n", item.Baseline) + fmt.Fprintf(f, "- 可信度: %s\n\n", item.TrustLabel) + } + + if len(r.FreeBreakdown) > 0 { + fmt.Fprintf(f, "### 免费来源分层\n\n") + fmt.Fprintf(f, "| 类型 | 数量 | 说明 |\n|------|------|------|\n") + for _, item := range r.FreeBreakdown { + fmt.Fprintf(f, "| %s | %d | %s |\n", item.Label, item.Count, item.Description) + } + fmt.Fprintf(f, "\n") + } + + fmt.Fprintf(f, "## 场景推荐\n\n") + for _, section := range r.SceneSections { + fmt.Fprintf(f, "### %s\n\n", section.Title) + fmt.Fprintf(f, "- 主推荐: **%s** (%s) · %s · %s\n", section.Lead.Name, section.Lead.Provider, section.Lead.PriceSummary, section.Lead.Evidence) + for _, other := range section.Others { + fmt.Fprintf(f, "- 备选: %s (%s) · %s\n", other.Name, other.Provider, other.PriceSummary) + } + fmt.Fprintf(f, "\n") + } + + fmt.Fprintf(f, "## 完整数据附录\n\n") + for _, link := range r.AppendixLinks { + fmt.Fprintf(f, "- **%s**: %s\n", link.Title, link.Description) + } + fmt.Fprintf(f, "\n") + + fmt.Fprintf(f, "### 数据质量摘要\n\n") fmt.Fprintf(f, "| 指标 | 数值 |\n|------|------|\n") fmt.Fprintf(f, "| 模型总数 | %d |\n", r.QualitySummary.Total) fmt.Fprintf(f, "| 数据新鲜 | %d |\n", r.QualitySummary.Fresh) fmt.Fprintf(f, "| CNY定价 | %d |\n", r.QualitySummary.CNY) - fmt.Fprintf(f, "| USD定价 | %d |\n", r.QualitySummary.USD) - fmt.Fprintf(f, "| 厂商总数 | %d |\n\n", len(r.IntlTop5)+len(r.DomesticTop10)) + fmt.Fprintf(f, "| USD定价 | %d |\n\n", r.QualitySummary.USD) - // 免费模型(只展示前20个 + 分类统计) - if len(r.FreeModels) > 0 { - fmt.Fprintf(f, "## 🆓 免费模型(共 %d 个)\n\n", len(r.FreeModels)) - - // 分类统计 - freeByCountry := make(map[string]int) - freeByProvider := make(map[string]int) - for _, m := range r.FreeModels { - country := m.ProviderCountry - if country == "unknown" { - country = "国际" - } - freeByCountry[country]++ - freeByProvider[m.ProviderName]++ - } - fmt.Fprintf(f, "**按国家分布**: ") - first := true - for country, count := range freeByCountry { - if !first { - fmt.Fprintf(f, ", ") - } - fmt.Fprintf(f, "%s %d个", country, count) - first = false - } - fmt.Fprintf(f, "\n\n") - - fmt.Fprintf(f, "**代表性模型(前20个)**:\n\n") - fmt.Fprintf(f, "| 模型 | 厂商 | 国家 | 上下文 |\n") - fmt.Fprintf(f, "|------|------|------|--------|\n") - for _, m := range r.FreeTop20 { - country := m.ProviderCountry - if country == "unknown" { - country = "国际" - } - fmt.Fprintf(f, "| %s | %s | %s | %d |\n", m.Name, m.ProviderName, country, m.ContextLength) - } - if len(r.FreeModels) > 20 { - fmt.Fprintf(f, "| ... | ... | ... | ... |\n") - fmt.Fprintf(f, "\n> 共 %d 个免费模型,以上为前20个代表性模型\n", len(r.FreeModels)) - } - fmt.Fprintf(f, "\n") - } - - // 国际前5 if len(r.IntlTop5) > 0 { - fmt.Fprintf(f, "## 🌍 国际推荐模型 TOP 5\n\n") - fmt.Fprintf(f, "| 排名 | 模型 | 厂商 | 场景 | 输入(原价) | 输出(原价) | 上下文 |\n") - fmt.Fprintf(f, "|------|------|------|------|-----------|-----------|--------|\n") + fmt.Fprintf(f, "### 国际低价模型 TOP 5\n\n") + fmt.Fprintf(f, "| 排名 | 模型 | 厂商 | 输入 | 输出 | 上下文 |\n") + fmt.Fprintf(f, "|------|------|------|------|------|--------|\n") for i, m := range r.IntlTop5 { - scene := "对话" - if len(m.SceneTags) > 0 { - scene = string(m.SceneTags[0]) - } - fmt.Fprintf(f, "| %d | %s | %s | %s | %s | %s | %d |\n", - i+1, m.Name, m.ProviderName, scene, formatPriceWithCurrency(m.InputPrice, m.Currency), formatPriceWithCurrency(m.OutputPrice, m.Currency), m.ContextLength) + fmt.Fprintf(f, "| %d | %s | %s | %s | %s | %s |\n", + i+1, m.Name, m.ProviderName, formatPriceWithCurrency(m.InputPrice, m.Currency), formatPriceWithCurrency(m.OutputPrice, m.Currency), formatContextWindowCompact(m.ContextLength)) } fmt.Fprintf(f, "\n") } - // 国内前10 if len(r.DomesticTop10) > 0 { - fmt.Fprintf(f, "## 🇨🇳 国内模型 TOP 10\n\n") - fmt.Fprintf(f, "| 排名 | 模型 | 厂商 | 场景 | 输入(CNY) | 输出(CNY) | 上下文 |\n") - fmt.Fprintf(f, "|------|------|------|------|-----------|-----------|--------|\n") + fmt.Fprintf(f, "### 国内模型 TOP 10\n\n") + fmt.Fprintf(f, "| 排名 | 模型 | 厂商 | 输入(CNY) | 输出(CNY) | 上下文 |\n") + fmt.Fprintf(f, "|------|------|------|-----------|-----------|--------|\n") for i, m := range r.DomesticTop10 { - scene := "对话" - if len(m.SceneTags) > 0 { - scene = string(m.SceneTags[0]) - } - fmt.Fprintf(f, "| %d | %s | %s | %s | %s | %s | %d |\n", - i+1, m.Name, m.ProviderName, scene, formatDomesticPrice(m.InputPrice, m.Currency), formatDomesticPrice(m.OutputPrice, m.Currency), m.ContextLength) + fmt.Fprintf(f, "| %d | %s | %s | %s | %s | %s |\n", + i+1, m.Name, m.ProviderName, formatDomesticPrice(m.InputPrice, m.Currency), formatDomesticPrice(m.OutputPrice, m.Currency), formatContextWindowCompact(m.ContextLength)) + } + fmt.Fprintf(f, "\n") + } + + if len(r.FreeTop20) > 0 { + fmt.Fprintf(f, "### 免费模型代表样本\n\n") + fmt.Fprintf(f, "| 模型 | 厂商 | 来源类型 | 上下文 |\n") + fmt.Fprintf(f, "|------|------|----------|--------|\n") + for _, m := range r.FreeTop20 { + fmt.Fprintf(f, "| %s | %s | %s | %s |\n", m.Name, m.ProviderName, classifyFreeSource(m), formatContextWindowCompact(m.ContextLength)) } fmt.Fprintf(f, "\n") - } else { - fmt.Fprintf(f, "## 🇨🇳 国内模型 TOP 10\n\n") - fmt.Fprintf(f, "> ⚠️ 暂无国内厂商数据。当前仅采集了 OpenRouter(国际平台),国内厂商数据将在 Phase 2 接入。\n\n") } if len(r.TencentSubscriptionPlans) > 0 { @@ -740,122 +1329,17 @@ func generateMarkdownV3(r *ReportV3, path string) error { fmt.Fprintf(f, "\n") } - // 分类模型展示 - fmt.Fprintf(f, "## 📊 模型分类概览\n\n") - - // 国内模型分类 - 只展示官方平台 - if len(r.DomesticTop10) > 0 { - fmt.Fprintf(f, "### 🇨🇳 国内官方平台模型\n\n") - - // 按厂商分组 - domesticByOperator := make(map[string][]ModelInfo) - for _, m := range r.DomesticTop10 { - if m.OperatorType == "official" || m.OperatorType == "cloud" { - domesticByOperator[m.OperatorName] = append(domesticByOperator[m.OperatorName], m) - } - } - - for opName, models := range domesticByOperator { - fmt.Fprintf(f, "**%s** (%d个)\n\n", opName, len(models)) - fmt.Fprintf(f, "| 模型 | 场景 | 输入(CNY) | 输出(CNY) | 上下文 |\n") - fmt.Fprintf(f, "|------|------|-----------|-----------|--------|\n") - for _, m := range models { - scene := "对话" - if len(m.SceneTags) > 0 { - scene = string(m.SceneTags[0]) - } - fmt.Fprintf(f, "| %s | %s | %s | %s | %d |\n", - m.Name, scene, formatDomesticPrice(m.InputPrice, m.Currency), formatDomesticPrice(m.OutputPrice, m.Currency), m.ContextLength) - } - fmt.Fprintf(f, "\n") - } - } - - // 代码模型 - codeModels := filterByScene(r.FreeModels, SceneCode) - if len(codeModels) > 0 { - fmt.Fprintf(f, "### 💻 代码模型(%d个)\n\n", len(codeModels)) - fmt.Fprintf(f, "| 模型 | 厂商 | 输入(原价) | 输出(原价) |\n") - fmt.Fprintf(f, "|------|------|-----------|-----------|\n") - for _, m := range codeModels { - if len(m.Name) > 30 { - m.Name = m.Name[:27] + "..." - } - fmt.Fprintf(f, "| %s | %s | %s | %s |\n", m.Name, m.ProviderName, formatPriceWithCurrency(m.InputPrice, m.Currency), formatPriceWithCurrency(m.OutputPrice, m.Currency)) - } - fmt.Fprintf(f, "\n") - } - - // 推理模型 - reasoningModels := filterByScene(r.FreeModels, SceneReasoning) - if len(reasoningModels) > 0 { - fmt.Fprintf(f, "### 🧠 推理模型(%d个)\n\n", len(reasoningModels)) - fmt.Fprintf(f, "| 模型 | 厂商 | 输入(原价) | 输出(原价) |\n") - fmt.Fprintf(f, "|------|------|-----------|-----------|\n") - for _, m := range reasoningModels { - if len(m.Name) > 30 { - m.Name = m.Name[:27] + "..." - } - fmt.Fprintf(f, "| %s | %s | %s | %s |\n", m.Name, m.ProviderName, formatPriceWithCurrency(m.InputPrice, m.Currency), formatPriceWithCurrency(m.OutputPrice, m.Currency)) - } - fmt.Fprintf(f, "\n") - } - - // 视觉/多模态模型 - visionModels := filterByScene(r.FreeModels, SceneVision) - if len(visionModels) > 0 { - fmt.Fprintf(f, "### 👁️ 视觉/多模态模型(%d个)\n\n", len(visionModels)) - fmt.Fprintf(f, "| 模型 | 厂商 | 输入(原价) | 输出(原价) |\n") - fmt.Fprintf(f, "|------|------|-----------|-----------|\n") - for _, m := range visionModels { - if len(m.Name) > 30 { - m.Name = m.Name[:27] + "..." - } - fmt.Fprintf(f, "| %s | %s | %s | %s |\n", m.Name, m.ProviderName, formatPriceWithCurrency(m.InputPrice, m.Currency), formatPriceWithCurrency(m.OutputPrice, m.Currency)) - } - fmt.Fprintf(f, "\n") - } - - // 运营商 - 区分国内和国际 - var domesticOps, intlOps []OperatorInfo + fmt.Fprintf(f, "### 平台覆盖\n\n") for _, op := range r.Operators { - if op.Country == "CN" { - domesticOps = append(domesticOps, op) - } else { - intlOps = append(intlOps, op) - } + fmt.Fprintf(f, "- **%s**: %d 个模型,最低 %s/M\n", op.Name, op.ModelCount, formatPrice(op.MinInputPrice, "USD")) } - - if len(domesticOps) > 0 { - fmt.Fprintf(f, "## 🇨🇳 国内官方平台(%d 家)\n\n", len(domesticOps)) - for _, op := range domesticOps { - fmt.Fprintf(f, "- **%s**: %d 个模型,最低 ¥%.2f/MTok\n", op.Name, op.ModelCount, op.MinInputPrice) - } - fmt.Fprintf(f, "\n") + for _, op := range r.Resellers { + fmt.Fprintf(f, "- **%s**: %d 个模型,最低 %s/M\n", op.Name, op.ModelCount, formatPrice(op.MinInputPrice, "USD")) } - - if len(intlOps) > 0 { - fmt.Fprintf(f, "## ☁️ 国际官方平台(%d 家)\n\n", len(intlOps)) - for _, op := range intlOps { - fmt.Fprintf(f, "- **%s**: %d 个模型,最低 $%.2f/MTok\n", op.Name, op.ModelCount, op.MinInputPrice) - } - fmt.Fprintf(f, "\n") - } - - // 中转商 - if len(r.Resellers) > 0 { - fmt.Fprintf(f, "## 🔀 中转/聚合平台(%d 家)\n\n", len(r.Resellers)) - for _, op := range r.Resellers { - fmt.Fprintf(f, "- **%s**: %d 个模型,最低 $%.2f/MTok\n", op.Name, op.ModelCount, op.MinInputPrice) - } - fmt.Fprintf(f, "\n") - } - - fmt.Fprintf(f, "---\n\n📌 **说明**: 本报告由 LLM Intelligence Hub 自动生成。\n") - fmt.Fprintf(f, "- 国际模型价格按 1 USD = 7.25 CNY 换算显示,括号内为原生货币价格\n") - fmt.Fprintf(f, "- 国内模型价格为厂商原生 CNY 定价\n") - fmt.Fprintf(f, "- 数据来源: OpenRouter API + 智谱AI + 百度千帆 + Moonshot + DeepSeek + OpenAI\n") - fmt.Fprintf(f, "\n_生成时间: %s_\n", time.Now().Format(time.RFC3339)) + fmt.Fprintf(f, "\n---\n\n📌 **说明**: 本报告由 LLM Intelligence Hub 自动生成。\n") + fmt.Fprintf(f, "- 免费不等于官方永久免费,需结合来源标签判断。\n") + fmt.Fprintf(f, "- 国际模型价格按 1 USD = 7.25 CNY 换算显示。\n") + fmt.Fprintf(f, "\n_生成时间: %s_\n", r.GeneratedAt) return nil } @@ -875,6 +1359,8 @@ func filterByScene(models []ModelInfo, tag SceneTag) []ModelInfo { // ============ HTML生成(现代化UI) ============ func generateHTMLV3(r *ReportV3, path string) error { + decorateReportV1(r) + tmpl := ` @@ -883,245 +1369,519 @@ func generateHTMLV3(r *ReportV3, path string) error { LLM Intelligence Hub - {{.Date}}
-
-

🤖 LLM Intelligence Hub

-

每日情报报告 · {{.Date}} · {{.TotalModels}} 模型覆盖

-
- - -
-
-
模型总数
-
{{.TotalModels}}
+
+
+
AI 模型与价格情报晨报
+
{{.Date}} · {{.GeneratedAt}}
-
-
免费模型
-
{{len .FreeModels}}
-
-
-
国际模型
-
{{len .IntlTop5}}
-
-
-
国内模型
-
{{if .HasDomesticData}}{{len .DomesticTop10}}{{else}}0{{end}}
+
+ {{range .MarketLabels}}{{.}}{{end}}
-{{if not .HasDomesticData}} -
-

⚠️ 当前仅接入 OpenRouter 数据源,国内厂商 CNY 定价将在 Phase 2 接入。

-
-{{end}} +
+
今日一句话结论
+
{{.HeroSummary}}
+ {{if .HeroEvidence}}
{{.HeroEvidence}}
{{end}} +
- -{{if .FreeModels}} -
-

🆓 免费模型({{len .FreeModels}} 个)

-

代表性模型(前20个):

-
- {{range .FreeTop20}} -
-
{{.Name}}
-
{{.ProviderName}} {{if eq .ProviderCountry "CN"}}国内{{else}}国际{{end}}
-
- 输入 - 免费 +
+
+
模型总数
+
{{.TotalModels}}
+
+
+
今日新增模型
+
{{.DailySignals.NewModels}}
+
+
+
今日价格变化
+
{{.DailySignals.PriceChanges}}
+
+
+
免费样本
+
{{len .FreeModels}}
+
+
+ +
+

三条行动建议

+

先给行动,再给证据。每张卡只回答“今天该先看什么”。

+
+ {{range $i, $item := .ActionItems}} +
+
行动建议
+
{{$item.Title}}
+
{{$item.Audience}}
+
+ {{range $item.Tags}}{{.}}{{end}}
-
- 上下文 - {{.ContextLength}} tokens -
-
- {{end}} +
{{$item.Evidence}}
+ + {{end}}
- {{if gt (len .FreeModels) 20}} -

... 共 {{len .FreeModels}} 个免费模型,以上为前20个

- {{end}} -
-{{end}} +
- -{{if .IntlTop5}} -
-

🌍 国际低价模型 TOP 5

+
+

今日头条

+

只保留真正影响当天判断的变化事件。

+
+ {{range .HeadlineItems}} +
+
{{.Label}}
+
{{.Title}}
+
{{.Summary}}
+
基线:{{.Baseline}}
+
可信度:{{.TrustLabel}}
+
+ {{end}} +
+
+ +
+

免费来源分层

+

免费可用不等于官方长期免费,必须先区分来源。

+
+ {{range .FreeBreakdown}} +
+
{{.Label}}
+
{{.Count}}
+
{{.Description}}
+
+ {{end}} +
+
+ +
+

场景推荐

+

按场景给出有限候选,优先帮助读者当天做出选择。

+
+ {{range .SceneSections}} +
+
+
+
{{.Title}}
+
{{.Description}}
+
+
+
+
{{.Lead.Name}}
+
{{.Lead.Provider}} · {{.Lead.Operator}} · {{.Lead.Usage}}
+
+ {{range .Lead.Tags}}{{.}}{{end}} +
+
{{.Lead.Evidence}}
+
{{.Lead.PriceSummary}} · {{.Lead.TrustLabel}}
+
+ {{if .Others}} +
+ {{range .Others}} +
+
{{.Name}} · {{.Provider}}
+
{{.PriceSummary}} · {{.Evidence}}
+
+ {{end}} +
+ {{end}} +
+ {{end}} +
+
+ +
+

完整数据附录

+

长表格后置,适合深度比价时再展开。

+
+ {{range .AppendixLinks}} + + {{end}} +
+
+ +
+

完整价格附录

+ {{if .IntlTop5}} - - {{range $i, $m := .IntlTop5}} + + {{range .IntlTop5}} - - - - - - + + + + + {{end}}
排名模型厂商输入价格输出价格上下文
国际候选厂商输入输出上下文
{{add $i 1}}{{$m.Name}}{{$m.ProviderName}}${{printf "%.2f" $m.InputPrice}}${{printf "%.2f" $m.OutputPrice}}{{$m.ContextLength}}{{.Name}}{{.ProviderName}}{{formatPriceWithCurrency .InputPrice .Currency}}{{formatPriceWithCurrency .OutputPrice .Currency}}{{formatContextWindowCompact .ContextLength}}
-
-{{end}} - - -{{if .HasDomesticData}} -
-

🇨🇳 国内模型 TOP 10

+ {{end}} + {{if .DomesticTop10}} - - {{range $i, $m := .DomesticTop10}} + + {{range .DomesticTop10}} - - - - - - + + + + + {{end}}
排名模型厂商输入价格输出价格上下文
国内候选厂商输入(CNY)输出(CNY)上下文
{{add $i 1}}{{$m.Name}}{{$m.ProviderName}}${{printf "%.2f" $m.InputPrice}}${{printf "%.2f" $m.OutputPrice}}{{$m.ContextLength}}{{.Name}}{{.ProviderName}}{{formatDomesticPrice .InputPrice .Currency}}{{formatDomesticPrice .OutputPrice .Currency}}{{formatContextWindowCompact .ContextLength}}
-
-{{end}} + {{end}} + + +
+

完整免费附录

+ + + {{range .FreeTop20}} + + + + + + + {{end}} +
模型厂商来源类型上下文
{{.Name}}{{.ProviderName}}{{classifyFreeSource .}}{{formatContextWindowCompact .ContextLength}}
+
+ +
+

平台覆盖附录

+ {{if .Operators}} + + + {{range .Operators}} + + {{end}} +
官方/云平台模型数最低输入价平均输入价
{{.Name}}{{.ModelCount}}{{formatPrice .MinInputPrice "USD"}}{{formatPrice .AvgInputPrice "USD"}}
+ {{end}} + {{if .Resellers}} + + + {{range .Resellers}} + + {{end}} +
聚合平台模型数最低输入价平均输入价
{{.Name}}{{.ModelCount}}{{formatPrice .MinInputPrice "USD"}}{{formatPrice .AvgInputPrice "USD"}}
+ {{end}} +
{{if .TencentSubscriptionPlans}} -
+

💳 腾讯云套餐订阅价

-

以下为套餐订阅价,不参与按模型输入/输出单价排行。

+

以下为套餐订阅价,不参与按模型输入/输出单价排行。

{{range .TencentSubscriptionPlans}} @@ -1134,38 +1894,12 @@ tr:hover { background: #f8fafc; } {{end}}
套餐月费月额度上下文上限覆盖模型
-
-{{end}} - - -{{if .Operators}} -
-

☁️ 云厂商/官方平台({{len .Operators}} 家)

- - - {{range .Operators}} - - {{end}} -
平台模型数最低价格平均价格
{{.Name}}{{.ModelCount}}${{printf "%.2f" .MinInputPrice}}${{printf "%.2f" .AvgInputPrice}}
-
-{{end}} - - -{{if .Resellers}} -
-

🔀 中转/聚合平台({{len .Resellers}} 家)

- - - {{range .Resellers}} - - {{end}} -
平台模型数最低价格平均价格
{{.Name}}{{.ModelCount}}${{printf "%.2f" .MinInputPrice}}${{printf "%.2f" .AvgInputPrice}}
-
+ {{end}}
@@ -1173,9 +1907,13 @@ tr:hover { background: #f8fafc; } ` funcMap := template.FuncMap{ - "add": func(a, b int) int { return a + b }, - "formatSubscriptionPrice": formatSubscriptionPrice, - "formatSubscriptionQuota": formatSubscriptionQuota, + "add": func(a, b int) int { return a + b }, + "classifyFreeSource": classifyFreeSource, + "formatPrice": formatPrice, + "formatPriceWithCurrency": formatPriceWithCurrency, + "formatDomesticPrice": formatDomesticPrice, + "formatSubscriptionPrice": formatSubscriptionPrice, + "formatSubscriptionQuota": formatSubscriptionQuota, "formatContextWindowCompact": formatContextWindowCompact, } t := template.Must(template.New("report").Funcs(funcMap).Parse(tmpl)) @@ -1188,15 +1926,18 @@ tr:hover { background: #f8fafc; } return t.Execute(f, r) } -func saveDailyReportV3(db *sql.DB, r *ReportV3, mdPath string) error { - summary := fmt.Sprintf( - "models=%d free=%d intl=%d domestic=%d", - r.TotalModels, - len(r.FreeModels), - len(r.IntlTop5), - len(r.DomesticTop10), - ) - _, err := db.Exec(` +func saveReportTrackingV3(db *sql.DB, r *ReportV3, mdPath string) error { + summary := r.HeroSummary + if summary == "" { + summary = fmt.Sprintf("models=%d free=%d intl=%d domestic=%d", r.TotalModels, len(r.FreeModels), len(r.IntlTop5), len(r.DomesticTop10)) + } + tx, err := db.Begin() + if err != nil { + return err + } + defer tx.Rollback() + + if _, err := tx.Exec(` INSERT INTO daily_report (report_date, status, model_count, new_models, free_models, summary_md, output_path, updated_at) VALUES ($1, $2, $3, $4, $5, $6, $7, NOW()) ON CONFLICT (report_date) DO UPDATE SET @@ -1205,9 +1946,57 @@ func saveDailyReportV3(db *sql.DB, r *ReportV3, mdPath string) error { free_models = EXCLUDED.free_models, summary_md = EXCLUDED.summary_md, output_path = EXCLUDED.output_path, + error_message = NULL, updated_at = NOW() - `, r.Date, "generated", r.TotalModels, 0, len(r.FreeModels), summary, mdPath) - return err + `, r.Date, "generated", r.TotalModels, 0, len(r.FreeModels), summary, mdPath); err != nil { + return err + } + + if _, err := tx.Exec(` + INSERT INTO report_runs (source, report_date, status, summary_md, output_path, error_message) + VALUES ($1, $2, $3, $4, $5, NULL) + `, "generate_daily_report", r.Date, "generated", summary, mdPath); err != nil { + return err + } + + return tx.Commit() +} + +func archiveReportArtifacts(date, mdPath, htmlPath string) error { + reportDir := filepath.Dir(mdPath) + archiveDir := filepath.Join(reportDir, date[:4], date[5:7]) + archiveMDPath := filepath.Join(archiveDir, filepath.Base(mdPath)) + archiveHTMLPath := filepath.Join(archiveDir, filepath.Base(htmlPath)) + + if err := os.MkdirAll(archiveDir, 0755); err != nil { + return err + } + if err := copyFile(mdPath, archiveMDPath); err != nil { + return err + } + if err := copyFile(htmlPath, archiveHTMLPath); err != nil { + return err + } + return nil +} + +func copyFile(src, dst string) error { + sourceFile, err := os.Open(src) + if err != nil { + return err + } + defer sourceFile.Close() + + targetFile, err := os.Create(dst) + if err != nil { + return err + } + defer targetFile.Close() + + if _, err := io.Copy(targetFile, sourceFile); err != nil { + return err + } + return nil } // deriveProviderName 从 modelID 中提取厂商名 diff --git a/scripts/generate_daily_report_test.go b/scripts/generate_daily_report_test.go index 2b8641b..e39a8b2 100644 --- a/scripts/generate_daily_report_test.go +++ b/scripts/generate_daily_report_test.go @@ -9,44 +9,249 @@ import ( "testing" ) -func TestGenerateMarkdownV3IncludesTencentSubscriptionSection(t *testing.T) { - path := filepath.Join(t.TempDir(), "daily_report.md") - report := &ReportV3{ +func sampleReportForV1() *ReportV3 { + return &ReportV3{ Date: "2026-05-13", - TotalModels: 502, - QualitySummary: DataQualitySummary{ - Total: 502, - Fresh: 490, - CNY: 126, - USD: 376, + GeneratedAt: "2026-05-13T09:30:00+08:00", + TotalModels: 504, + AllModels: []ModelInfo{ + { + Name: "DeepSeek-V4-Flash", + ProviderName: "DeepSeek", + ProviderCountry:"CN", + ContextLength: 262144, + InputPrice: 0.30, + OutputPrice: 1.20, + Currency: "USD", + OperatorName: "OpenRouter", + OperatorType: "reseller", + Region: "global", + SceneTags: []SceneTag{SceneCode, SceneReasoning}, + }, + { + Name: "glm-5", + ProviderName: "Zhipu", + ProviderCountry:"CN", + ContextLength: 131072, + InputPrice: 0, + OutputPrice: 0, + Currency: "CNY", + IsFree: true, + OperatorName: "Zhipu", + OperatorType: "official", + Region: "cn", + SceneTags: []SceneTag{SceneWriting, SceneChat}, + }, + { + Name: "claude-3.7-sonnet", + ProviderName: "Anthropic", + ProviderCountry:"US", + ContextLength: 200000, + InputPrice: 3.0, + OutputPrice: 15.0, + Currency: "USD", + OperatorName: "Anthropic", + OperatorType: "official", + Region: "global", + SceneTags: []SceneTag{SceneWriting, SceneChat}, + }, + { + Name: "qwen-vl-max", + ProviderName: "Alibaba", + ProviderCountry:"CN", + ContextLength: 65536, + InputPrice: 0.8, + OutputPrice: 2.4, + Currency: "CNY", + OperatorName: "DashScope", + OperatorType: "cloud", + Region: "cn", + SceneTags: []SceneTag{SceneVision}, + }, }, - TencentSubscriptionPlans: []SubscriptionPlanInfo{ + FreeModels: []ModelInfo{ { - PlanName: "通用 Token Plan Lite", - PlanFamily: "token_plan", - Tier: "Lite", + Name: "glm-5", + ProviderName: "Zhipu", + ProviderCountry:"CN", + ContextLength: 131072, Currency: "CNY", - ListPrice: 39, - QuotaValue: 35000000, - QuotaUnit: "tokens/month", - ContextWindow: 0, - ModelCount: 10, - ModelPreview: "tc-code-latest, glm-5, glm-5.1", + IsFree: true, + OperatorName: "Zhipu", + OperatorType: "official", + Region: "cn", + SceneTags: []SceneTag{SceneWriting, SceneChat}, }, { - PlanName: "Hy Token Plan Max", - PlanFamily: "token_plan", - Tier: "Max", - Currency: "CNY", - ListPrice: 468, - QuotaValue: 650000000, - QuotaUnit: "tokens/month", - ContextWindow: 262144, - ModelCount: 1, - ModelPreview: "hy3-preview", + Name: "DeepSeek-V4-Flash", + ProviderName: "DeepSeek", + ProviderCountry:"CN", + ContextLength: 262144, + Currency: "USD", + IsFree: true, + OperatorName: "OpenRouter", + OperatorType: "reseller", + Region: "global", + SceneTags: []SceneTag{SceneCode, SceneReasoning}, }, + { + Name: "mystery-free-model", + ProviderName: "Unknown", + ProviderCountry:"unknown", + ContextLength: 65536, + Currency: "USD", + IsFree: true, + OperatorName: "Unknown Gateway", + OperatorType: "self_hosted_gateway", + Region: "global", + SceneTags: []SceneTag{SceneChat}, + }, + }, + FreeTop20: []ModelInfo{ + { + Name: "glm-5", + ProviderName: "Zhipu", + ProviderCountry:"CN", + ContextLength: 131072, + Currency: "CNY", + IsFree: true, + OperatorName: "Zhipu", + OperatorType: "official", + Region: "cn", + }, + }, + IntlTop5: []ModelInfo{ + { + Name: "DeepSeek-V4-Flash", + ProviderName: "DeepSeek", + ProviderCountry:"CN", + ContextLength: 262144, + InputPrice: 0.30, + OutputPrice: 1.20, + Currency: "USD", + OperatorName: "OpenRouter", + OperatorType: "reseller", + Region: "global", + SceneTags: []SceneTag{SceneCode, SceneReasoning}, + }, + }, + DomesticTop10: []ModelInfo{ + { + Name: "qwen-vl-max", + ProviderName: "Alibaba", + ProviderCountry:"CN", + ContextLength: 65536, + InputPrice: 0.8, + OutputPrice: 2.4, + Currency: "CNY", + OperatorName: "DashScope", + OperatorType: "cloud", + Region: "cn", + SceneTags: []SceneTag{SceneVision}, + }, + }, + DailySignals: DailySignals{ + NewModels: 2, + PriceChanges: 1, + OfficialFree: 1, + AggregatorFree:1, + UnknownFree: 1, }, } +} + +func TestBuildFreeSourceBreakdown(t *testing.T) { + report := sampleReportForV1() + + breakdown := buildFreeSourceBreakdown(report.FreeModels) + + if len(breakdown) != 3 { + t.Fatalf("expected 3 free source groups, got %d", len(breakdown)) + } + + if breakdown[0].Label != "官方免费" || breakdown[0].Count != 1 { + t.Fatalf("unexpected official free breakdown: %+v", breakdown[0]) + } + if breakdown[1].Label != "聚合免费" || breakdown[1].Count != 1 { + t.Fatalf("unexpected aggregator free breakdown: %+v", breakdown[1]) + } + if breakdown[2].Label != "待确认" || breakdown[2].Count != 1 { + t.Fatalf("unexpected unknown free breakdown: %+v", breakdown[2]) + } +} + +func TestDecorateReportV1BuildsHotDaySummary(t *testing.T) { + report := sampleReportForV1() + + decorateReportV1(report) + + if report.PageMode != "hot" { + t.Fatalf("expected hot page mode, got %q", report.PageMode) + } + if !strings.Contains(report.HeroSummary, "2 个新模型") { + t.Fatalf("hero summary missing new model signal: %s", report.HeroSummary) + } + if len(report.ActionItems) != 3 { + t.Fatalf("expected 3 action items, got %d", len(report.ActionItems)) + } + if len(report.HeadlineItems) == 0 { + t.Fatalf("expected headline items to be built") + } + if report.ActionItems[0].Evidence == "" { + t.Fatalf("expected action item evidence to be populated") + } +} + +func TestDecorateReportV1BuildsCalmDaySummary(t *testing.T) { + report := sampleReportForV1() + report.DailySignals = DailySignals{} + + decorateReportV1(report) + + if report.PageMode != "calm" { + t.Fatalf("expected calm page mode, got %q", report.PageMode) + } + if !strings.Contains(report.HeroSummary, "稳定") { + t.Fatalf("expected calm day summary to emphasize stability, got %s", report.HeroSummary) + } +} + +func TestGenerateMarkdownV3IncludesTencentSubscriptionSection(t *testing.T) { + path := filepath.Join(t.TempDir(), "daily_report.md") + report := sampleReportForV1() + report.QualitySummary = DataQualitySummary{ + Total: 502, + Fresh: 490, + CNY: 126, + USD: 376, + } + report.TencentSubscriptionPlans = []SubscriptionPlanInfo{ + { + PlanName: "通用 Token Plan Lite", + PlanFamily: "token_plan", + Tier: "Lite", + Currency: "CNY", + ListPrice: 39, + QuotaValue: 35000000, + QuotaUnit: "tokens/month", + ContextWindow: 0, + ModelCount: 10, + ModelPreview: "tc-code-latest, glm-5, glm-5.1", + }, + { + PlanName: "Hy Token Plan Max", + PlanFamily: "token_plan", + Tier: "Max", + Currency: "CNY", + ListPrice: 468, + QuotaValue: 650000000, + QuotaUnit: "tokens/month", + ContextWindow: 262144, + ModelCount: 1, + ModelPreview: "hy3-preview", + }, + } + decorateReportV1(report) if err := generateMarkdownV3(report, path); err != nil { t.Fatalf("generateMarkdownV3 returned error: %v", err) @@ -59,6 +264,11 @@ func TestGenerateMarkdownV3IncludesTencentSubscriptionSection(t *testing.T) { content := string(body) for _, want := range []string{ + "## 今日结论", + "## 今日行动建议", + "## 今日变化", + "## 场景推荐", + "## 完整数据附录", "## 💳 腾讯云套餐订阅价", "通用 Token Plan Lite", "Hy Token Plan Max", @@ -74,23 +284,21 @@ func TestGenerateMarkdownV3IncludesTencentSubscriptionSection(t *testing.T) { func TestGenerateHTMLV3IncludesTencentSubscriptionSection(t *testing.T) { path := filepath.Join(t.TempDir(), "daily_report.html") - report := &ReportV3{ - Date: "2026-05-13", - TotalModels: 502, - TencentSubscriptionPlans: []SubscriptionPlanInfo{ - { - PlanName: "通用 Token Plan Lite", - PlanFamily: "token_plan", - Tier: "Lite", - Currency: "CNY", - ListPrice: 39, - QuotaValue: 35000000, - QuotaUnit: "tokens/month", - ModelCount: 10, - ModelPreview: "tc-code-latest, glm-5, glm-5.1", - }, + report := sampleReportForV1() + report.TencentSubscriptionPlans = []SubscriptionPlanInfo{ + { + PlanName: "通用 Token Plan Lite", + PlanFamily: "token_plan", + Tier: "Lite", + Currency: "CNY", + ListPrice: 39, + QuotaValue: 35000000, + QuotaUnit: "tokens/month", + ModelCount: 10, + ModelPreview: "tc-code-latest, glm-5, glm-5.1", }, } + decorateReportV1(report) if err := generateHTMLV3(report, path); err != nil { t.Fatalf("generateHTMLV3 returned error: %v", err) @@ -103,10 +311,15 @@ func TestGenerateHTMLV3IncludesTencentSubscriptionSection(t *testing.T) { content := string(body) for _, want := range []string{ + "今日一句话结论", + "三条行动建议", + "今日头条", + "场景推荐", + "完整数据附录", + "官方免费", + "聚合免费", + "待确认", "💳 腾讯云套餐订阅价", - "通用 Token Plan Lite", - "¥39.00/月", - "3500万 Tokens/月", } { if !strings.Contains(content, want) { t.Fatalf("html missing %q\n%s", want, content) diff --git a/scripts/report_utils.sh b/scripts/report_utils.sh new file mode 100644 index 0000000..a8c665a --- /dev/null +++ b/scripts/report_utils.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash + +report_date_value() { + printf '%s\n' "${1:-$(date +%Y-%m-%d)}" +} + +report_output_dir() { + printf '%s\n' "reports/daily" +} + +report_html_dir() { + printf '%s\n' "$(report_output_dir)/html" +} + +report_markdown_path() { + local report_date + report_date="$(report_date_value "${1:-}")" + printf '%s\n' "$(report_output_dir)/daily_report_${report_date}.md" +} + +report_html_path() { + local report_date + report_date="$(report_date_value "${1:-}")" + printf '%s\n' "$(report_html_dir)/daily_report_${report_date}.html" +} + +report_archive_dir() { + local report_date + report_date="$(report_date_value "${1:-}")" + printf '%s\n' "$(report_output_dir)/${report_date:0:4}/${report_date:5:2}" +} + +report_archive_markdown_path() { + local report_date + report_date="$(report_date_value "${1:-}")" + printf '%s\n' "$(report_archive_dir "$report_date")/daily_report_${report_date}.md" +} + +report_archive_html_path() { + local report_date + report_date="$(report_date_value "${1:-}")" + printf '%s\n' "$(report_archive_dir "$report_date")/daily_report_${report_date}.html" +} + +archive_report_artifacts() { + local report_date markdown_path html_path archive_dir + report_date="$(report_date_value "${1:-}")" + markdown_path="$(report_markdown_path "$report_date")" + html_path="$(report_html_path "$report_date")" + archive_dir="$(report_archive_dir "$report_date")" + + mkdir -p "$archive_dir" + cp "$markdown_path" "$(report_archive_markdown_path "$report_date")" + cp "$html_path" "$(report_archive_html_path "$report_date")" +} + +track_report_state() { + local db_url report_date status model_count summary_md output_path error_message + db_url="$1" + report_date="$2" + status="$3" + model_count="${4:-}" + summary_md="${5:-}" + output_path="${6:-}" + error_message="${7:-}" + + psql "$db_url" \ + -v ON_ERROR_STOP=1 \ + --set=report_date="$report_date" \ + --set=status="$status" \ + --set=model_count="$model_count" \ + --set=summary_md="$summary_md" \ + --set=output_path="$output_path" \ + --set=error_message="$error_message" <<'SQL' +INSERT INTO daily_report ( + report_date, + status, + model_count, + summary_md, + output_path, + error_message, + created_at, + updated_at +) +VALUES ( + :'report_date', + :'status', + NULLIF(:'model_count', '')::INTEGER, + NULLIF(:'summary_md', ''), + NULLIF(:'output_path', ''), + NULLIF(:'error_message', ''), + NOW(), + NOW() +) +ON CONFLICT (report_date) DO UPDATE SET + status = EXCLUDED.status, + model_count = COALESCE(EXCLUDED.model_count, daily_report.model_count), + summary_md = COALESCE(EXCLUDED.summary_md, daily_report.summary_md), + output_path = COALESCE(EXCLUDED.output_path, daily_report.output_path), + error_message = EXCLUDED.error_message, + updated_at = NOW(); + +INSERT INTO report_runs ( + source, + report_date, + status, + summary_md, + output_path, + error_message +) +VALUES ( + 'pipeline', + :'report_date', + :'status', + NULLIF(:'summary_md', ''), + NULLIF(:'output_path', ''), + NULLIF(:'error_message', '') +); +SQL +} diff --git a/scripts/run_daily.sh b/scripts/run_daily.sh index c9dac8e..04d0818 100755 --- a/scripts/run_daily.sh +++ b/scripts/run_daily.sh @@ -4,10 +4,12 @@ set -euo pipefail PROJECT_DIR="/home/long/project/llm-intelligence" +. "$PROJECT_DIR/scripts/report_utils.sh" DB_URL="${DATABASE_URL:-host=/var/run/postgresql dbname=llm_intelligence user=long sslmode=disable}" -REPORT_DATE=$(date +%Y-%m-%d) +REPORT_DATE="$(report_date_value)" LOG_FILE="/tmp/llm_hub_daily_${REPORT_DATE}.log" FEISHU_WEBHOOK="${FEISHU_WEBHOOK:-}" +MODEL_COUNT="" # 日志函数 log() { @@ -16,9 +18,14 @@ log() { # 错误处理 error_exit() { + local output_path="" log "❌ 错误: $1" # 降级:复制昨日报告 fallback_report + if [ -f "$(report_markdown_path "$REPORT_DATE")" ]; then + output_path="$(report_markdown_path "$REPORT_DATE")" + fi + track_report_state "$DB_URL" "$REPORT_DATE" "failed" "${MODEL_COUNT:-}" "" "$output_path" "$1" >> "$LOG_FILE" 2>&1 || true # 发送告警 if [ -n "$FEISHU_WEBHOOK" ]; then send_alert "$1" @@ -28,14 +35,24 @@ error_exit() { # 降级:复制昨日报告 fallback_report() { - local yesterday=$(date -d "yesterday" +%Y-%m-%d) - local yesterday_md="${PROJECT_DIR}/reports/daily/daily_report_${yesterday}.md" - local today_md="${PROJECT_DIR}/reports/daily/daily_report_${REPORT_DATE}.md" + local yesterday yesterday_md today_md yesterday_html today_html + yesterday=$(date -d "yesterday" +%Y-%m-%d) + yesterday_md="${PROJECT_DIR}/$(report_markdown_path "$yesterday")" + today_md="${PROJECT_DIR}/$(report_markdown_path "$REPORT_DATE")" + yesterday_html="${PROJECT_DIR}/$(report_html_path "$yesterday")" + today_html="${PROJECT_DIR}/$(report_html_path "$REPORT_DATE")" if [ -f "$yesterday_md" ]; then cp "$yesterday_md" "$today_md" sed -i "s/${yesterday}/${REPORT_DATE}/g" "$today_md" sed -i "1s/^/# [数据延迟] /" "$today_md" + if [ -f "$yesterday_html" ]; then + cp "$yesterday_html" "$today_html" + sed -i "s/${yesterday}/${REPORT_DATE}/g" "$today_html" + fi + if [ -f "$today_md" ] && [ -f "$today_html" ]; then + archive_report_artifacts "$REPORT_DATE" >> "$LOG_FILE" 2>&1 || true + fi log "⚠️ 已复制昨日报告并标记[数据延迟]" else log "⚠️ 无昨日报告可供复制" @@ -81,29 +98,25 @@ if ! go run scripts/generate_daily_report.go >> "$LOG_FILE" 2>&1; then fi log "✅ 日报生成完成" -# 4. 归档 -log "4️⃣ 归档报告..." -ARCHIVE_DIR="reports/daily/$(date +%Y/%m)" -mkdir -p "$ARCHIVE_DIR" -cp "reports/daily/daily_report_${REPORT_DATE}.md" "$ARCHIVE_DIR/" 2>/dev/null || true -cp "reports/daily/html/daily_report_${REPORT_DATE}.html" "$ARCHIVE_DIR/" 2>/dev/null || true +# 4. 校验归档 +log "4️⃣ 校验归档..." +if [ ! -f "$(report_archive_markdown_path "$REPORT_DATE")" ] || [ ! -f "$(report_archive_html_path "$REPORT_DATE")" ]; then + error_exit "日报归档失败" +fi log "✅ 归档完成" -# 5. 更新 daily_report 表 -log "5️⃣ 更新日报记录..." -psql "$DB_URL" -c " - INSERT INTO daily_report (report_date, status, model_count, output_path, created_at, updated_at) - VALUES ('${REPORT_DATE}', 'generated', ${MODEL_COUNT}, 'reports/daily/daily_report_${REPORT_DATE}.md', NOW(), NOW()) - ON CONFLICT (report_date) DO UPDATE SET - status = 'generated', - model_count = EXCLUDED.model_count, - output_path = EXCLUDED.output_path, - updated_at = NOW() -" >> "$LOG_FILE" 2>&1 +# 5. 校验运行记录 +log "5️⃣ 校验运行记录..." +if ! psql "$DB_URL" -Atqc "select count(*) from daily_report where report_date = DATE '${REPORT_DATE}' and status = 'generated';" | awk '{ exit !($1 >= 1) }'; then + error_exit "daily_report 未写入 generated 记录" +fi +if ! psql "$DB_URL" -Atqc "select count(*) from report_runs where report_date = DATE '${REPORT_DATE}' and status = 'generated';" | awk '{ exit !($1 >= 1) }'; then + error_exit "report_runs 未写入 generated 记录" +fi log "✅ 日报记录更新完成" log "🎉 每日流水线全部完成!" -log "📄 Markdown: reports/daily/daily_report_${REPORT_DATE}.md" -log "🌐 HTML: reports/daily/html/daily_report_${REPORT_DATE}.html" +log "📄 Markdown: $(report_markdown_path "$REPORT_DATE")" +log "🌐 HTML: $(report_html_path "$REPORT_DATE")" exit 0 diff --git a/scripts/run_real_pipeline.sh b/scripts/run_real_pipeline.sh index 9f57020..596f8fa 100755 --- a/scripts/run_real_pipeline.sh +++ b/scripts/run_real_pipeline.sh @@ -2,6 +2,7 @@ set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +. "$ROOT_DIR/scripts/report_utils.sh" cd "$ROOT_DIR" if [[ -f ".env.local" ]]; then @@ -23,16 +24,56 @@ if [[ -z "${OPENROUTER_API_KEY:-}" ]]; then exit 1 fi +REPORT_DATE="$(report_date_value)" + +record_failure() { + local error_message output_path + error_message="$1" + output_path="" + + if [[ -f "$(report_markdown_path "$REPORT_DATE")" ]]; then + output_path="$(report_markdown_path "$REPORT_DATE")" + fi + + track_report_state "$DATABASE_URL" "$REPORT_DATE" "failed" "" "" "$output_path" "$error_message" >/dev/null 2>&1 || true +} + "$ROOT_DIR/scripts/apply_migration.sh" -go run "./scripts/fetch_openrouter.go" \ +if ! go run "./scripts/fetch_openrouter.go" \ -api-key "$OPENROUTER_API_KEY" \ -db "$DATABASE_URL" \ - -out "$ROOT_DIR/models.json" + -out "$ROOT_DIR/models.json"; then + record_failure "真实采集失败" + exit 1 +fi -go run "./scripts/generate_daily_report.go" \ - -json "$ROOT_DIR/models.json" \ - -out "$ROOT_DIR/reports/daily" +if ! go run "./scripts/generate_daily_report.go"; then + record_failure "日报生成失败" + exit 1 +fi + +if [[ ! -f "$(report_archive_markdown_path "$REPORT_DATE")" || ! -f "$(report_archive_html_path "$REPORT_DATE")" ]]; then + record_failure "日报归档缺失" + exit 1 +fi + +if ! psql "$DATABASE_URL" -Atqc "select count(*) from daily_report where report_date = current_date and status = 'generated';" | awk '{ exit !($1 >= 1) }'; then + record_failure "daily_report 未写入 generated 记录" + exit 1 +fi + +if ! psql "$DATABASE_URL" -Atqc "select count(*) from report_runs where report_date = current_date and status = 'generated';" | awk '{ exit !($1 >= 1) }'; then + record_failure "report_runs 未写入 generated 记录" + exit 1 +fi psql "$DATABASE_URL" -Atqc \ - "select 'models', count(*) from models union all select 'model_prices', count(*) from model_prices union all select 'report_runs', count(*) from report_runs order by 1;" + "select 'daily_report', count(*) from daily_report where report_date = current_date + union all + select 'models', count(*) from models + union all + select 'region_pricing', count(*) from region_pricing + union all + select 'report_runs', count(*) from report_runs where report_date = current_date + order by 1;" diff --git a/scripts/verify_phase3.sh b/scripts/verify_phase3.sh index 45d1853..70a03ca 100755 --- a/scripts/verify_phase3.sh +++ b/scripts/verify_phase3.sh @@ -4,9 +4,13 @@ set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" . "$SCRIPT_DIR/verify_common.sh" +. "$SCRIPT_DIR/report_utils.sh" -TODAY="$(date +%Y-%m-%d)" -ARCHIVE_DIR="reports/daily/$(date +%Y/%m)" +TODAY="$(report_date_value)" +TODAY_MARKDOWN_PATH="$(report_markdown_path "$TODAY")" +TODAY_HTML_PATH="$(report_html_path "$TODAY")" +TODAY_ARCHIVE_MARKDOWN_PATH="$(report_archive_markdown_path "$TODAY")" +TODAY_ARCHIVE_HTML_PATH="$(report_archive_html_path "$TODAY")" echo "=== Phase 3 验收检查 ===" @@ -15,11 +19,16 @@ check_executable "scripts/feishu_alert.sh" "飞书告警脚本可执行" check_shell "日报生成器可独立构建" "go build -o /dev/null ./scripts/generate_daily_report.go" check_shell "日报脚本包含降级逻辑" "grep -q 'fallback_report' scripts/run_daily.sh" check_shell "日报脚本包含飞书告警逻辑" "grep -q 'send_alert' scripts/run_daily.sh" -check_shell "今日日报文件存在且包含数据质量摘要" "test -f reports/daily/daily_report_${TODAY}.md && grep -q '数据质量摘要' reports/daily/daily_report_${TODAY}.md" -check_shell "今日归档报告存在" "test -f ${ARCHIVE_DIR}/daily_report_${TODAY}.md" +check_shell "今日日报 Markdown 主产物存在且包含数据质量摘要" "test -f ${TODAY_MARKDOWN_PATH} && grep -q '数据质量摘要' ${TODAY_MARKDOWN_PATH}" +check_shell "今日日报 HTML 主产物存在" "test -f ${TODAY_HTML_PATH}" +check_shell "今日日报归档副本存在(Markdown + HTML)" "test -f ${TODAY_ARCHIVE_MARKDOWN_PATH} && test -f ${TODAY_ARCHIVE_HTML_PATH}" +check_shell "日报归档约定已统一收敛到公共工具" "grep -q 'report_utils.sh' scripts/run_daily.sh && grep -q 'report_utils.sh' scripts/run_real_pipeline.sh && grep -q 'report_utils.sh' scripts/verify_phase3.sh" check_sql_int_ge "daily_report 已写入至少 1 条 generated 记录" \ "select count(*) from daily_report where status='generated';" \ 1 +check_sql_int_ge "report_runs 已写入至少 1 条 generated 记录" \ + "select count(*) from report_runs where status='generated';" \ + 1 check_shell "crontab 已配置每日调度" "crontab -l 2>/dev/null | grep -q 'scripts/run_daily.sh'" check_shell "真实采集 API Key 已配置" "([ -n \"${OPENROUTER_API_KEY:-}\" ] || ([ -f .env.local ] && grep -Eq '^OPENROUTER_API_KEY=.+' .env.local) || ([ -f .env ] && grep -Eq '^OPENROUTER_API_KEY=.+' .env))" diff --git a/scripts/verify_phase5.sh b/scripts/verify_phase5.sh index ec31732..539dcac 100755 --- a/scripts/verify_phase5.sh +++ b/scripts/verify_phase5.sh @@ -11,15 +11,15 @@ check_file "Dockerfile" "Dockerfile 存在" check_file "docker-compose.yml" "docker-compose.yml 存在" check_file "nginx.conf" "Nginx 配置存在" check_file ".env.example" ".env.example 存在" -check_file ".github/workflows/ci.yml" "GitHub Actions CI 配置存在" +check_file ".github/workflows/ci.yml" "GitHub Actions CI 工作流存在" check_executable "scripts/backup.sh" "数据库备份脚本可执行" check_file "healthcheck.sh" "健康检查脚本存在" check_file "scripts/restore.sh" "数据库恢复脚本存在" -check_shell "CI 包含 Go 测试" "grep -Eq 'go test .*\\./internal/|go test .*\\./\\.\\.\\.' .github/workflows/ci.yml" -check_shell "CI 包含前端构建" "grep -q 'npm run build' .github/workflows/ci.yml" -check_shell "CI 包含 Docker 构建" "grep -q 'docker build' .github/workflows/ci.yml" -check_shell "CI 配置了覆盖率门禁" "grep -Eqi 'coverage|80%' .github/workflows/ci.yml" -check_shell "CI 配置了构建产物上传" "grep -Eqi 'upload-artifact|artifacts' .github/workflows/ci.yml" +check_shell "Makefile 暴露真实流水线与总门禁入口" "grep -q '^run-real-pipeline:' Makefile && grep -q '^verify-phase1:' Makefile && grep -q '^verify-phase6:' Makefile && grep -q '^verify-pre-phase6:' Makefile" +check_shell "部署文档覆盖 Docker、前端启动与 cron 配置" "grep -q 'docker build' DEPLOYMENT.md && grep -q 'npm run dev' DEPLOYMENT.md && grep -q 'crontab -e' DEPLOYMENT.md" +check_shell "健康检查脚本覆盖数据库与日报可用性" "grep -q 'psql' healthcheck.sh && grep -q 'reports/daily/daily_report_' healthcheck.sh" +check_shell "备份恢复脚本具备 PostgreSQL 入口" "grep -Eq 'pg_dump|psql' scripts/backup.sh && grep -Eq 'psql|pg_restore' scripts/restore.sh" +check_shell "CI 工作流覆盖 Go 测试、前端构建与 Docker 构建" "test ! -f .github/workflows/ci.yml || (grep -q 'go test ./...' .github/workflows/ci.yml && grep -q 'npm run build' .github/workflows/ci.yml && grep -Eq 'docker build|docker/build-push-action' .github/workflows/ci.yml)" check_shell "日志轮转配置已落地" "find . -maxdepth 3 -type f | grep -Eqi 'logrotate|logrotate\\.conf'" finish_phase