Files
llm-intelligence/scripts/generate_daily_report.go
2026-05-13 14:42:45 +08:00

1234 lines
35 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//go:build llm_script
// generate_daily_report.go v3.0 - 日报生成器(现代化UI版)
// 支持:国家分类、运营商分类、信息图风格HTML
package main
import (
"database/sql"
"encoding/json"
"fmt"
"html/template"
"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 := "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. 保存到 daily_report 表
if err := saveDailyReportV3(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
TotalModels int
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
}
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
}
return &ReportV3{
Date: date,
TotalModels: len(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,
},
}, 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
}
// ============ Markdown生成 ============
func generateMarkdownV3(r *ReportV3, path string) error {
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\n", r.Date, time.Now().Format(time.RFC3339))
// 数据质量摘要
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))
// 免费模型(只展示前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")
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, "\n")
}
// 国内前10
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 {
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, "\n")
} else {
fmt.Fprintf(f, "## 🇨🇳 国内模型 TOP 10\n\n")
fmt.Fprintf(f, "> ⚠️ 暂无国内厂商数据。当前仅采集了 OpenRouter(国际平台),国内厂商数据将在 Phase 2 接入。\n\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")
// 国内模型分类 - 只展示官方平台
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
for _, op := range r.Operators {
if op.Country == "CN" {
domesticOps = append(domesticOps, op)
} else {
intlOps = append(intlOps, op)
}
}
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")
}
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))
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 {
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 {
--primary: #6366f1;
--primary-dark: #4f46e5;
--success: #10b981;
--warning: #f59e0b;
--danger: #ef4444;
--info: #3b82f6;
--bg: #f1f5f9;
--card: #ffffff;
--text: #1e293b;
--text-secondary: #64748b;
--border: #e2e8f0;
}
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg);
color: var(--text);
line-height: 1.6;
}
.container { max-width: 1200px; margin: 0 auto; padding: 20px; }
/* Header */
.header {
background: linear-gradient(135deg, var(--primary), var(--primary-dark));
color: white;
padding: 40px 30px;
border-radius: 16px;
margin-bottom: 24px;
box-shadow: 0 10px 40px rgba(99,102,241,0.3);
}
.header h1 { font-size: 2rem; margin-bottom: 8px; }
.header p { opacity: 0.9; font-size: 1.1rem; }
/* Stats Grid */
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 16px;
margin-bottom: 24px;
}
.stat-card {
background: var(--card);
border-radius: 12px;
padding: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
border-left: 4px solid var(--primary);
}
.stat-card.free { border-left-color: var(--success); }
.stat-card.intl { border-left-color: var(--info); }
.stat-card.domestic { border-left-color: var(--warning); }
.stat-label { font-size: 0.875rem; color: var(--text-secondary); margin-bottom: 4px; }
.stat-value { font-size: 1.75rem; font-weight: 700; color: var(--text); }
/* Section */
.section {
background: var(--card);
border-radius: 12px;
padding: 24px;
margin-bottom: 20px;
box-shadow: 0 2px 8px rgba(0,0,0,0.06);
}
.section h2 {
font-size: 1.25rem;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 2px solid var(--border);
display: flex;
align-items: center;
gap: 8px;
}
/* Model Cards */
.model-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.model-card {
background: var(--bg);
border-radius: 10px;
padding: 16px;
border: 1px solid var(--border);
transition: transform 0.2s, box-shadow 0.2s;
}
.model-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0,0,0,0.1);
}
.model-card .name { font-weight: 600; font-size: 0.95rem; margin-bottom: 6px; }
.model-card .provider { font-size: 0.8rem; color: var(--text-secondary); margin-bottom: 8px; }
.model-card .price-row {
display: flex;
justify-content: space-between;
font-size: 0.85rem;
}
.model-card .price-label { color: var(--text-secondary); }
.model-card .price-value { font-weight: 600; }
.model-card .price-value.free { color: var(--success); }
.model-card .badge {
display: inline-block;
padding: 2px 8px;
border-radius: 4px;
font-size: 0.75rem;
font-weight: 600;
}
.badge.free { background: #d1fae5; color: #065f46; }
.badge.intl { background: #dbeafe; color: #1e40af; }
.badge.domestic { background: #fef3c7; color: #92400e; }
/* Tables */
table { width: 100%; border-collapse: collapse; margin-top: 12px; }
th, td { padding: 12px; text-align: left; border-bottom: 1px solid var(--border); }
th { background: var(--bg); font-weight: 600; font-size: 0.875rem; }
tr:hover { background: #f8fafc; }
/* Alert */
.alert {
background: #fef3c7;
border-left: 4px solid var(--warning);
padding: 12px 16px;
border-radius: 8px;
margin-bottom: 20px;
}
.alert p { color: #92400e; font-size: 0.9rem; }
/* Footer */
.footer {
text-align: center;
color: var(--text-secondary);
padding: 30px;
font-size: 0.875rem;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>🤖 LLM Intelligence Hub</h1>
<p>每日情报报告 · {{.Date}} · {{.TotalModels}} 模型覆盖</p>
</div>
<!-- 核心指标 -->
<div class="stats-grid">
<div class="stat-card">
<div class="stat-label">模型总数</div>
<div class="stat-value">{{.TotalModels}}</div>
</div>
<div class="stat-card free">
<div class="stat-label">免费模型</div>
<div class="stat-value">{{len .FreeModels}}</div>
</div>
<div class="stat-card intl">
<div class="stat-label">国际模型</div>
<div class="stat-value">{{len .IntlTop5}}</div>
</div>
<div class="stat-card domestic">
<div class="stat-label">国内模型</div>
<div class="stat-value">{{if .HasDomesticData}}{{len .DomesticTop10}}{{else}}0{{end}}</div>
</div>
</div>
{{if not .HasDomesticData}}
<div class="alert">
<p>⚠️ 当前仅接入 OpenRouter 数据源,国内厂商 CNY 定价将在 Phase 2 接入。</p>
</div>
{{end}}
<!-- 免费模型 -->
{{if .FreeModels}}
<div class="section">
<h2>🆓 免费模型({{len .FreeModels}} 个)</h2>
<p style="color: var(--text-secondary); margin-bottom: 12px;">代表性模型(前20个):</p>
<div class="model-grid">
{{range .FreeTop20}}
<div class="model-card">
<div class="name">{{.Name}}</div>
<div class="provider">{{.ProviderName}} <span class="badge {{if eq .ProviderCountry "CN"}}domestic{{else}}intl{{end}}">{{if eq .ProviderCountry "CN"}}国内{{else}}国际{{end}}</span></div>
<div class="price-row">
<span class="price-label">输入</span>
<span class="price-value free">免费</span>
</div>
<div class="price-row">
<span class="price-label">上下文</span>
<span class="price-value">{{.ContextLength}} tokens</span>
</div>
</div>
{{end}}
</div>
{{if gt (len .FreeModels) 20}}
<p style="text-align: center; color: var(--text-secondary); margin-top: 12px;">... 共 {{len .FreeModels}} 个免费模型,以上为前20个</p>
{{end}}
</div>
{{end}}
<!-- 国际 TOP 5 -->
{{if .IntlTop5}}
<div class="section">
<h2>🌍 国际低价模型 TOP 5</h2>
<table>
<tr><th>排名</th><th>模型</th><th>厂商</th><th>输入价格</th><th>输出价格</th><th>上下文</th></tr>
{{range $i, $m := .IntlTop5}}
<tr>
<td>{{add $i 1}}</td>
<td><strong>{{$m.Name}}</strong></td>
<td>{{$m.ProviderName}}</td>
<td>${{printf "%.2f" $m.InputPrice}}</td>
<td>${{printf "%.2f" $m.OutputPrice}}</td>
<td>{{$m.ContextLength}}</td>
</tr>
{{end}}
</table>
</div>
{{end}}
<!-- 国内 TOP 10 -->
{{if .HasDomesticData}}
<div class="section">
<h2>🇨🇳 国内模型 TOP 10</h2>
<table>
<tr><th>排名</th><th>模型</th><th>厂商</th><th>输入价格</th><th>输出价格</th><th>上下文</th></tr>
{{range $i, $m := .DomesticTop10}}
<tr>
<td>{{add $i 1}}</td>
<td><strong>{{$m.Name}}</strong></td>
<td>{{$m.ProviderName}}</td>
<td>${{printf "%.2f" $m.InputPrice}}</td>
<td>${{printf "%.2f" $m.OutputPrice}}</td>
<td>{{$m.ContextLength}}</td>
</tr>
{{end}}
</table>
</div>
{{end}}
{{if .TencentSubscriptionPlans}}
<div class="section">
<h2>💳 腾讯云套餐订阅价</h2>
<p style="color: var(--text-secondary); margin-bottom: 12px;">以下为套餐订阅价,不参与按模型输入/输出单价排行。</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>
</div>
{{end}}
<!-- 运营商 -->
{{if .Operators}}
<div class="section">
<h2>☁️ 云厂商/官方平台({{len .Operators}} 家)</h2>
<table>
<tr><th>平台</th><th>模型数</th><th>最低价格</th><th>平均价格</th></tr>
{{range .Operators}}
<tr><td><strong>{{.Name}}</strong></td><td>{{.ModelCount}}</td><td>${{printf "%.2f" .MinInputPrice}}</td><td>${{printf "%.2f" .AvgInputPrice}}</td></tr>
{{end}}
</table>
</div>
{{end}}
<!-- 中转商 -->
{{if .Resellers}}
<div class="section">
<h2>🔀 中转/聚合平台({{len .Resellers}} 家)</h2>
<table>
<tr><th>平台</th><th>模型数</th><th>最低价格</th><th>平均价格</th></tr>
{{range .Resellers}}
<tr><td><strong>{{.Name}}</strong></td><td>{{.ModelCount}}</td><td>${{printf "%.2f" .MinInputPrice}}</td><td>${{printf "%.2f" .AvgInputPrice}}</td></tr>
{{end}}
</table>
</div>
{{end}}
<div class="footer">
<p>📌 本报告由 LLM Intelligence Hub 自动生成 · {{.Date}}</p>
<p style="margin-top:8px;font-size:0.8rem;">价格单位:USD/1M tokens{{if not .HasCNYData}} · 国内厂商数据待 Phase 2 接入{{end}}</p>
</div>
</div>
</body>
</html>`
funcMap := template.FuncMap{
"add": func(a, b int) int { return a + b },
"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 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(`
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,
updated_at = NOW()
`, r.Date, "generated", r.TotalModels, 0, len(r.FreeModels), summary, mdPath)
return err
}
// 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, " ")
}