Files
llm-intelligence/scripts/generate_daily_report.go

1234 lines
35 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"
"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, " ")
}