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