Files
llm-intelligence/scripts/generate_daily_report.go

2023 lines
54 KiB
Go
Raw Normal View History

//go:build llm_script
// generate_daily_report.go v3.0 - 日报生成器(现代化UI版)
// 支持:国家分类、运营商分类、信息图风格HTML
package main
import (
"database/sql"
"encoding/json"
"fmt"
"html/template"
"io"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"time"
_ "github.com/lib/pq"
)
var logger *slog.Logger
func init() {
logger = slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
}
func main() {
loadProjectEnv()
if err := run(); err != nil {
logger.Error("日报生成失败", "error", err)
os.Exit(1)
}
logger.Info("日报生成完成")
}
func loadProjectEnv() {
for _, path := range []string{".env.local", ".env"} {
loadEnvFile(path)
}
}
func loadEnvFile(path string) {
f, err := os.Open(path)
if err != nil {
return
}
defer f.Close()
buf := make([]byte, 4096)
n, _ := f.Read(buf)
content := string(buf[:n])
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
value = strings.Trim(value, `"'`)
if key == "" {
continue
}
if _, exists := os.LookupEnv(key); exists {
continue
}
_ = os.Setenv(key, value)
}
}
func run() error {
dbConn := os.Getenv("DATABASE_URL")
if dbConn == "" {
return fmt.Errorf("DATABASE_URL 未设置")
}
db, err := sql.Open("postgres", dbConn)
if err != nil {
return fmt.Errorf("连接数据库失败: %w", err)
}
defer db.Close()
date := time.Now().Format("2006-01-02")
// 1. 获取报告数据(使用新schema)
report, err := generateReportDataV3(db, date)
if err != nil {
return fmt.Errorf("生成报告数据失败: %w", err)
}
// 2. 创建目录
outDir := os.Getenv("REPORT_OUTPUT_DIR")
if outDir == "" {
outDir = "reports/daily"
}
os.MkdirAll(outDir, 0755)
os.MkdirAll(outDir+"/html", 0755)
// 3. 生成 Markdown
mdPath := filepath.Join(outDir, fmt.Sprintf("daily_report_%s.md", date))
if err := generateMarkdownV3(report, mdPath); err != nil {
return err
}
// 4. 生成 HTML(现代化UI)
htmlPath := filepath.Join(outDir, "html", fmt.Sprintf("daily_report_%s.html", date))
if err := generateHTMLV3(report, htmlPath); err != nil {
return err
}
// 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)
}
logger.Info("日报生成完成",
"models", report.TotalModels,
"free", len(report.FreeModels),
"intl", len(report.IntlTop5),
"domestic", len(report.DomesticTop10),
"md", mdPath,
"html", htmlPath)
return nil
}
// ============ 数据模型 ============
const (
USD_TO_CNY = 7.25 // USD 转 CNY 汇率
)
type ModelInfo struct {
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
}
type ReportV3 struct {
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
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 {
Name, Type, Country string
ModelCount int
AvgInputPrice float64
MinInputPrice float64
}
type DataQualitySummary struct {
Total, Fresh, Stale, CNY, USD int
}
type SubscriptionPlanInfo struct {
PlanName string
PlanFamily string
Tier string
Currency string
ListPrice float64
QuotaValue int64
QuotaUnit string
ContextWindow int
ModelCount int
ModelPreview string
SourceURL string
}
// ============ 数据查询(新Schema) ============
func generateReportDataV3(db *sql.DB, date string) (*ReportV3, error) {
// 查询模型+厂商+定价+运营商信息
rows, err := db.Query(`
WITH latest_prices AS (
SELECT
rp.model_id,
rp.input_price_per_mtok,
rp.output_price_per_mtok,
rp.currency,
rp.region,
rp.is_free,
o.name as operator_name,
COALESCE(o.name_cn, o.name) as operator_name_cn,
COALESCE(o.type, 'reseller') as operator_type,
ROW_NUMBER() OVER (
PARTITION BY rp.model_id
ORDER BY rp.effective_date DESC NULLS LAST, rp.id DESC
) AS rn
FROM region_pricing rp
LEFT JOIN operator o ON rp.operator_id = o.id
)
SELECT
m.external_id,
COALESCE(NULLIF(m.name, ''), m.external_id) as name,
COALESCE(mp.name, split_part(m.external_id, '/', 1)) as provider_name,
COALESCE(mp.country, 'unknown') as provider_country,
COALESCE(m.context_length, 0),
m.modality,
COALESCE(lp.input_price_per_mtok, 0),
COALESCE(lp.output_price_per_mtok, 0),
COALESCE(lp.currency, 'USD'),
COALESCE(lp.is_free, false),
COALESCE(lp.operator_name, 'OpenRouter'),
COALESCE(lp.operator_type, 'reseller'),
COALESCE(lp.region, 'global')
FROM models m
LEFT JOIN model_provider mp ON m.provider_id = mp.id
LEFT JOIN latest_prices lp ON lp.model_id = m.id AND lp.rn = 1
WHERE m.deleted_at IS NULL
ORDER BY m.id
`)
if err != nil {
return nil, err
}
defer rows.Close()
var allModels []ModelInfo
var freeModels []ModelInfo
var intlModels []ModelInfo // 国际模型(US/EU/unknown)
var domesticModels []ModelInfo // 国内模型(CN)
providerSet := make(map[string]struct{})
operatorSet := make(map[string]OperatorInfo)
for rows.Next() {
var m ModelInfo
if err := rows.Scan(
&m.ID, &m.Name, &m.ProviderName, &m.ProviderCountry,
&m.ContextLength, &m.Modality,
&m.InputPrice, &m.OutputPrice,
&m.Currency, &m.IsFree,
&m.OperatorName, &m.OperatorType, &m.Region,
); err != nil {
logger.Warn("扫描模型数据失败", "error", err)
continue
}
m.SceneTags = deriveSceneTags(m.Name, m.Modality, nil)
allModels = append(allModels, m)
if m.IsFree {
freeModels = append(freeModels, m)
}
// 国家分类 - 国内官方平台 vs OpenRouter上的国内模型
isDomesticOfficial := (m.ProviderCountry == "CN" || m.ProviderCountry == "cn") &&
(m.OperatorType == "official" || m.OperatorType == "cloud")
isDomesticReseller := (m.ProviderCountry == "CN" || m.ProviderCountry == "cn") &&
m.OperatorType == "reseller"
if isDomesticOfficial {
domesticModels = append(domesticModels, m)
} else if isDomesticReseller {
// OpenRouter上的国内模型归入国际分类但标记
intlModels = append(intlModels, m)
} else {
intlModels = append(intlModels, m)
}
providerSet[m.ProviderName] = struct{}{}
// 统计运营商
op := operatorSet[m.OperatorName]
op.Name = m.OperatorName
op.Type = m.OperatorType
op.Country = m.ProviderCountry
op.ModelCount++
if op.MinInputPrice == 0 || m.InputPrice < op.MinInputPrice {
op.MinInputPrice = m.InputPrice
}
op.AvgInputPrice = (op.AvgInputPrice*float64(op.ModelCount-1) + m.InputPrice) / float64(op.ModelCount)
operatorSet[m.OperatorName] = op
}
// 排序
sort.Slice(intlModels, func(i, j int) bool {
if intlModels[i].IsFree != intlModels[j].IsFree {
return intlModels[i].IsFree
}
return intlModels[i].InputPrice < intlModels[j].InputPrice
})
sort.Slice(domesticModels, func(i, j int) bool {
if domesticModels[i].IsFree != domesticModels[j].IsFree {
return domesticModels[i].IsFree
}
return domesticModels[i].InputPrice < domesticModels[j].InputPrice
})
sort.Slice(freeModels, func(i, j int) bool {
return freeModels[i].ContextLength > freeModels[j].ContextLength
})
// 提取TOP - 国际排除免费,国内包含免费(展示真实低价+免费精选)
var intlTop5 []ModelInfo
intlPaid := filterPaid(intlModels)
if len(intlPaid) > 5 {
intlTop5 = intlPaid[:5]
} else {
intlTop5 = intlPaid
}
var domesticTop10 []ModelInfo
// 国内模型:优先展示付费低价,然后补充免费模型
domesticPaid := filterPaid(domesticModels)
domesticTop10 = append(domesticTop10, domesticPaid...)
// 补充免费国内模型(按上下文排序)
var domesticFree []ModelInfo
for _, m := range domesticModels {
if m.IsFree {
domesticFree = append(domesticFree, m)
}
}
sort.Slice(domesticFree, func(i, j int) bool {
return domesticFree[i].ContextLength > domesticFree[j].ContextLength
})
for _, m := range domesticFree {
if len(domesticTop10) >= 10 {
break
}
domesticTop10 = append(domesticTop10, m)
}
// 免费模型只展示前20个 + 分类统计
var freeTop20 []ModelInfo
if len(freeModels) > 20 {
freeTop20 = freeModels[:20]
} else {
freeTop20 = freeModels
}
// 如果付费不足,用免费模型补充"推荐"
if len(intlTop5) == 0 && len(intlModels) > 0 {
if len(intlModels) > 5 {
intlTop5 = intlModels[:5]
} else {
intlTop5 = intlModels
}
}
if len(domesticTop10) == 0 && len(domesticModels) > 0 {
if len(domesticModels) > 10 {
domesticTop10 = domesticModels[:10]
} else {
domesticTop10 = domesticModels
}
}
// 运营商分类
var operators, resellers []OperatorInfo
for _, op := range operatorSet {
if op.Type == "cloud" || op.Type == "official" {
operators = append(operators, op)
} else {
resellers = append(resellers, op)
}
}
// 数据质量统计
var fresh, stale, cny, usd int
for _, m := range allModels {
if m.InputPrice > 0 || m.IsFree {
fresh++
} else {
stale++
}
if m.Currency == "CNY" {
cny++
} else if m.Currency == "USD" {
usd++
}
}
tencentPlans, err := loadTencentSubscriptionPlans(db)
if err != nil {
return nil, err
}
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,
QualitySummary: DataQualitySummary{
Total: len(allModels),
Fresh: fresh,
Stale: stale,
CNY: cny,
USD: usd,
},
}
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) {
rows, err := db.Query(`
SELECT
sp.plan_name,
sp.plan_family,
sp.tier,
sp.currency,
sp.list_price,
COALESCE(sp.quota_value, 0),
COALESCE(sp.quota_unit, ''),
COALESCE(sp.context_window, 0),
COALESCE(sp.model_scope, '[]'),
COALESCE(sp.source_url, '')
FROM subscription_plan sp
JOIN model_provider mp ON mp.id = sp.provider_id
WHERE mp.name = 'Tencent'
ORDER BY sp.list_price ASC, sp.plan_name ASC
`)
if err != nil {
if strings.Contains(err.Error(), `relation "subscription_plan" does not exist`) {
return nil, nil
}
return nil, err
}
defer rows.Close()
var plans []SubscriptionPlanInfo
for rows.Next() {
var plan SubscriptionPlanInfo
var modelScopeRaw string
if err := rows.Scan(
&plan.PlanName,
&plan.PlanFamily,
&plan.Tier,
&plan.Currency,
&plan.ListPrice,
&plan.QuotaValue,
&plan.QuotaUnit,
&plan.ContextWindow,
&modelScopeRaw,
&plan.SourceURL,
); err != nil {
return nil, err
}
var modelIDs []string
if err := json.Unmarshal([]byte(modelScopeRaw), &modelIDs); err == nil {
plan.ModelCount = len(modelIDs)
if len(modelIDs) > 3 {
plan.ModelPreview = strings.Join(modelIDs[:3], ", ")
} else {
plan.ModelPreview = strings.Join(modelIDs, ", ")
}
}
plans = append(plans, plan)
}
return plans, rows.Err()
}
func filterPaid(models []ModelInfo) []ModelInfo {
var paid []ModelInfo
for _, m := range models {
if !m.IsFree && m.InputPrice > 0 {
paid = append(paid, m)
}
}
sort.Slice(paid, func(i, j int) bool {
return paid[i].InputPrice < paid[j].InputPrice
})
return paid
}
func formatPrice(price float64, currency string) string {
if price <= 0 {
return "免费"
}
if currency == "CNY" {
if price < 1 {
return fmt.Sprintf("¥%.2f", price)
}
return fmt.Sprintf("¥%.1f", price)
}
// USD - convert to CNY for display
cny := price * USD_TO_CNY
if cny < 1 {
return fmt.Sprintf("¥%.2f", cny)
}
return fmt.Sprintf("¥%.1f", cny)
}
func formatPriceWithCurrency(price float64, currency string) string {
if price <= 0 {
return "免费"
}
if currency == "CNY" {
return fmt.Sprintf("¥%.2f", price)
}
return fmt.Sprintf("$%.2f", price)
}
// formatDomesticPrice 显示国内模型价格统一转换为CNY
func formatDomesticPrice(price float64, currency string) string {
if price <= 0 {
return "免费"
}
if currency == "USD" {
price = price * USD_TO_CNY
}
return fmt.Sprintf("¥%.2f", price)
}
// Deprecated: use formatPrice
func formatCNY(price float64) string {
return formatPrice(price, "USD")
}
func formatPriceUSD(price float64) string {
if price <= 0 {
return "免费"
}
return fmt.Sprintf("$%.2f", price)
}
func formatSubscriptionPrice(price float64, currency string) string {
switch currency {
case "CNY":
return fmt.Sprintf("¥%.2f/月", price)
case "USD":
return fmt.Sprintf("$%.2f/month", price)
default:
return fmt.Sprintf("%.2f %s", price, currency)
}
}
func formatSubscriptionQuota(value int64, unit string) string {
if value <= 0 {
return "-"
}
if unit == "tokens/month" {
switch {
case value%10000 == 0 && value < 100000000:
return fmt.Sprintf("%d万 Tokens/月", value/10000)
case value%100000000 == 0:
return fmt.Sprintf("%d亿 Tokens/月", value/100000000)
case value >= 10000000:
return fmt.Sprintf("%.1f亿 Tokens/月", float64(value)/100000000)
}
}
return fmt.Sprintf("%d %s", value, unit)
}
func formatContextWindowCompact(value int) string {
if value <= 0 {
return "-"
}
if value%(1024*1024) == 0 {
return fmt.Sprintf("%dM", value/(1024*1024))
}
if value%1024 == 0 {
return fmt.Sprintf("%dK", value/1024)
}
return fmt.Sprintf("%d", value)
}
// 场景标签
type SceneTag string
const (
SceneCode SceneTag = "代码"
SceneReasoning SceneTag = "推理"
SceneWriting SceneTag = "写作"
SceneVision SceneTag = "视觉"
SceneChat SceneTag = "对话"
)
func deriveSceneTags(name, modality string, capabilities []string) []SceneTag {
var tags []SceneTag
lowerName := strings.ToLower(name)
// 代码模型
if strings.Contains(lowerName, "codex") || strings.Contains(lowerName, "coder") ||
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") {
tags = append(tags, SceneReasoning)
}
// 视觉模型
if strings.Contains(modality, "vision") || strings.Contains(modality, "multimodal") ||
strings.Contains(lowerName, "vl") || strings.Contains(lowerName, "vision") {
tags = append(tags, SceneVision)
}
// 写作/对话(兜底)
if len(tags) == 0 {
if strings.Contains(modality, "text") || strings.Contains(modality, "chat") {
tags = append(tags, SceneChat)
}
}
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 &sections[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
}
defer f.Close()
fmt.Fprintf(f, "# 🤖 LLM Intelligence Hub - 每日情报报告\n\n")
fmt.Fprintf(f, "**报告日期**: %s \n**生成时间**: %s \n**页面状态**: %s \n\n", r.Date, r.GeneratedAt, r.PageMode)
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\n", r.QualitySummary.USD)
if len(r.IntlTop5) > 0 {
fmt.Fprintf(f, "### 国际低价模型 TOP 5\n\n")
fmt.Fprintf(f, "| 排名 | 模型 | 厂商 | 输入 | 输出 | 上下文 |\n")
fmt.Fprintf(f, "|------|------|------|------|------|--------|\n")
for i, m := range r.IntlTop5 {
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")
}
if len(r.DomesticTop10) > 0 {
fmt.Fprintf(f, "### 国内模型 TOP 10\n\n")
fmt.Fprintf(f, "| 排名 | 模型 | 厂商 | 输入(CNY) | 输出(CNY) | 上下文 |\n")
fmt.Fprintf(f, "|------|------|------|-----------|-----------|--------|\n")
for i, m := range r.DomesticTop10 {
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")
}
if len(r.TencentSubscriptionPlans) > 0 {
fmt.Fprintf(f, "## 💳 腾讯云套餐订阅价\n\n")
fmt.Fprintf(f, "> 以下为套餐订阅价,不参与按模型输入/输出单价排行。\n\n")
fmt.Fprintf(f, "| 套餐 | 月费 | 月额度 | 上下文上限 | 覆盖模型 |\n")
fmt.Fprintf(f, "|------|------|--------|------------|----------|\n")
for _, plan := range r.TencentSubscriptionPlans {
fmt.Fprintf(
f,
"| %s | %s | %s | %s | %d 个(%s |\n",
plan.PlanName,
formatSubscriptionPrice(plan.ListPrice, plan.Currency),
formatSubscriptionQuota(plan.QuotaValue, plan.QuotaUnit),
formatContextWindowCompact(plan.ContextWindow),
plan.ModelCount,
plan.ModelPreview,
)
}
fmt.Fprintf(f, "\n")
}
fmt.Fprintf(f, "### 平台覆盖\n\n")
for _, op := range r.Operators {
fmt.Fprintf(f, "- **%s**: %d 个模型,最低 %s/M\n", op.Name, op.ModelCount, formatPrice(op.MinInputPrice, "USD"))
}
for _, op := range r.Resellers {
fmt.Fprintf(f, "- **%s**: %d 个模型,最低 %s/M\n", op.Name, op.ModelCount, formatPrice(op.MinInputPrice, "USD"))
}
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
}
func filterByScene(models []ModelInfo, tag SceneTag) []ModelInfo {
var result []ModelInfo
for _, m := range models {
for _, t := range m.SceneTags {
if t == tag {
result = append(result, m)
break
}
}
}
return result
}
// ============ HTML生成(现代化UI) ============
func generateHTMLV3(r *ReportV3, path string) error {
decorateReportV1(r)
tmpl := `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LLM Intelligence Hub - {{.Date}}</title>
<style>
:root {
--ink: #15304b;
--ink-soft: #516579;
--fog: #f6f4ef;
--card: rgba(255,255,255,0.94);
--line: rgba(21,48,75,0.10);
--blue: #123c63;
--green: #1f7a4c;
--amber: #ad6b11;
--red: #a53b2a;
--shadow: 0 20px 40px rgba(17, 38, 58, 0.08);
}
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family: "SF Pro Display", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
background:
radial-gradient(circle at top left, rgba(18,60,99,0.08), transparent 30%),
linear-gradient(180deg, #fbfaf7 0%, #f4f1ea 100%);
color: var(--ink);
line-height: 1.5;
}
.container {
max-width: 1160px;
margin: 0 auto;
padding: 16px;
}
.topbar,
.hero-card,
.section,
.appendix-card,
.metric-card,
.action-card,
.headline-card,
.scene-card,
.free-card {
background: var(--card);
border: 1px solid var(--line);
box-shadow: var(--shadow);
backdrop-filter: blur(12px);
}
.topbar {
border-radius: 24px;
padding: 20px;
margin-bottom: 14px;
}
.topbar-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.topbar-title {
font-size: 1rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--ink-soft);
}
.topbar-meta {
font-size: 0.95rem;
color: var(--ink-soft);
}
.label-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
.pill {
display: inline-flex;
align-items: center;
padding: 7px 12px;
border-radius: 999px;
font-size: 0.82rem;
font-weight: 700;
}
.pill.blue { background: rgba(18,60,99,0.10); color: var(--blue); }
.pill.green { background: rgba(31,122,76,0.12); color: var(--green); }
.pill.amber { background: rgba(173,107,17,0.12); color: var(--amber); }
.pill.red { background: rgba(165,59,42,0.12); color: var(--red); }
.hero-card {
border-radius: 28px;
padding: 24px 20px;
margin-bottom: 16px;
background:
linear-gradient(135deg, rgba(18,60,99,0.96), rgba(34,80,122,0.90)),
#123c63;
color: #f9fafb;
}
.hero-kicker {
font-size: 0.9rem;
letter-spacing: 0.06em;
text-transform: uppercase;
opacity: 0.82;
margin-bottom: 10px;
}
.hero-title {
font-size: clamp(1.45rem, 4.8vw, 2.05rem);
line-height: 1.28;
font-weight: 800;
margin-bottom: 14px;
}
.hero-evidence {
font-size: 1rem;
opacity: 0.92;
}
.metrics-grid,
.actions-grid,
.headline-grid,
.free-grid,
.scene-grid,
.appendix-grid {
display: grid;
gap: 12px;
margin-bottom: 16px;
}
.metrics-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metric-card {
border-radius: 20px;
padding: 16px;
}
.metric-label {
font-size: 0.92rem;
color: var(--ink-soft);
margin-bottom: 6px;
}
.metric-value {
font-size: 1.8rem;
font-weight: 800;
}
.section {
border-radius: 24px;
padding: 20px;
margin-bottom: 16px;
}
.section h2 {
font-size: 1.3rem;
margin-bottom: 14px;
}
.section-intro {
color: var(--ink-soft);
font-size: 0.98rem;
margin-bottom: 14px;
}
.actions-grid,
.headline-grid,
.scene-grid,
.appendix-grid {
grid-template-columns: 1fr;
}
.action-card,
.headline-card,
.scene-card,
.free-card,
.appendix-card {
border-radius: 22px;
padding: 18px;
}
.action-card.primary {
border: 2px solid rgba(18,60,99,0.16);
}
.card-kicker {
font-size: 0.85rem;
color: var(--ink-soft);
margin-bottom: 8px;
}
.card-title {
font-size: 1.18rem;
font-weight: 800;
margin-bottom: 8px;
}
.card-summary,
.card-evidence,
.scene-desc,
.appendix-desc,
.meta-line {
font-size: 0.98rem;
color: var(--ink-soft);
}
.card-evidence {
margin-top: 10px;
color: var(--ink);
font-weight: 700;
}
.trust-line,
.baseline-line {
margin-top: 8px;
font-size: 0.9rem;
color: var(--ink-soft);
}
.scene-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
margin-bottom: 10px;
}
.scene-lead {
border-top: 1px solid var(--line);
padding-top: 12px;
margin-top: 12px;
}
.scene-lead-name {
font-size: 1.12rem;
font-weight: 800;
margin-bottom: 6px;
}
.scene-others {
margin-top: 12px;
display: grid;
gap: 8px;
}
.scene-other {
padding-top: 10px;
border-top: 1px dashed var(--line);
}
.free-grid {
grid-template-columns: 1fr;
}
.free-count {
font-size: 1.9rem;
font-weight: 800;
margin-bottom: 6px;
}
.appendix-links {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 10px;
}
.appendix-link {
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
color: var(--blue);
background: rgba(18,60,99,0.08);
border-radius: 999px;
padding: 10px 14px;
font-weight: 700;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 12px;
font-size: 0.95rem;
}
th, td {
padding: 12px 10px;
text-align: left;
border-bottom: 1px solid var(--line);
vertical-align: top;
}
th {
color: var(--ink-soft);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.footer {
padding: 24px 8px 12px;
text-align: center;
color: var(--ink-soft);
font-size: 0.92rem;
}
@media (min-width: 900px) {
.container {
padding: 24px;
}
.metrics-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.actions-grid,
.headline-grid,
.free-grid,
.appendix-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.scene-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (prefers-reduced-motion: no-preference) {
.hero-card,
.section,
.metric-card {
animation: fadeUp 240ms ease-out both;
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
}
.tone-success { border-color: rgba(31,122,76,0.18); }
.tone-warning { border-color: rgba(173,107,17,0.18); }
.tone-info { border-color: rgba(18,60,99,0.18); }
.tone-caution { border-color: rgba(165,59,42,0.18); }
.tone-neutral { border-color: rgba(81,101,121,0.16); }
.footer {
text-align: center;
}
</style>
</head>
<body>
<div class="container">
<div class="topbar">
<div class="topbar-row">
<div class="topbar-title">AI 模型与价格情报晨报</div>
<div class="topbar-meta">{{.Date}} · {{.GeneratedAt}}</div>
</div>
<div class="label-row">
{{range .MarketLabels}}<span class="pill blue">{{.}}</span>{{end}}
</div>
</div>
<section class="hero-card">
<div class="hero-kicker">今日一句话结论</div>
<div class="hero-title">{{.HeroSummary}}</div>
{{if .HeroEvidence}}<div class="hero-evidence">{{.HeroEvidence}}</div>{{end}}
</section>
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-label">模型总数</div>
<div class="metric-value">{{.TotalModels}}</div>
</div>
<div class="metric-card">
<div class="metric-label">今日新增模型</div>
<div class="metric-value">{{.DailySignals.NewModels}}</div>
</div>
<div class="metric-card">
<div class="metric-label">今日价格变化</div>
<div class="metric-value">{{.DailySignals.PriceChanges}}</div>
</div>
<div class="metric-card">
<div class="metric-label">免费样本</div>
<div class="metric-value">{{len .FreeModels}}</div>
</div>
</div>
<section class="section">
<h2>三条行动建议</h2>
<p class="section-intro">先给行动再给证据每张卡只回答今天该先看什么</p>
<div class="actions-grid">
{{range $i, $item := .ActionItems}}
<article class="action-card {{if eq $i 0}}primary{{end}}">
<div class="card-kicker">行动建议</div>
<div class="card-title">{{$item.Title}}</div>
<div class="card-summary">{{$item.Audience}}</div>
<div class="label-row">
{{range $item.Tags}}<span class="pill amber">{{.}}</span>{{end}}
</div>
<div class="card-evidence">{{$item.Evidence}}</div>
</article>
{{end}}
</div>
</section>
<section class="section">
<h2>今日头条</h2>
<p class="section-intro">只保留真正影响当天判断的变化事件</p>
<div class="headline-grid">
{{range .HeadlineItems}}
<article class="headline-card tone-{{.Tone}}">
<div class="card-kicker">{{.Label}}</div>
<div class="card-title">{{.Title}}</div>
<div class="card-summary">{{.Summary}}</div>
<div class="baseline-line">基线{{.Baseline}}</div>
<div class="trust-line">可信度{{.TrustLabel}}</div>
</article>
{{end}}
</div>
</section>
<section class="section">
<h2>免费来源分层</h2>
<p class="section-intro">免费可用不等于官方长期免费必须先区分来源</p>
<div class="free-grid">
{{range .FreeBreakdown}}
<article class="free-card tone-{{.Tone}}">
<div class="card-title">{{.Label}}</div>
<div class="free-count">{{.Count}}</div>
<div class="card-summary">{{.Description}}</div>
</article>
{{end}}
</div>
</section>
<section class="section">
<h2>场景推荐</h2>
<p class="section-intro">按场景给出有限候选优先帮助读者当天做出选择</p>
<div class="scene-grid">
{{range .SceneSections}}
<article class="scene-card">
<div class="scene-header">
<div>
<div class="card-title">{{.Title}}</div>
<div class="scene-desc">{{.Description}}</div>
</div>
</div>
<div class="scene-lead">
<div class="scene-lead-name">{{.Lead.Name}}</div>
<div class="meta-line">{{.Lead.Provider}} · {{.Lead.Operator}} · {{.Lead.Usage}}</div>
<div class="label-row">
{{range .Lead.Tags}}<span class="pill green">{{.}}</span>{{end}}
</div>
<div class="card-evidence">{{.Lead.Evidence}}</div>
<div class="trust-line">{{.Lead.PriceSummary}} · {{.Lead.TrustLabel}}</div>
</div>
{{if .Others}}
<div class="scene-others">
{{range .Others}}
<div class="scene-other">
<div><strong>{{.Name}}</strong> · {{.Provider}}</div>
<div class="meta-line">{{.PriceSummary}} · {{.Evidence}}</div>
</div>
{{end}}
</div>
{{end}}
</article>
{{end}}
</div>
</section>
<section class="section">
<h2>完整数据附录</h2>
<p class="section-intro">长表格后置适合深度比价时再展开</p>
<div class="appendix-grid">
{{range .AppendixLinks}}
<article class="appendix-card">
<div class="card-title">{{.Title}}</div>
<div class="appendix-desc">{{.Description}}</div>
<div class="appendix-links">
<a class="appendix-link" href="{{.Anchor}}">跳转查看</a>
</div>
</article>
{{end}}
</div>
</section>
<section class="section" id="appendix-pricing">
<h2>完整价格附录</h2>
{{if .IntlTop5}}
<table>
<tr><th>国际候选</th><th>厂商</th><th>输入</th><th>输出</th><th>上下文</th></tr>
{{range .IntlTop5}}
<tr>
<td><strong>{{.Name}}</strong></td>
<td>{{.ProviderName}}</td>
<td>{{formatPriceWithCurrency .InputPrice .Currency}}</td>
<td>{{formatPriceWithCurrency .OutputPrice .Currency}}</td>
<td>{{formatContextWindowCompact .ContextLength}}</td>
</tr>
{{end}}
</table>
{{end}}
{{if .DomesticTop10}}
<table>
<tr><th>国内候选</th><th>厂商</th><th>输入(CNY)</th><th>输出(CNY)</th><th>上下文</th></tr>
{{range .DomesticTop10}}
<tr>
<td><strong>{{.Name}}</strong></td>
<td>{{.ProviderName}}</td>
<td>{{formatDomesticPrice .InputPrice .Currency}}</td>
<td>{{formatDomesticPrice .OutputPrice .Currency}}</td>
<td>{{formatContextWindowCompact .ContextLength}}</td>
</tr>
{{end}}
</table>
{{end}}
</section>
<section class="section" id="appendix-free">
<h2>完整免费附录</h2>
<table>
<tr><th>模型</th><th>厂商</th><th>来源类型</th><th>上下文</th></tr>
{{range .FreeTop20}}
<tr>
<td><strong>{{.Name}}</strong></td>
<td>{{.ProviderName}}</td>
<td>{{classifyFreeSource .}}</td>
<td>{{formatContextWindowCompact .ContextLength}}</td>
</tr>
{{end}}
</table>
</section>
<section class="section" id="appendix-platforms">
<h2>平台覆盖附录</h2>
{{if .Operators}}
<table>
<tr><th>官方/云平台</th><th>模型数</th><th>最低输入价</th><th>平均输入价</th></tr>
{{range .Operators}}
<tr><td><strong>{{.Name}}</strong></td><td>{{.ModelCount}}</td><td>{{formatPrice .MinInputPrice "USD"}}</td><td>{{formatPrice .AvgInputPrice "USD"}}</td></tr>
{{end}}
</table>
{{end}}
{{if .Resellers}}
<table>
<tr><th>聚合平台</th><th>模型数</th><th>最低输入价</th><th>平均输入价</th></tr>
{{range .Resellers}}
<tr><td><strong>{{.Name}}</strong></td><td>{{.ModelCount}}</td><td>{{formatPrice .MinInputPrice "USD"}}</td><td>{{formatPrice .AvgInputPrice "USD"}}</td></tr>
{{end}}
</table>
{{end}}
</section>
{{if .TencentSubscriptionPlans}}
<section class="section">
<h2>💳 腾讯云套餐订阅价</h2>
<p class="section-intro">以下为套餐订阅价不参与按模型输入/输出单价排行</p>
<table>
<tr><th>套餐</th><th>月费</th><th>月额度</th><th>上下文上限</th><th>覆盖模型</th></tr>
{{range .TencentSubscriptionPlans}}
<tr>
<td><strong>{{.PlanName}}</strong></td>
<td>{{formatSubscriptionPrice .ListPrice .Currency}}</td>
<td>{{formatSubscriptionQuota .QuotaValue .QuotaUnit}}</td>
<td>{{formatContextWindowCompact .ContextWindow}}</td>
<td>{{.ModelCount}} {{if .ModelPreview}}{{.ModelPreview}}{{end}}</td>
</tr>
{{end}}
</table>
</section>
{{end}}
<div class="footer">
<p>📌 本报告由 LLM Intelligence Hub 自动生成 · {{.Date}}</p>
<p style="margin-top:8px;">免费不等于官方永久免费需结合来源标签判断</p>
</div>
</div>
</body>
</html>`
funcMap := template.FuncMap{
"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))
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
return t.Execute(f, r)
}
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
status = EXCLUDED.status,
model_count = EXCLUDED.model_count,
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); 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 中提取厂商名
func deriveProviderName(modelID string) string {
parts := strings.SplitN(modelID, "/", 2)
if len(parts) == 0 || parts[0] == "" {
return "Unknown"
}
raw := parts[0]
raw = strings.ReplaceAll(raw, "-", " ")
raw = strings.ReplaceAll(raw, "_", " ")
words := strings.Fields(raw)
for i, word := range words {
if len(word) == 0 {
continue
}
words[i] = strings.ToUpper(word[:1]) + strings.ToLower(word[1:])
}
if len(words) == 0 {
return "Unknown"
}
return strings.Join(words, " ")
}