2026-05-13 14:42:45 +08:00
|
|
|
|
//go:build llm_script
|
|
|
|
|
|
|
|
|
|
|
|
// generate_daily_report.go v3.0 - 日报生成器(现代化UI版)
|
|
|
|
|
|
// 支持:国家分类、运营商分类、信息图风格HTML
|
2026-05-08 13:49:12 +08:00
|
|
|
|
package main
|
|
|
|
|
|
|
|
|
|
|
|
import (
|
2026-05-13 14:42:45 +08:00
|
|
|
|
"database/sql"
|
2026-05-08 13:49:12 +08:00
|
|
|
|
"encoding/json"
|
|
|
|
|
|
"fmt"
|
2026-05-13 14:42:45 +08:00
|
|
|
|
"html/template"
|
2026-05-13 20:13:02 +08:00
|
|
|
|
"io"
|
2026-05-13 14:42:45 +08:00
|
|
|
|
"log/slog"
|
2026-05-08 13:49:12 +08:00
|
|
|
|
"os"
|
|
|
|
|
|
"path/filepath"
|
|
|
|
|
|
"sort"
|
|
|
|
|
|
"strings"
|
|
|
|
|
|
"time"
|
2026-05-13 14:42:45 +08:00
|
|
|
|
|
|
|
|
|
|
_ "github.com/lib/pq"
|
2026-05-08 13:49:12 +08:00
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
var logger *slog.Logger
|
|
|
|
|
|
|
|
|
|
|
|
func init() {
|
|
|
|
|
|
logger = slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
func main() {
|
|
|
|
|
|
loadProjectEnv()
|
|
|
|
|
|
if err := run(); err != nil {
|
|
|
|
|
|
logger.Error("日报生成失败", "error", err)
|
|
|
|
|
|
os.Exit(1)
|
|
|
|
|
|
}
|
|
|
|
|
|
logger.Info("日报生成完成")
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
func loadProjectEnv() {
|
|
|
|
|
|
for _, path := range []string{".env.local", ".env"} {
|
|
|
|
|
|
loadEnvFile(path)
|
|
|
|
|
|
}
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
func loadEnvFile(path string) {
|
|
|
|
|
|
f, err := os.Open(path)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
defer f.Close()
|
2026-05-08 13:49:12 +08:00
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
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)
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
func run() error {
|
|
|
|
|
|
dbConn := os.Getenv("DATABASE_URL")
|
|
|
|
|
|
if dbConn == "" {
|
|
|
|
|
|
return fmt.Errorf("DATABASE_URL 未设置")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
db, err := sql.Open("postgres", dbConn)
|
2026-05-08 13:49:12 +08:00
|
|
|
|
if err != nil {
|
2026-05-13 14:42:45 +08:00
|
|
|
|
return fmt.Errorf("连接数据库失败: %w", err)
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
2026-05-13 14:42:45 +08:00
|
|
|
|
defer db.Close()
|
2026-05-08 13:49:12 +08:00
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
date := time.Now().Format("2006-01-02")
|
|
|
|
|
|
|
|
|
|
|
|
// 1. 获取报告数据(使用新schema)
|
|
|
|
|
|
report, err := generateReportDataV3(db, date)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return fmt.Errorf("生成报告数据失败: %w", err)
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
// 2. 创建目录
|
2026-05-13 20:13:02 +08:00
|
|
|
|
outDir := os.Getenv("REPORT_OUTPUT_DIR")
|
|
|
|
|
|
if outDir == "" {
|
|
|
|
|
|
outDir = "reports/daily"
|
|
|
|
|
|
}
|
2026-05-13 14:42:45 +08:00
|
|
|
|
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
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
// 4. 生成 HTML(现代化UI)
|
|
|
|
|
|
htmlPath := filepath.Join(outDir, "html", fmt.Sprintf("daily_report_%s.html", date))
|
|
|
|
|
|
if err := generateHTMLV3(report, htmlPath); err != nil {
|
|
|
|
|
|
return err
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
2026-05-13 14:42:45 +08:00
|
|
|
|
|
2026-05-13 20:13:02 +08:00
|
|
|
|
// 5. 归档主产物,确保运行脚本和门禁使用统一路径约定
|
|
|
|
|
|
if err := archiveReportArtifacts(date, mdPath, htmlPath); err != nil {
|
|
|
|
|
|
return fmt.Errorf("归档日报失败: %w", err)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 6. 同步写入日报状态与运行轨迹
|
|
|
|
|
|
if err := saveReportTrackingV3(db, report, mdPath); err != nil {
|
2026-05-13 14:42:45 +08:00
|
|
|
|
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 {
|
2026-05-13 20:13:02 +08:00
|
|
|
|
ID, Name, ProviderName string
|
|
|
|
|
|
ProviderCountry string
|
|
|
|
|
|
ContextLength int
|
2026-05-13 14:42:45 +08:00
|
|
|
|
InputPrice, OutputPrice float64
|
2026-05-13 20:13:02 +08:00
|
|
|
|
Currency string
|
|
|
|
|
|
IsFree bool
|
|
|
|
|
|
OperatorName string
|
|
|
|
|
|
OperatorType string // cloud / reseller / official
|
|
|
|
|
|
Region string
|
|
|
|
|
|
Modality string
|
|
|
|
|
|
SceneTags []SceneTag
|
2026-05-13 14:42:45 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type ReportV3 struct {
|
2026-05-13 20:13:02 +08:00
|
|
|
|
Date string
|
|
|
|
|
|
GeneratedAt string
|
|
|
|
|
|
TotalModels int
|
|
|
|
|
|
AllModels []ModelInfo
|
|
|
|
|
|
FreeModels []ModelInfo
|
|
|
|
|
|
FreeTop20 []ModelInfo // 免费模型前20个(展示用)
|
|
|
|
|
|
IntlTop5 []ModelInfo // 国际前5(付费低价)
|
|
|
|
|
|
DomesticTop10 []ModelInfo // 国内前10(付费低价)
|
|
|
|
|
|
TopContext []ModelInfo // 大上下文TOP10
|
2026-05-13 14:42:45 +08:00
|
|
|
|
TencentSubscriptionPlans []SubscriptionPlanInfo
|
2026-05-13 20:13:02 +08:00
|
|
|
|
Operators []OperatorInfo
|
|
|
|
|
|
Resellers []OperatorInfo
|
|
|
|
|
|
QualitySummary DataQualitySummary
|
|
|
|
|
|
HasCNYData bool
|
|
|
|
|
|
HasDomesticData bool
|
|
|
|
|
|
DailySignals DailySignals
|
|
|
|
|
|
PageMode string
|
|
|
|
|
|
MarketLabels []string
|
|
|
|
|
|
HeroSummary string
|
|
|
|
|
|
HeroEvidence string
|
|
|
|
|
|
FreeBreakdown []FreeSourceStat
|
|
|
|
|
|
ActionItems []ActionItem
|
|
|
|
|
|
HeadlineItems []HeadlineItem
|
|
|
|
|
|
SceneSections []SceneSection
|
|
|
|
|
|
AppendixLinks []AppendixLink
|
2026-05-13 21:10:11 +08:00
|
|
|
|
ModelEvents []ModelEvent
|
2026-05-13 20:13:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type DailySignals struct {
|
|
|
|
|
|
NewModels int
|
|
|
|
|
|
PriceChanges int
|
|
|
|
|
|
OfficialFree int
|
|
|
|
|
|
AggregatorFree int
|
|
|
|
|
|
UnknownFree int
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type FreeSourceStat struct {
|
|
|
|
|
|
Label string
|
|
|
|
|
|
Description string
|
|
|
|
|
|
Tone string
|
|
|
|
|
|
Count int
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type ActionItem struct {
|
|
|
|
|
|
Title string
|
|
|
|
|
|
Audience string
|
|
|
|
|
|
Evidence string
|
|
|
|
|
|
Tags []string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type HeadlineItem struct {
|
|
|
|
|
|
Label string
|
|
|
|
|
|
Title string
|
|
|
|
|
|
Summary string
|
|
|
|
|
|
Baseline string
|
|
|
|
|
|
TrustLabel string
|
|
|
|
|
|
Tone string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 21:10:11 +08:00
|
|
|
|
type ModelEvent struct {
|
|
|
|
|
|
EventType string
|
|
|
|
|
|
ModelName string
|
|
|
|
|
|
ProviderName string
|
|
|
|
|
|
OperatorName string
|
|
|
|
|
|
TrustLabel string
|
|
|
|
|
|
Baseline string
|
|
|
|
|
|
Summary string
|
|
|
|
|
|
Currency string
|
|
|
|
|
|
OldInputPrice float64
|
|
|
|
|
|
NewInputPrice float64
|
|
|
|
|
|
OldOutputPrice float64
|
|
|
|
|
|
NewOutputPrice float64
|
|
|
|
|
|
PriceChangePct float64
|
|
|
|
|
|
Priority int
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 20:13:02 +08:00
|
|
|
|
type Recommendation struct {
|
|
|
|
|
|
Name string
|
|
|
|
|
|
Provider string
|
|
|
|
|
|
Operator string
|
|
|
|
|
|
Usage string
|
|
|
|
|
|
PriceSummary string
|
|
|
|
|
|
Evidence string
|
|
|
|
|
|
TrustLabel string
|
|
|
|
|
|
Tags []string
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type SceneSection struct {
|
|
|
|
|
|
Title string
|
|
|
|
|
|
Description string
|
|
|
|
|
|
Lead Recommendation
|
|
|
|
|
|
Others []Recommendation
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type AppendixLink struct {
|
|
|
|
|
|
Title string
|
|
|
|
|
|
Description string
|
|
|
|
|
|
Anchor string
|
2026-05-13 14:42:45 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
2026-05-13 14:42:45 +08:00
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
|
|
|
|
var allModels []ModelInfo
|
|
|
|
|
|
var freeModels []ModelInfo
|
2026-05-13 20:13:02 +08:00
|
|
|
|
var intlModels []ModelInfo // 国际模型(US/EU/unknown)
|
2026-05-13 14:42:45 +08:00
|
|
|
|
var domesticModels []ModelInfo // 国内模型(CN)
|
2026-05-08 13:49:12 +08:00
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
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 {
|
2026-05-08 13:49:12 +08:00
|
|
|
|
freeModels = append(freeModels, m)
|
|
|
|
|
|
}
|
2026-05-13 14:42:45 +08:00
|
|
|
|
|
|
|
|
|
|
// 国家分类 - 国内官方平台 vs OpenRouter上的国内模型
|
2026-05-13 20:13:02 +08:00
|
|
|
|
isDomesticOfficial := (m.ProviderCountry == "CN" || m.ProviderCountry == "cn") &&
|
2026-05-13 14:42:45 +08:00
|
|
|
|
(m.OperatorType == "official" || m.OperatorType == "cloud")
|
2026-05-13 20:13:02 +08:00
|
|
|
|
isDomesticReseller := (m.ProviderCountry == "CN" || m.ProviderCountry == "cn") &&
|
2026-05-13 14:42:45 +08:00
|
|
|
|
m.OperatorType == "reseller"
|
2026-05-13 20:13:02 +08:00
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
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
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
2026-05-13 14:42:45 +08:00
|
|
|
|
|
|
|
|
|
|
// 排序
|
|
|
|
|
|
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
|
|
|
|
|
|
})
|
2026-05-08 13:49:12 +08:00
|
|
|
|
sort.Slice(freeModels, func(i, j int) bool {
|
|
|
|
|
|
return freeModels[i].ContextLength > freeModels[j].ContextLength
|
|
|
|
|
|
})
|
2026-05-13 14:42:45 +08:00
|
|
|
|
|
|
|
|
|
|
// 提取TOP - 国际排除免费,国内包含免费(展示真实低价+免费精选)
|
|
|
|
|
|
var intlTop5 []ModelInfo
|
|
|
|
|
|
intlPaid := filterPaid(intlModels)
|
|
|
|
|
|
if len(intlPaid) > 5 {
|
|
|
|
|
|
intlTop5 = intlPaid[:5]
|
|
|
|
|
|
} else {
|
|
|
|
|
|
intlTop5 = intlPaid
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
var domesticTop10 []ModelInfo
|
|
|
|
|
|
// 国内模型:优先展示付费低价,然后补充免费模型
|
|
|
|
|
|
domesticPaid := filterPaid(domesticModels)
|
|
|
|
|
|
domesticTop10 = append(domesticTop10, domesticPaid...)
|
|
|
|
|
|
// 补充免费国内模型(按上下文排序)
|
|
|
|
|
|
var domesticFree []ModelInfo
|
|
|
|
|
|
for _, m := range domesticModels {
|
|
|
|
|
|
if m.IsFree {
|
|
|
|
|
|
domesticFree = append(domesticFree, m)
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
2026-05-13 14:42:45 +08:00
|
|
|
|
}
|
|
|
|
|
|
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]
|
2026-05-08 13:49:12 +08:00
|
|
|
|
} else {
|
2026-05-13 14:42:45 +08:00
|
|
|
|
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
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 20:13:02 +08:00
|
|
|
|
report := &ReportV3{
|
|
|
|
|
|
Date: date,
|
|
|
|
|
|
GeneratedAt: time.Now().Format(time.RFC3339),
|
|
|
|
|
|
TotalModels: len(allModels),
|
|
|
|
|
|
AllModels: allModels,
|
|
|
|
|
|
FreeModels: freeModels,
|
|
|
|
|
|
FreeTop20: freeTop20,
|
|
|
|
|
|
IntlTop5: intlTop5,
|
|
|
|
|
|
DomesticTop10: domesticTop10,
|
2026-05-13 14:42:45 +08:00
|
|
|
|
TencentSubscriptionPlans: tencentPlans,
|
2026-05-13 20:13:02 +08:00
|
|
|
|
Operators: operators,
|
|
|
|
|
|
Resellers: resellers,
|
|
|
|
|
|
HasCNYData: cny > 0,
|
|
|
|
|
|
HasDomesticData: len(domesticModels) > 0,
|
2026-05-13 14:42:45 +08:00
|
|
|
|
QualitySummary: DataQualitySummary{
|
|
|
|
|
|
Total: len(allModels),
|
|
|
|
|
|
Fresh: fresh,
|
|
|
|
|
|
Stale: stale,
|
|
|
|
|
|
CNY: cny,
|
|
|
|
|
|
USD: usd,
|
|
|
|
|
|
},
|
2026-05-13 20:13:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
if signals, err := loadDailySignals(db, date); err != nil {
|
|
|
|
|
|
logger.Warn("加载日报变化信号失败", "error", err)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
report.DailySignals = signals
|
|
|
|
|
|
}
|
2026-05-13 21:10:11 +08:00
|
|
|
|
if events, err := loadModelEvents(db, date); err != nil {
|
|
|
|
|
|
logger.Warn("加载模型级事件失败", "error", err)
|
|
|
|
|
|
} else {
|
|
|
|
|
|
report.ModelEvents = events
|
|
|
|
|
|
}
|
2026-05-13 20:13:02 +08:00
|
|
|
|
decorateReportV1(report)
|
|
|
|
|
|
return report, nil
|
2026-05-13 14:42:45 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
2026-05-13 14:42:45 +08:00
|
|
|
|
return fmt.Sprintf("%d", value)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 场景标签
|
|
|
|
|
|
type SceneTag string
|
|
|
|
|
|
|
|
|
|
|
|
const (
|
|
|
|
|
|
SceneCode SceneTag = "代码"
|
|
|
|
|
|
SceneReasoning SceneTag = "推理"
|
|
|
|
|
|
SceneWriting SceneTag = "写作"
|
|
|
|
|
|
SceneVision SceneTag = "视觉"
|
|
|
|
|
|
SceneChat SceneTag = "对话"
|
|
|
|
|
|
)
|
2026-05-08 13:49:12 +08:00
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
func deriveSceneTags(name, modality string, capabilities []string) []SceneTag {
|
|
|
|
|
|
var tags []SceneTag
|
|
|
|
|
|
lowerName := strings.ToLower(name)
|
|
|
|
|
|
|
|
|
|
|
|
// 代码模型
|
|
|
|
|
|
if strings.Contains(lowerName, "codex") || strings.Contains(lowerName, "coder") ||
|
2026-05-13 20:13:02 +08:00
|
|
|
|
strings.Contains(lowerName, "code") || strings.Contains(modality, "code") {
|
2026-05-13 14:42:45 +08:00
|
|
|
|
tags = append(tags, SceneCode)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 推理模型
|
|
|
|
|
|
if strings.Contains(lowerName, "o1") || strings.Contains(lowerName, "o3") ||
|
2026-05-13 20:13:02 +08:00
|
|
|
|
strings.Contains(lowerName, "o4") || strings.Contains(lowerName, "reasoning") ||
|
|
|
|
|
|
strings.Contains(lowerName, "r1") || strings.Contains(lowerName, "thinking") {
|
2026-05-13 14:42:45 +08:00
|
|
|
|
tags = append(tags, SceneReasoning)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 视觉模型
|
|
|
|
|
|
if strings.Contains(modality, "vision") || strings.Contains(modality, "multimodal") ||
|
2026-05-13 20:13:02 +08:00
|
|
|
|
strings.Contains(lowerName, "vl") || strings.Contains(lowerName, "vision") {
|
2026-05-13 14:42:45 +08:00
|
|
|
|
tags = append(tags, SceneVision)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 写作/对话(兜底)
|
|
|
|
|
|
if len(tags) == 0 {
|
|
|
|
|
|
if strings.Contains(modality, "text") || strings.Contains(modality, "chat") {
|
|
|
|
|
|
tags = append(tags, SceneChat)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return tags
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 20:13:02 +08:00
|
|
|
|
func loadDailySignals(db *sql.DB, date string) (DailySignals, error) {
|
|
|
|
|
|
signals := DailySignals{}
|
|
|
|
|
|
|
|
|
|
|
|
if err := db.QueryRow(`
|
|
|
|
|
|
SELECT COUNT(*)
|
|
|
|
|
|
FROM models
|
|
|
|
|
|
WHERE deleted_at IS NULL
|
|
|
|
|
|
AND DATE(created_at) = $1::date
|
|
|
|
|
|
`, date).Scan(&signals.NewModels); err != nil {
|
|
|
|
|
|
return signals, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if err := db.QueryRow(`
|
|
|
|
|
|
SELECT COUNT(*)
|
|
|
|
|
|
FROM pricing_history
|
|
|
|
|
|
WHERE DATE(changed_at) = $1::date
|
|
|
|
|
|
`, date).Scan(&signals.PriceChanges); err != nil {
|
|
|
|
|
|
return signals, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return signals, nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 21:10:11 +08:00
|
|
|
|
func loadModelEvents(db *sql.DB, date string) ([]ModelEvent, error) {
|
|
|
|
|
|
var events []ModelEvent
|
|
|
|
|
|
|
|
|
|
|
|
newModelEvents, err := loadNewModelEvents(db, date)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
events = append(events, newModelEvents...)
|
|
|
|
|
|
|
|
|
|
|
|
priceEvents, err := loadPriceChangeEvents(db, date)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
events = append(events, priceEvents...)
|
|
|
|
|
|
|
|
|
|
|
|
sort.Slice(events, func(i, j int) bool {
|
|
|
|
|
|
if events[i].Priority != events[j].Priority {
|
|
|
|
|
|
return events[i].Priority > events[j].Priority
|
|
|
|
|
|
}
|
|
|
|
|
|
return events[i].ModelName < events[j].ModelName
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
return dedupeModelEvents(events), nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func loadNewModelEvents(db *sql.DB, date string) ([]ModelEvent, error) {
|
|
|
|
|
|
rows, err := db.Query(`
|
|
|
|
|
|
WITH latest_prices AS (
|
|
|
|
|
|
SELECT
|
|
|
|
|
|
rp.model_id,
|
|
|
|
|
|
COALESCE(o.name, 'Unknown') AS operator_name,
|
|
|
|
|
|
COALESCE(o.type, 'reseller') AS operator_type,
|
|
|
|
|
|
rp.currency,
|
|
|
|
|
|
rp.input_price_per_mtok,
|
|
|
|
|
|
rp.output_price_per_mtok,
|
|
|
|
|
|
rp.is_free,
|
|
|
|
|
|
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
|
|
|
|
|
|
COALESCE(NULLIF(m.name, ''), m.external_id) AS model_name,
|
|
|
|
|
|
COALESCE(mp.name, split_part(m.external_id, '/', 1)) AS provider_name,
|
|
|
|
|
|
COALESCE(lp.operator_name, 'OpenRouter') AS operator_name,
|
|
|
|
|
|
COALESCE(lp.operator_type, 'reseller') AS operator_type,
|
|
|
|
|
|
COALESCE(lp.currency, 'USD') AS currency,
|
|
|
|
|
|
COALESCE(lp.input_price_per_mtok, 0) AS input_price,
|
|
|
|
|
|
COALESCE(lp.output_price_per_mtok, 0) AS output_price,
|
|
|
|
|
|
COALESCE(lp.is_free, false) AS is_free,
|
|
|
|
|
|
COALESCE(m.context_length, 0) AS context_length,
|
|
|
|
|
|
COALESCE(mp.country, 'unknown') AS provider_country
|
|
|
|
|
|
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
|
|
|
|
|
|
AND DATE(m.created_at) = $1::date
|
|
|
|
|
|
ORDER BY m.created_at DESC, m.id DESC
|
|
|
|
|
|
LIMIT 8
|
|
|
|
|
|
`, date)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
|
|
|
|
var events []ModelEvent
|
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
|
var (
|
|
|
|
|
|
modelName string
|
|
|
|
|
|
providerName string
|
|
|
|
|
|
operatorName string
|
|
|
|
|
|
operatorType string
|
|
|
|
|
|
currency string
|
|
|
|
|
|
inputPrice float64
|
|
|
|
|
|
outputPrice float64
|
|
|
|
|
|
isFree bool
|
|
|
|
|
|
contextLength int
|
|
|
|
|
|
providerCountry string
|
|
|
|
|
|
)
|
|
|
|
|
|
if err := rows.Scan(
|
|
|
|
|
|
&modelName,
|
|
|
|
|
|
&providerName,
|
|
|
|
|
|
&operatorName,
|
|
|
|
|
|
&operatorType,
|
|
|
|
|
|
¤cy,
|
|
|
|
|
|
&inputPrice,
|
|
|
|
|
|
&outputPrice,
|
|
|
|
|
|
&isFree,
|
|
|
|
|
|
&contextLength,
|
|
|
|
|
|
&providerCountry,
|
|
|
|
|
|
); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
model := ModelInfo{
|
|
|
|
|
|
Name: modelName,
|
|
|
|
|
|
ProviderName: providerName,
|
|
|
|
|
|
ProviderCountry: providerCountry,
|
|
|
|
|
|
ContextLength: contextLength,
|
|
|
|
|
|
InputPrice: inputPrice,
|
|
|
|
|
|
OutputPrice: outputPrice,
|
|
|
|
|
|
Currency: currency,
|
|
|
|
|
|
IsFree: isFree,
|
|
|
|
|
|
OperatorName: operatorName,
|
|
|
|
|
|
OperatorType: operatorType,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
summary := "新模型进入情报池,值得重新评估当前默认选择。"
|
|
|
|
|
|
if isFree {
|
|
|
|
|
|
summary = fmt.Sprintf("新模型首日可免费试用,需注意其免费来源属于%s。", classifyFreeSource(model))
|
|
|
|
|
|
} else if contextLength >= 1024*256 {
|
|
|
|
|
|
summary = fmt.Sprintf("新模型带来 %s 长上下文,值得复查 Agent 和代码场景。", formatContextWindowCompact(contextLength))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
events = append(events, ModelEvent{
|
|
|
|
|
|
EventType: "new_model",
|
|
|
|
|
|
ModelName: modelName,
|
|
|
|
|
|
ProviderName: providerName,
|
|
|
|
|
|
OperatorName: operatorName,
|
|
|
|
|
|
TrustLabel: buildTrustLabel(model),
|
|
|
|
|
|
Baseline: "首次出现",
|
|
|
|
|
|
Summary: summary,
|
|
|
|
|
|
Currency: currency,
|
|
|
|
|
|
NewInputPrice: inputPrice,
|
|
|
|
|
|
NewOutputPrice: outputPrice,
|
|
|
|
|
|
Priority: 85 + minInt(contextLength/(1024*128), 10),
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
return events, rows.Err()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func loadPriceChangeEvents(db *sql.DB, date string) ([]ModelEvent, error) {
|
|
|
|
|
|
rows, err := db.Query(`
|
|
|
|
|
|
WITH latest_prices AS (
|
|
|
|
|
|
SELECT
|
|
|
|
|
|
rp.model_id,
|
|
|
|
|
|
COALESCE(o.name, 'Unknown') AS operator_name,
|
|
|
|
|
|
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
|
|
|
|
|
|
COALESCE(NULLIF(m.name, ''), m.external_id) AS model_name,
|
|
|
|
|
|
COALESCE(mp.name, split_part(m.external_id, '/', 1)) AS provider_name,
|
|
|
|
|
|
COALESCE(lp.operator_name, 'OpenRouter') AS operator_name,
|
|
|
|
|
|
COALESCE(lp.operator_type, 'reseller') AS operator_type,
|
|
|
|
|
|
ph.currency,
|
|
|
|
|
|
COALESCE(ph.old_input_price, 0),
|
|
|
|
|
|
COALESCE(ph.new_input_price, 0),
|
|
|
|
|
|
COALESCE(ph.old_output_price, 0),
|
|
|
|
|
|
COALESCE(ph.new_output_price, 0),
|
|
|
|
|
|
COALESCE(mp.country, 'unknown') AS provider_country
|
|
|
|
|
|
FROM pricing_history ph
|
|
|
|
|
|
JOIN models m ON ph.model_id = m.id
|
|
|
|
|
|
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 DATE(ph.changed_at) = $1::date
|
|
|
|
|
|
ORDER BY ph.changed_at DESC, ph.id DESC
|
|
|
|
|
|
LIMIT 16
|
|
|
|
|
|
`, date)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
defer rows.Close()
|
|
|
|
|
|
|
|
|
|
|
|
var events []ModelEvent
|
|
|
|
|
|
for rows.Next() {
|
|
|
|
|
|
var (
|
|
|
|
|
|
modelName string
|
|
|
|
|
|
providerName string
|
|
|
|
|
|
operatorName string
|
|
|
|
|
|
operatorType string
|
|
|
|
|
|
currency string
|
|
|
|
|
|
oldInputPrice float64
|
|
|
|
|
|
newInputPrice float64
|
|
|
|
|
|
oldOutputPrice float64
|
|
|
|
|
|
newOutputPrice float64
|
|
|
|
|
|
providerCountry string
|
|
|
|
|
|
)
|
|
|
|
|
|
if err := rows.Scan(
|
|
|
|
|
|
&modelName,
|
|
|
|
|
|
&providerName,
|
|
|
|
|
|
&operatorName,
|
|
|
|
|
|
&operatorType,
|
|
|
|
|
|
¤cy,
|
|
|
|
|
|
&oldInputPrice,
|
|
|
|
|
|
&newInputPrice,
|
|
|
|
|
|
&oldOutputPrice,
|
|
|
|
|
|
&newOutputPrice,
|
|
|
|
|
|
&providerCountry,
|
|
|
|
|
|
); err != nil {
|
|
|
|
|
|
return nil, err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
changePct := signedPriceChangePct(oldInputPrice, newInputPrice, oldOutputPrice, newOutputPrice)
|
|
|
|
|
|
if changePct == 0 {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
model := ModelInfo{
|
|
|
|
|
|
Name: modelName,
|
|
|
|
|
|
ProviderName: providerName,
|
|
|
|
|
|
ProviderCountry: providerCountry,
|
|
|
|
|
|
Currency: currency,
|
|
|
|
|
|
OperatorName: operatorName,
|
|
|
|
|
|
OperatorType: operatorType,
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
eventType := "price_increase"
|
|
|
|
|
|
label := "价格上调"
|
|
|
|
|
|
summary := "价格上调已足以影响默认成本,需要确认备用模型。"
|
|
|
|
|
|
if changePct < 0 {
|
|
|
|
|
|
eventType = "price_cut"
|
|
|
|
|
|
label = "价格下调"
|
|
|
|
|
|
summary = "价格下降已足以影响默认选型,值得重新评估同类模型。"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
events = append(events, ModelEvent{
|
|
|
|
|
|
EventType: eventType,
|
|
|
|
|
|
ModelName: modelName,
|
|
|
|
|
|
ProviderName: providerName,
|
|
|
|
|
|
OperatorName: operatorName,
|
|
|
|
|
|
TrustLabel: buildTrustLabel(model),
|
|
|
|
|
|
Baseline: fmt.Sprintf("较昨日 %+.0f%%", changePct),
|
|
|
|
|
|
Summary: summary,
|
|
|
|
|
|
Currency: currency,
|
|
|
|
|
|
OldInputPrice: oldInputPrice,
|
|
|
|
|
|
NewInputPrice: newInputPrice,
|
|
|
|
|
|
OldOutputPrice: oldOutputPrice,
|
|
|
|
|
|
NewOutputPrice: newOutputPrice,
|
|
|
|
|
|
PriceChangePct: changePct,
|
|
|
|
|
|
Priority: 70 + minInt(int(abs(changePct)), 25),
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
_ = label
|
|
|
|
|
|
}
|
|
|
|
|
|
return events, rows.Err()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func dedupeModelEvents(events []ModelEvent) []ModelEvent {
|
|
|
|
|
|
seen := make(map[string]struct{})
|
|
|
|
|
|
result := make([]ModelEvent, 0, len(events))
|
|
|
|
|
|
for _, event := range events {
|
|
|
|
|
|
key := event.EventType + "|" + event.ModelName
|
|
|
|
|
|
if _, exists := seen[key]; exists {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
seen[key] = struct{}{}
|
|
|
|
|
|
result = append(result, event)
|
|
|
|
|
|
}
|
|
|
|
|
|
return result
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func signedPriceChangePct(oldInput, newInput, oldOutput, newOutput float64) float64 {
|
|
|
|
|
|
inputPct := signedChange(oldInput, newInput)
|
|
|
|
|
|
outputPct := signedChange(oldOutput, newOutput)
|
|
|
|
|
|
if abs(inputPct) >= abs(outputPct) {
|
|
|
|
|
|
return inputPct
|
|
|
|
|
|
}
|
|
|
|
|
|
return outputPct
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func signedChange(oldValue, newValue float64) float64 {
|
|
|
|
|
|
if oldValue == 0 {
|
|
|
|
|
|
if newValue == 0 {
|
|
|
|
|
|
return 0
|
|
|
|
|
|
}
|
|
|
|
|
|
return 100
|
|
|
|
|
|
}
|
|
|
|
|
|
return ((newValue - oldValue) / oldValue) * 100
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func minInt(a, b int) int {
|
|
|
|
|
|
if a < b {
|
|
|
|
|
|
return a
|
|
|
|
|
|
}
|
|
|
|
|
|
return b
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func abs(v float64) float64 {
|
|
|
|
|
|
if v < 0 {
|
|
|
|
|
|
return -v
|
|
|
|
|
|
}
|
|
|
|
|
|
return v
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 20:13:02 +08:00
|
|
|
|
func decorateReportV1(r *ReportV3) {
|
|
|
|
|
|
if r == nil {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
r.FreeBreakdown = buildFreeSourceBreakdown(r.FreeModels)
|
|
|
|
|
|
for _, item := range r.FreeBreakdown {
|
|
|
|
|
|
switch item.Label {
|
|
|
|
|
|
case "官方免费":
|
|
|
|
|
|
r.DailySignals.OfficialFree = item.Count
|
|
|
|
|
|
case "聚合免费":
|
|
|
|
|
|
r.DailySignals.AggregatorFree = item.Count
|
|
|
|
|
|
case "待确认":
|
|
|
|
|
|
r.DailySignals.UnknownFree = item.Count
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
r.PageMode = buildPageMode(r.DailySignals)
|
|
|
|
|
|
r.MarketLabels = buildMarketLabels(r)
|
|
|
|
|
|
r.HeroSummary, r.HeroEvidence = buildHeroSummary(r)
|
|
|
|
|
|
r.SceneSections = buildSceneSections(r)
|
2026-05-13 21:10:11 +08:00
|
|
|
|
r.ModelEvents = enrichModelEvents(r)
|
2026-05-13 20:13:02 +08:00
|
|
|
|
r.ActionItems = buildActionItems(r)
|
|
|
|
|
|
r.HeadlineItems = buildHeadlineItems(r)
|
|
|
|
|
|
r.AppendixLinks = []AppendixLink{
|
|
|
|
|
|
{Title: "完整价格", Description: "查看完整模型价格表", Anchor: "#appendix-pricing"},
|
|
|
|
|
|
{Title: "完整免费", Description: "查看全部免费模型与来源", Anchor: "#appendix-free"},
|
|
|
|
|
|
{Title: "平台覆盖", Description: "查看官方平台与聚合平台覆盖", Anchor: "#appendix-platforms"},
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 21:10:11 +08:00
|
|
|
|
func enrichModelEvents(r *ReportV3) []ModelEvent {
|
|
|
|
|
|
events := append([]ModelEvent{}, r.ModelEvents...)
|
|
|
|
|
|
existing := make(map[string]struct{}, len(events))
|
|
|
|
|
|
for _, event := range events {
|
|
|
|
|
|
existing[event.EventType+"|"+event.ModelName] = struct{}{}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
addFreeHighlight := func(model ModelInfo, priority int) {
|
|
|
|
|
|
key := "free_highlight|" + model.Name
|
|
|
|
|
|
if _, exists := existing[key]; exists {
|
|
|
|
|
|
return
|
|
|
|
|
|
}
|
|
|
|
|
|
existing[key] = struct{}{}
|
|
|
|
|
|
events = append(events, ModelEvent{
|
|
|
|
|
|
EventType: "free_highlight",
|
|
|
|
|
|
ModelName: model.Name,
|
|
|
|
|
|
ProviderName: model.ProviderName,
|
|
|
|
|
|
OperatorName: model.OperatorName,
|
|
|
|
|
|
TrustLabel: buildTrustLabel(model),
|
|
|
|
|
|
Baseline: "今日快照",
|
|
|
|
|
|
Summary: buildModelEvidence(model),
|
|
|
|
|
|
Currency: model.Currency,
|
|
|
|
|
|
Priority: priority,
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
for _, model := range r.FreeModels {
|
|
|
|
|
|
if classifyFreeSource(model) == "官方免费" {
|
|
|
|
|
|
addFreeHighlight(model, 72)
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, model := range r.FreeModels {
|
|
|
|
|
|
if classifyFreeSource(model) == "聚合免费" {
|
|
|
|
|
|
addFreeHighlight(model, 68)
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
sort.Slice(events, func(i, j int) bool {
|
|
|
|
|
|
if events[i].Priority != events[j].Priority {
|
|
|
|
|
|
return events[i].Priority > events[j].Priority
|
|
|
|
|
|
}
|
|
|
|
|
|
return events[i].ModelName < events[j].ModelName
|
|
|
|
|
|
})
|
|
|
|
|
|
return events
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 20:13:02 +08:00
|
|
|
|
func buildFreeSourceBreakdown(models []ModelInfo) []FreeSourceStat {
|
|
|
|
|
|
counts := map[string]int{
|
|
|
|
|
|
"官方免费": 0,
|
|
|
|
|
|
"聚合免费": 0,
|
|
|
|
|
|
"待确认": 0,
|
|
|
|
|
|
}
|
|
|
|
|
|
for _, model := range models {
|
|
|
|
|
|
counts[classifyFreeSource(model)]++
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
order := []FreeSourceStat{
|
|
|
|
|
|
{Label: "官方免费", Description: "官方或云厂商直接提供免费能力", Tone: "official", Count: counts["官方免费"]},
|
|
|
|
|
|
{Label: "聚合免费", Description: "主流聚合平台提供免费路由或免费变体", Tone: "aggregator", Count: counts["聚合免费"]},
|
|
|
|
|
|
{Label: "待确认", Description: "免费机制或来源仍需进一步核验", Tone: "caution", Count: counts["待确认"]},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var result []FreeSourceStat
|
|
|
|
|
|
for _, item := range order {
|
|
|
|
|
|
if item.Count > 0 {
|
|
|
|
|
|
result = append(result, item)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return result
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func classifyFreeSource(model ModelInfo) string {
|
|
|
|
|
|
switch model.OperatorType {
|
|
|
|
|
|
case "official", "cloud":
|
|
|
|
|
|
return "官方免费"
|
|
|
|
|
|
case "reseller":
|
|
|
|
|
|
if isVerifiedAggregator(model.OperatorName) {
|
|
|
|
|
|
return "聚合免费"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return "待确认"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func isVerifiedAggregator(name string) bool {
|
|
|
|
|
|
normalized := strings.ToLower(strings.TrimSpace(name))
|
|
|
|
|
|
switch normalized {
|
|
|
|
|
|
case "openrouter", "siliconflow", "fireworks", "groq":
|
|
|
|
|
|
return true
|
|
|
|
|
|
default:
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func buildPageMode(signals DailySignals) string {
|
|
|
|
|
|
if signals.NewModels == 0 && signals.PriceChanges == 0 {
|
|
|
|
|
|
return "calm"
|
|
|
|
|
|
}
|
|
|
|
|
|
if signals.NewModels+signals.PriceChanges >= 3 {
|
|
|
|
|
|
return "hot"
|
|
|
|
|
|
}
|
|
|
|
|
|
return "standard"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func buildMarketLabels(r *ReportV3) []string {
|
|
|
|
|
|
labels := []string{}
|
|
|
|
|
|
switch r.PageMode {
|
|
|
|
|
|
case "hot":
|
|
|
|
|
|
labels = append(labels, "热点日")
|
|
|
|
|
|
case "calm":
|
|
|
|
|
|
labels = append(labels, "平静日")
|
|
|
|
|
|
default:
|
|
|
|
|
|
labels = append(labels, "常规日")
|
|
|
|
|
|
}
|
|
|
|
|
|
if r.DailySignals.NewModels > 0 {
|
|
|
|
|
|
labels = append(labels, "新模型日")
|
|
|
|
|
|
}
|
|
|
|
|
|
if r.DailySignals.PriceChanges > 0 {
|
|
|
|
|
|
labels = append(labels, "价格波动")
|
|
|
|
|
|
}
|
|
|
|
|
|
if r.DailySignals.AggregatorFree > r.DailySignals.OfficialFree {
|
|
|
|
|
|
labels = append(labels, "聚合免费偏多")
|
|
|
|
|
|
} else if r.DailySignals.OfficialFree > 0 {
|
|
|
|
|
|
labels = append(labels, "官方免费可看")
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(labels) > 3 {
|
|
|
|
|
|
return labels[:3]
|
|
|
|
|
|
}
|
|
|
|
|
|
return labels
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func buildHeroSummary(r *ReportV3) (string, string) {
|
|
|
|
|
|
switch r.PageMode {
|
|
|
|
|
|
case "hot":
|
|
|
|
|
|
return fmt.Sprintf(
|
|
|
|
|
|
"今天最值得关注的是 %d 个新模型与 %d 次价格变化同时出现,免费机会仍以聚合平台为主。",
|
|
|
|
|
|
r.DailySignals.NewModels,
|
|
|
|
|
|
r.DailySignals.PriceChanges,
|
|
|
|
|
|
), fmt.Sprintf("官方免费 %d 个,聚合免费 %d 个", r.DailySignals.OfficialFree, r.DailySignals.AggregatorFree)
|
|
|
|
|
|
case "calm":
|
|
|
|
|
|
return "今天没有大规模上新或明显价格波动,优先关注稳定商用与低成本选择。", "观察重点转向稳定推荐与来源可信度"
|
|
|
|
|
|
default:
|
|
|
|
|
|
return fmt.Sprintf(
|
|
|
|
|
|
"今天有 %d 个新模型和 %d 次价格变化,值得优先复查低成本与来源清晰的可用选择。",
|
|
|
|
|
|
r.DailySignals.NewModels,
|
|
|
|
|
|
r.DailySignals.PriceChanges,
|
|
|
|
|
|
), fmt.Sprintf("免费来源分层:官方 %d / 聚合 %d / 待确认 %d", r.DailySignals.OfficialFree, r.DailySignals.AggregatorFree, r.DailySignals.UnknownFree)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func buildHeadlineItems(r *ReportV3) []HeadlineItem {
|
2026-05-13 21:10:11 +08:00
|
|
|
|
if items := buildHeadlineItemsFromEvents(r.ModelEvents); len(items) > 0 {
|
|
|
|
|
|
return items
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 20:13:02 +08:00
|
|
|
|
var items []HeadlineItem
|
|
|
|
|
|
|
|
|
|
|
|
if r.DailySignals.NewModels > 0 {
|
|
|
|
|
|
items = append(items, HeadlineItem{
|
|
|
|
|
|
Label: "新模型",
|
|
|
|
|
|
Title: fmt.Sprintf("今日新增 %d 个模型进入情报池", r.DailySignals.NewModels),
|
|
|
|
|
|
Summary: "建议优先复查今天的新上架模型是否改变当前低成本或中文场景的最优解。",
|
|
|
|
|
|
Baseline: "首次出现",
|
|
|
|
|
|
TrustLabel: "数据库追踪",
|
|
|
|
|
|
Tone: "info",
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
if r.DailySignals.PriceChanges > 0 {
|
|
|
|
|
|
items = append(items, HeadlineItem{
|
|
|
|
|
|
Label: "价格变化",
|
|
|
|
|
|
Title: fmt.Sprintf("今日检测到 %d 次价格变化", r.DailySignals.PriceChanges),
|
|
|
|
|
|
Summary: "价格变化已足以影响低成本选型,建议重新确认默认模型与备用模型。",
|
|
|
|
|
|
Baseline: "较昨日",
|
|
|
|
|
|
TrustLabel: "价格快照",
|
|
|
|
|
|
Tone: "success",
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if r.DailySignals.AggregatorFree > 0 || r.DailySignals.OfficialFree > 0 || r.DailySignals.UnknownFree > 0 {
|
|
|
|
|
|
items = append(items, HeadlineItem{
|
|
|
|
|
|
Label: "免费策略",
|
|
|
|
|
|
Title: "免费机会主要来自聚合平台,不等于官方长期免费",
|
|
|
|
|
|
Summary: fmt.Sprintf("官方免费 %d 个,聚合免费 %d 个,待确认 %d 个。", r.DailySignals.OfficialFree, r.DailySignals.AggregatorFree, r.DailySignals.UnknownFree),
|
|
|
|
|
|
Baseline: "今日快照",
|
|
|
|
|
|
TrustLabel: "来源已分层",
|
|
|
|
|
|
Tone: "warning",
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if len(items) == 0 {
|
|
|
|
|
|
items = append(items, HeadlineItem{
|
|
|
|
|
|
Label: "观察重点",
|
|
|
|
|
|
Title: "今日无重大上新或显著调价",
|
|
|
|
|
|
Summary: "平静日优先关注稳定商用与长期成本,避免被噪音列表打断判断。",
|
|
|
|
|
|
Baseline: "较昨日",
|
|
|
|
|
|
TrustLabel: "日报编辑规则",
|
|
|
|
|
|
Tone: "neutral",
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if len(items) > 3 {
|
|
|
|
|
|
return items[:3]
|
|
|
|
|
|
}
|
|
|
|
|
|
return items
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 21:10:11 +08:00
|
|
|
|
func buildHeadlineItemsFromEvents(events []ModelEvent) []HeadlineItem {
|
|
|
|
|
|
if len(events) == 0 {
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
sort.Slice(events, func(i, j int) bool {
|
|
|
|
|
|
if events[i].Priority != events[j].Priority {
|
|
|
|
|
|
return events[i].Priority > events[j].Priority
|
|
|
|
|
|
}
|
|
|
|
|
|
return events[i].ModelName < events[j].ModelName
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
var items []HeadlineItem
|
|
|
|
|
|
usedModels := make(map[string]struct{})
|
|
|
|
|
|
for _, event := range dedupeModelEvents(events) {
|
|
|
|
|
|
if _, exists := usedModels[event.ModelName]; exists {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
usedModels[event.ModelName] = struct{}{}
|
|
|
|
|
|
items = append(items, headlineItemFromModelEvent(event))
|
|
|
|
|
|
if len(items) >= 3 {
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return items
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func headlineItemFromModelEvent(event ModelEvent) HeadlineItem {
|
|
|
|
|
|
item := HeadlineItem{
|
|
|
|
|
|
Title: event.ModelName,
|
|
|
|
|
|
Summary: event.Summary,
|
|
|
|
|
|
Baseline: event.Baseline,
|
|
|
|
|
|
TrustLabel: event.TrustLabel,
|
|
|
|
|
|
Tone: "neutral",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
switch event.EventType {
|
|
|
|
|
|
case "new_model":
|
|
|
|
|
|
item.Label = "新模型"
|
|
|
|
|
|
item.Title = fmt.Sprintf("%s 进入今日情报池", event.ModelName)
|
|
|
|
|
|
item.Tone = "info"
|
|
|
|
|
|
case "price_cut":
|
|
|
|
|
|
item.Label = "价格下调"
|
|
|
|
|
|
item.Title = fmt.Sprintf("%s 成本下调 %.0f%%", event.ModelName, abs(event.PriceChangePct))
|
|
|
|
|
|
item.Tone = "success"
|
|
|
|
|
|
case "price_increase":
|
|
|
|
|
|
item.Label = "价格上调"
|
|
|
|
|
|
item.Title = fmt.Sprintf("%s 成本上调 %.0f%%", event.ModelName, abs(event.PriceChangePct))
|
|
|
|
|
|
item.Tone = "caution"
|
|
|
|
|
|
case "free_highlight":
|
|
|
|
|
|
item.Label = "免费机会"
|
|
|
|
|
|
item.Title = fmt.Sprintf("%s 当前可免费试用", event.ModelName)
|
|
|
|
|
|
item.Tone = "warning"
|
|
|
|
|
|
default:
|
|
|
|
|
|
item.Label = "观察重点"
|
|
|
|
|
|
item.Title = fmt.Sprintf("%s 值得关注", event.ModelName)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return item
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 20:13:02 +08:00
|
|
|
|
func buildActionItems(r *ReportV3) []ActionItem {
|
|
|
|
|
|
var actions []ActionItem
|
|
|
|
|
|
|
|
|
|
|
|
if section := findSceneSection(r.SceneSections, "低成本编码"); section != nil {
|
|
|
|
|
|
actions = append(actions, ActionItem{
|
|
|
|
|
|
Title: fmt.Sprintf("今天先看 %s", section.Lead.Name),
|
|
|
|
|
|
Audience: "适合控制编码与推理成本的团队",
|
|
|
|
|
|
Evidence: section.Lead.Evidence,
|
|
|
|
|
|
Tags: []string{"低成本编码", section.Lead.TrustLabel},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
if section := findSceneSection(r.SceneSections, "中文通用"); section != nil {
|
|
|
|
|
|
actions = append(actions, ActionItem{
|
|
|
|
|
|
Title: fmt.Sprintf("正式上线优先 %s", section.Lead.Name),
|
|
|
|
|
|
Audience: "适合中文业务和稳定商用场景",
|
|
|
|
|
|
Evidence: section.Lead.Evidence,
|
|
|
|
|
|
Tags: []string{"中文通用", section.Lead.TrustLabel},
|
|
|
|
|
|
})
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
actions = append(actions, ActionItem{
|
|
|
|
|
|
Title: "免费尝鲜先区分来源",
|
|
|
|
|
|
Audience: "适合想快速试用免费模型的读者",
|
|
|
|
|
|
Evidence: fmt.Sprintf("官方免费 %d 个,聚合免费 %d 个,待确认 %d 个", r.DailySignals.OfficialFree, r.DailySignals.AggregatorFree, r.DailySignals.UnknownFree),
|
|
|
|
|
|
Tags: []string{"免费策略", "来源分层"},
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
if len(actions) > 3 {
|
|
|
|
|
|
return actions[:3]
|
|
|
|
|
|
}
|
|
|
|
|
|
return actions
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func findSceneSection(sections []SceneSection, title string) *SceneSection {
|
|
|
|
|
|
for i := range sections {
|
|
|
|
|
|
if sections[i].Title == title {
|
|
|
|
|
|
return §ions[i]
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func buildSceneSections(r *ReportV3) []SceneSection {
|
|
|
|
|
|
allModels := r.AllModels
|
|
|
|
|
|
if len(allModels) == 0 {
|
|
|
|
|
|
allModels = append(allModels, r.IntlTop5...)
|
|
|
|
|
|
allModels = append(allModels, r.DomesticTop10...)
|
|
|
|
|
|
allModels = append(allModels, r.FreeModels...)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
builders := []struct {
|
|
|
|
|
|
title string
|
|
|
|
|
|
description string
|
|
|
|
|
|
filter func(ModelInfo) bool
|
|
|
|
|
|
sorter func(a, b ModelInfo) bool
|
|
|
|
|
|
}{
|
|
|
|
|
|
{
|
|
|
|
|
|
title: "低成本编码",
|
|
|
|
|
|
description: "优先看能明显降低编码与工具调用成本的模型。",
|
|
|
|
|
|
filter: func(m ModelInfo) bool {
|
|
|
|
|
|
return hasSceneTag(m, SceneCode)
|
|
|
|
|
|
},
|
|
|
|
|
|
sorter: func(a, b ModelInfo) bool {
|
|
|
|
|
|
return compareByCostAndTrust(a, b)
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: "中文通用",
|
|
|
|
|
|
description: "优先看中文业务、写作和稳定对话能力。",
|
|
|
|
|
|
filter: func(m ModelInfo) bool {
|
|
|
|
|
|
return strings.EqualFold(m.ProviderCountry, "CN") || hasSceneTag(m, SceneWriting) || hasSceneTag(m, SceneChat)
|
|
|
|
|
|
},
|
|
|
|
|
|
sorter: func(a, b ModelInfo) bool {
|
|
|
|
|
|
return compareByTrustThenPrice(a, b)
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: "Agent / 工具调用",
|
|
|
|
|
|
description: "优先看推理、代码和长上下文能力。",
|
|
|
|
|
|
filter: func(m ModelInfo) bool {
|
|
|
|
|
|
return hasSceneTag(m, SceneReasoning) || hasSceneTag(m, SceneCode)
|
|
|
|
|
|
},
|
|
|
|
|
|
sorter: func(a, b ModelInfo) bool {
|
|
|
|
|
|
if a.ContextLength != b.ContextLength {
|
|
|
|
|
|
return a.ContextLength > b.ContextLength
|
|
|
|
|
|
}
|
|
|
|
|
|
return compareByTrustThenPrice(a, b)
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: "视觉 / 多模态",
|
|
|
|
|
|
description: "优先看视觉、多模态和图像理解相关模型。",
|
|
|
|
|
|
filter: func(m ModelInfo) bool {
|
|
|
|
|
|
return hasSceneTag(m, SceneVision)
|
|
|
|
|
|
},
|
|
|
|
|
|
sorter: func(a, b ModelInfo) bool {
|
|
|
|
|
|
return compareByTrustThenPrice(a, b)
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
var sections []SceneSection
|
|
|
|
|
|
for _, builder := range builders {
|
|
|
|
|
|
var matches []ModelInfo
|
|
|
|
|
|
for _, model := range allModels {
|
|
|
|
|
|
if builder.filter(model) {
|
|
|
|
|
|
matches = append(matches, model)
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(matches) == 0 {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
sort.Slice(matches, func(i, j int) bool {
|
|
|
|
|
|
return builder.sorter(matches[i], matches[j])
|
|
|
|
|
|
})
|
|
|
|
|
|
|
|
|
|
|
|
recommendations := buildRecommendations(matches, 3)
|
|
|
|
|
|
if len(recommendations) == 0 {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
section := SceneSection{
|
|
|
|
|
|
Title: builder.title,
|
|
|
|
|
|
Description: builder.description,
|
|
|
|
|
|
Lead: recommendations[0],
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(recommendations) > 1 {
|
|
|
|
|
|
section.Others = recommendations[1:]
|
|
|
|
|
|
}
|
|
|
|
|
|
sections = append(sections, section)
|
|
|
|
|
|
}
|
|
|
|
|
|
return sections
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func buildRecommendations(models []ModelInfo, limit int) []Recommendation {
|
|
|
|
|
|
seen := make(map[string]struct{})
|
|
|
|
|
|
var result []Recommendation
|
|
|
|
|
|
for _, model := range models {
|
|
|
|
|
|
key := model.Name + "|" + model.OperatorName
|
|
|
|
|
|
if _, exists := seen[key]; exists {
|
|
|
|
|
|
continue
|
|
|
|
|
|
}
|
|
|
|
|
|
seen[key] = struct{}{}
|
|
|
|
|
|
result = append(result, Recommendation{
|
|
|
|
|
|
Name: model.Name,
|
|
|
|
|
|
Provider: model.ProviderName,
|
|
|
|
|
|
Operator: model.OperatorName,
|
|
|
|
|
|
Usage: buildUsage(model),
|
|
|
|
|
|
PriceSummary: buildPriceSummary(model),
|
|
|
|
|
|
Evidence: buildModelEvidence(model),
|
|
|
|
|
|
TrustLabel: buildTrustLabel(model),
|
|
|
|
|
|
Tags: buildModelTags(model),
|
|
|
|
|
|
})
|
|
|
|
|
|
if len(result) >= limit {
|
|
|
|
|
|
break
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return result
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func hasSceneTag(model ModelInfo, target SceneTag) bool {
|
|
|
|
|
|
for _, tag := range model.SceneTags {
|
|
|
|
|
|
if tag == target {
|
|
|
|
|
|
return true
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return false
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func compareByCostAndTrust(a, b ModelInfo) bool {
|
|
|
|
|
|
if a.IsFree != b.IsFree {
|
|
|
|
|
|
return a.IsFree
|
|
|
|
|
|
}
|
|
|
|
|
|
if trustRank(a) != trustRank(b) {
|
|
|
|
|
|
return trustRank(a) < trustRank(b)
|
|
|
|
|
|
}
|
|
|
|
|
|
if normalizePrice(a.InputPrice, a.Currency) != normalizePrice(b.InputPrice, b.Currency) {
|
|
|
|
|
|
return normalizePrice(a.InputPrice, a.Currency) < normalizePrice(b.InputPrice, b.Currency)
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.ContextLength > b.ContextLength
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func compareByTrustThenPrice(a, b ModelInfo) bool {
|
|
|
|
|
|
if trustRank(a) != trustRank(b) {
|
|
|
|
|
|
return trustRank(a) < trustRank(b)
|
|
|
|
|
|
}
|
|
|
|
|
|
if a.IsFree != b.IsFree {
|
|
|
|
|
|
return a.IsFree
|
|
|
|
|
|
}
|
|
|
|
|
|
if normalizePrice(a.InputPrice, a.Currency) != normalizePrice(b.InputPrice, b.Currency) {
|
|
|
|
|
|
return normalizePrice(a.InputPrice, a.Currency) < normalizePrice(b.InputPrice, b.Currency)
|
|
|
|
|
|
}
|
|
|
|
|
|
return a.ContextLength > b.ContextLength
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func normalizePrice(price float64, currency string) float64 {
|
|
|
|
|
|
if price <= 0 {
|
|
|
|
|
|
return 0
|
|
|
|
|
|
}
|
|
|
|
|
|
if currency == "USD" {
|
|
|
|
|
|
return price * USD_TO_CNY
|
|
|
|
|
|
}
|
|
|
|
|
|
return price
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func trustRank(model ModelInfo) int {
|
|
|
|
|
|
switch model.OperatorType {
|
|
|
|
|
|
case "official", "cloud":
|
|
|
|
|
|
return 0
|
|
|
|
|
|
case "reseller":
|
|
|
|
|
|
if isVerifiedAggregator(model.OperatorName) {
|
|
|
|
|
|
return 1
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return 2
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func buildUsage(model ModelInfo) string {
|
|
|
|
|
|
switch {
|
|
|
|
|
|
case hasSceneTag(model, SceneVision):
|
|
|
|
|
|
return "适合视觉与多模态"
|
|
|
|
|
|
case hasSceneTag(model, SceneCode):
|
|
|
|
|
|
return "适合编码与工具调用"
|
|
|
|
|
|
case hasSceneTag(model, SceneReasoning):
|
|
|
|
|
|
return "适合推理与 Agent"
|
|
|
|
|
|
case hasSceneTag(model, SceneWriting):
|
|
|
|
|
|
return "适合中文写作与通用对话"
|
|
|
|
|
|
default:
|
|
|
|
|
|
return "适合通用场景"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func buildPriceSummary(model ModelInfo) string {
|
|
|
|
|
|
if model.IsFree || model.InputPrice <= 0 {
|
|
|
|
|
|
return "免费"
|
|
|
|
|
|
}
|
|
|
|
|
|
return fmt.Sprintf("输入 %s/M", formatPrice(model.InputPrice, model.Currency))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func buildModelEvidence(model ModelInfo) string {
|
|
|
|
|
|
if model.IsFree {
|
|
|
|
|
|
switch classifyFreeSource(model) {
|
|
|
|
|
|
case "官方免费":
|
|
|
|
|
|
return "官方免费额度已确认"
|
|
|
|
|
|
case "聚合免费":
|
|
|
|
|
|
return "聚合免费,适合尝鲜"
|
|
|
|
|
|
default:
|
|
|
|
|
|
return "免费机制仍待确认"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if model.ContextLength >= 1024*256 {
|
|
|
|
|
|
return fmt.Sprintf("长上下文 %s", formatContextWindowCompact(model.ContextLength))
|
|
|
|
|
|
}
|
|
|
|
|
|
return fmt.Sprintf("输入 %s/M", formatPrice(model.InputPrice, model.Currency))
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func buildTrustLabel(model ModelInfo) string {
|
|
|
|
|
|
switch model.OperatorType {
|
|
|
|
|
|
case "official", "cloud":
|
|
|
|
|
|
return "官方来源"
|
|
|
|
|
|
case "reseller":
|
|
|
|
|
|
if isVerifiedAggregator(model.OperatorName) {
|
|
|
|
|
|
return "聚合来源"
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return "待验证来源"
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func buildModelTags(model ModelInfo) []string {
|
|
|
|
|
|
tags := []string{buildTrustLabel(model)}
|
|
|
|
|
|
if model.IsFree {
|
|
|
|
|
|
tags = append(tags, classifyFreeSource(model))
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(model.SceneTags) > 0 {
|
|
|
|
|
|
tags = append(tags, string(model.SceneTags[0]))
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(tags) > 3 {
|
|
|
|
|
|
return tags[:3]
|
|
|
|
|
|
}
|
|
|
|
|
|
return tags
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
// ============ Markdown生成 ============
|
|
|
|
|
|
|
|
|
|
|
|
func generateMarkdownV3(r *ReportV3, path string) error {
|
2026-05-13 20:13:02 +08:00
|
|
|
|
decorateReportV1(r)
|
|
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
f, err := os.Create(path)
|
2026-05-08 13:49:12 +08:00
|
|
|
|
if err != nil {
|
2026-05-13 14:42:45 +08:00
|
|
|
|
return err
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
defer f.Close()
|
|
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
fmt.Fprintf(f, "# 🤖 LLM Intelligence Hub - 每日情报报告\n\n")
|
2026-05-13 20:13:02 +08:00
|
|
|
|
fmt.Fprintf(f, "**报告日期**: %s \n**生成时间**: %s \n**页面状态**: %s \n\n", r.Date, r.GeneratedAt, r.PageMode)
|
2026-05-13 14:42:45 +08:00
|
|
|
|
|
2026-05-13 20:13:02 +08:00
|
|
|
|
fmt.Fprintf(f, "## 今日结论\n\n")
|
|
|
|
|
|
fmt.Fprintf(f, "> %s\n\n", r.HeroSummary)
|
|
|
|
|
|
if r.HeroEvidence != "" {
|
|
|
|
|
|
fmt.Fprintf(f, "- 证据: %s\n", r.HeroEvidence)
|
|
|
|
|
|
}
|
|
|
|
|
|
if len(r.MarketLabels) > 0 {
|
|
|
|
|
|
fmt.Fprintf(f, "- 市场标签: %s\n", strings.Join(r.MarketLabels, " / "))
|
|
|
|
|
|
}
|
|
|
|
|
|
fmt.Fprintf(f, "\n")
|
|
|
|
|
|
|
|
|
|
|
|
fmt.Fprintf(f, "## 今日行动建议\n\n")
|
|
|
|
|
|
for i, item := range r.ActionItems {
|
|
|
|
|
|
fmt.Fprintf(f, "%d. **%s** \n", i+1, item.Title)
|
|
|
|
|
|
fmt.Fprintf(f, " %s \n", item.Audience)
|
|
|
|
|
|
fmt.Fprintf(f, " 证据: %s \n", item.Evidence)
|
|
|
|
|
|
if len(item.Tags) > 0 {
|
|
|
|
|
|
fmt.Fprintf(f, " 标签: %s\n", strings.Join(item.Tags, " / "))
|
2026-05-13 14:42:45 +08:00
|
|
|
|
}
|
2026-05-13 20:13:02 +08:00
|
|
|
|
}
|
|
|
|
|
|
fmt.Fprintf(f, "\n")
|
2026-05-13 14:42:45 +08:00
|
|
|
|
|
2026-05-13 20:13:02 +08:00
|
|
|
|
fmt.Fprintf(f, "## 今日变化\n\n")
|
|
|
|
|
|
fmt.Fprintf(f, "| 指标 | 数值 |\n|------|------|\n")
|
|
|
|
|
|
fmt.Fprintf(f, "| 今日新增模型 | %d |\n", r.DailySignals.NewModels)
|
|
|
|
|
|
fmt.Fprintf(f, "| 今日价格变化 | %d |\n", r.DailySignals.PriceChanges)
|
|
|
|
|
|
fmt.Fprintf(f, "| 官方免费 | %d |\n", r.DailySignals.OfficialFree)
|
|
|
|
|
|
fmt.Fprintf(f, "| 聚合免费 | %d |\n", r.DailySignals.AggregatorFree)
|
|
|
|
|
|
fmt.Fprintf(f, "| 待确认免费 | %d |\n\n", r.DailySignals.UnknownFree)
|
|
|
|
|
|
|
|
|
|
|
|
for _, item := range r.HeadlineItems {
|
|
|
|
|
|
fmt.Fprintf(f, "### %s · %s\n\n", item.Label, item.Title)
|
|
|
|
|
|
fmt.Fprintf(f, "- 影响: %s\n", item.Summary)
|
|
|
|
|
|
fmt.Fprintf(f, "- 基线: %s\n", item.Baseline)
|
|
|
|
|
|
fmt.Fprintf(f, "- 可信度: %s\n\n", item.TrustLabel)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if len(r.FreeBreakdown) > 0 {
|
|
|
|
|
|
fmt.Fprintf(f, "### 免费来源分层\n\n")
|
|
|
|
|
|
fmt.Fprintf(f, "| 类型 | 数量 | 说明 |\n|------|------|------|\n")
|
|
|
|
|
|
for _, item := range r.FreeBreakdown {
|
|
|
|
|
|
fmt.Fprintf(f, "| %s | %d | %s |\n", item.Label, item.Count, item.Description)
|
2026-05-13 14:42:45 +08:00
|
|
|
|
}
|
2026-05-13 20:13:02 +08:00
|
|
|
|
fmt.Fprintf(f, "\n")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
fmt.Fprintf(f, "## 场景推荐\n\n")
|
|
|
|
|
|
for _, section := range r.SceneSections {
|
|
|
|
|
|
fmt.Fprintf(f, "### %s\n\n", section.Title)
|
|
|
|
|
|
fmt.Fprintf(f, "- 主推荐: **%s** (%s) · %s · %s\n", section.Lead.Name, section.Lead.Provider, section.Lead.PriceSummary, section.Lead.Evidence)
|
|
|
|
|
|
for _, other := range section.Others {
|
|
|
|
|
|
fmt.Fprintf(f, "- 备选: %s (%s) · %s\n", other.Name, other.Provider, other.PriceSummary)
|
2026-05-13 14:42:45 +08:00
|
|
|
|
}
|
|
|
|
|
|
fmt.Fprintf(f, "\n")
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 20:13:02 +08:00
|
|
|
|
fmt.Fprintf(f, "## 完整数据附录\n\n")
|
|
|
|
|
|
for _, link := range r.AppendixLinks {
|
|
|
|
|
|
fmt.Fprintf(f, "- **%s**: %s\n", link.Title, link.Description)
|
|
|
|
|
|
}
|
|
|
|
|
|
fmt.Fprintf(f, "\n")
|
|
|
|
|
|
|
|
|
|
|
|
fmt.Fprintf(f, "### 数据质量摘要\n\n")
|
|
|
|
|
|
fmt.Fprintf(f, "| 指标 | 数值 |\n|------|------|\n")
|
|
|
|
|
|
fmt.Fprintf(f, "| 模型总数 | %d |\n", r.QualitySummary.Total)
|
|
|
|
|
|
fmt.Fprintf(f, "| 数据新鲜 | %d |\n", r.QualitySummary.Fresh)
|
|
|
|
|
|
fmt.Fprintf(f, "| CNY定价 | %d |\n", r.QualitySummary.CNY)
|
|
|
|
|
|
fmt.Fprintf(f, "| USD定价 | %d |\n\n", r.QualitySummary.USD)
|
|
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
if len(r.IntlTop5) > 0 {
|
2026-05-13 20:13:02 +08:00
|
|
|
|
fmt.Fprintf(f, "### 国际低价模型 TOP 5\n\n")
|
|
|
|
|
|
fmt.Fprintf(f, "| 排名 | 模型 | 厂商 | 输入 | 输出 | 上下文 |\n")
|
|
|
|
|
|
fmt.Fprintf(f, "|------|------|------|------|------|--------|\n")
|
2026-05-13 14:42:45 +08:00
|
|
|
|
for i, m := range r.IntlTop5 {
|
2026-05-13 20:13:02 +08:00
|
|
|
|
fmt.Fprintf(f, "| %d | %s | %s | %s | %s | %s |\n",
|
|
|
|
|
|
i+1, m.Name, m.ProviderName, formatPriceWithCurrency(m.InputPrice, m.Currency), formatPriceWithCurrency(m.OutputPrice, m.Currency), formatContextWindowCompact(m.ContextLength))
|
2026-05-13 14:42:45 +08:00
|
|
|
|
}
|
|
|
|
|
|
fmt.Fprintf(f, "\n")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if len(r.DomesticTop10) > 0 {
|
2026-05-13 20:13:02 +08:00
|
|
|
|
fmt.Fprintf(f, "### 国内模型 TOP 10\n\n")
|
|
|
|
|
|
fmt.Fprintf(f, "| 排名 | 模型 | 厂商 | 输入(CNY) | 输出(CNY) | 上下文 |\n")
|
|
|
|
|
|
fmt.Fprintf(f, "|------|------|------|-----------|-----------|--------|\n")
|
2026-05-13 14:42:45 +08:00
|
|
|
|
for i, m := range r.DomesticTop10 {
|
2026-05-13 20:13:02 +08:00
|
|
|
|
fmt.Fprintf(f, "| %d | %s | %s | %s | %s | %s |\n",
|
|
|
|
|
|
i+1, m.Name, m.ProviderName, formatDomesticPrice(m.InputPrice, m.Currency), formatDomesticPrice(m.OutputPrice, m.Currency), formatContextWindowCompact(m.ContextLength))
|
|
|
|
|
|
}
|
|
|
|
|
|
fmt.Fprintf(f, "\n")
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if len(r.FreeTop20) > 0 {
|
|
|
|
|
|
fmt.Fprintf(f, "### 免费模型代表样本\n\n")
|
|
|
|
|
|
fmt.Fprintf(f, "| 模型 | 厂商 | 来源类型 | 上下文 |\n")
|
|
|
|
|
|
fmt.Fprintf(f, "|------|------|----------|--------|\n")
|
|
|
|
|
|
for _, m := range r.FreeTop20 {
|
|
|
|
|
|
fmt.Fprintf(f, "| %s | %s | %s | %s |\n", m.Name, m.ProviderName, classifyFreeSource(m), formatContextWindowCompact(m.ContextLength))
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
2026-05-13 14:42:45 +08:00
|
|
|
|
fmt.Fprintf(f, "\n")
|
2026-05-08 13:49:12 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
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")
|
|
|
|
|
|
}
|
2026-05-08 13:49:12 +08:00
|
|
|
|
|
2026-05-13 20:13:02 +08:00
|
|
|
|
fmt.Fprintf(f, "### 平台覆盖\n\n")
|
2026-05-13 14:42:45 +08:00
|
|
|
|
for _, op := range r.Operators {
|
2026-05-13 20:13:02 +08:00
|
|
|
|
fmt.Fprintf(f, "- **%s**: %d 个模型,最低 %s/M\n", op.Name, op.ModelCount, formatPrice(op.MinInputPrice, "USD"))
|
2026-05-13 14:42:45 +08:00
|
|
|
|
}
|
2026-05-13 20:13:02 +08:00
|
|
|
|
for _, op := range r.Resellers {
|
|
|
|
|
|
fmt.Fprintf(f, "- **%s**: %d 个模型,最低 %s/M\n", op.Name, op.ModelCount, formatPrice(op.MinInputPrice, "USD"))
|
2026-05-13 14:42:45 +08:00
|
|
|
|
}
|
2026-05-13 20:13:02 +08:00
|
|
|
|
fmt.Fprintf(f, "\n---\n\n📌 **说明**: 本报告由 LLM Intelligence Hub 自动生成。\n")
|
|
|
|
|
|
fmt.Fprintf(f, "- 免费不等于官方永久免费,需结合来源标签判断。\n")
|
|
|
|
|
|
fmt.Fprintf(f, "- 国际模型价格按 1 USD = 7.25 CNY 换算显示。\n")
|
|
|
|
|
|
fmt.Fprintf(f, "\n_生成时间: %s_\n", r.GeneratedAt)
|
2026-05-08 13:49:12 +08:00
|
|
|
|
return nil
|
|
|
|
|
|
}
|
2026-05-13 14:42:45 +08:00
|
|
|
|
|
|
|
|
|
|
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 {
|
2026-05-13 20:13:02 +08:00
|
|
|
|
decorateReportV1(r)
|
|
|
|
|
|
|
2026-05-13 14:42:45 +08:00
|
|
|
|
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 {
|
2026-05-13 20:13:02 +08:00
|
|
|
|
--ink: #15304b;
|
|
|
|
|
|
--ink-soft: #516579;
|
|
|
|
|
|
--fog: #f6f4ef;
|
|
|
|
|
|
--card: rgba(255,255,255,0.94);
|
|
|
|
|
|
--line: rgba(21,48,75,0.10);
|
|
|
|
|
|
--blue: #123c63;
|
|
|
|
|
|
--green: #1f7a4c;
|
|
|
|
|
|
--amber: #ad6b11;
|
|
|
|
|
|
--red: #a53b2a;
|
|
|
|
|
|
--shadow: 0 20px 40px rgba(17, 38, 58, 0.08);
|
2026-05-13 14:42:45 +08:00
|
|
|
|
}
|
|
|
|
|
|
* { margin:0; padding:0; box-sizing:border-box; }
|
|
|
|
|
|
body {
|
2026-05-13 20:13:02 +08:00
|
|
|
|
font-family: "SF Pro Display", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
|
|
|
|
|
|
background:
|
|
|
|
|
|
radial-gradient(circle at top left, rgba(18,60,99,0.08), transparent 30%),
|
|
|
|
|
|
linear-gradient(180deg, #fbfaf7 0%, #f4f1ea 100%);
|
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
|
line-height: 1.5;
|
2026-05-13 14:42:45 +08:00
|
|
|
|
}
|
2026-05-13 20:13:02 +08:00
|
|
|
|
.container {
|
|
|
|
|
|
max-width: 1160px;
|
|
|
|
|
|
margin: 0 auto;
|
|
|
|
|
|
padding: 16px;
|
2026-05-13 14:42:45 +08:00
|
|
|
|
}
|
2026-05-13 20:13:02 +08:00
|
|
|
|
.topbar,
|
|
|
|
|
|
.hero-card,
|
|
|
|
|
|
.section,
|
|
|
|
|
|
.appendix-card,
|
|
|
|
|
|
.metric-card,
|
|
|
|
|
|
.action-card,
|
|
|
|
|
|
.headline-card,
|
|
|
|
|
|
.scene-card,
|
|
|
|
|
|
.free-card {
|
2026-05-13 14:42:45 +08:00
|
|
|
|
background: var(--card);
|
2026-05-13 20:13:02 +08:00
|
|
|
|
border: 1px solid var(--line);
|
|
|
|
|
|
box-shadow: var(--shadow);
|
|
|
|
|
|
backdrop-filter: blur(12px);
|
2026-05-13 14:42:45 +08:00
|
|
|
|
}
|
2026-05-13 20:13:02 +08:00
|
|
|
|
.topbar {
|
|
|
|
|
|
border-radius: 24px;
|
|
|
|
|
|
padding: 20px;
|
|
|
|
|
|
margin-bottom: 14px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.topbar-row {
|
2026-05-13 14:42:45 +08:00
|
|
|
|
display: flex;
|
2026-05-13 20:13:02 +08:00
|
|
|
|
justify-content: space-between;
|
2026-05-13 14:42:45 +08:00
|
|
|
|
align-items: center;
|
2026-05-13 20:13:02 +08:00
|
|
|
|
gap: 12px;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
}
|
|
|
|
|
|
.topbar-title {
|
|
|
|
|
|
font-size: 1rem;
|
|
|
|
|
|
letter-spacing: 0.04em;
|
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
|
color: var(--ink-soft);
|
|
|
|
|
|
}
|
|
|
|
|
|
.topbar-meta {
|
|
|
|
|
|
font-size: 0.95rem;
|
|
|
|
|
|
color: var(--ink-soft);
|
|
|
|
|
|
}
|
|
|
|
|
|
.label-row {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
2026-05-13 14:42:45 +08:00
|
|
|
|
gap: 8px;
|
2026-05-13 20:13:02 +08:00
|
|
|
|
margin-top: 14px;
|
2026-05-13 14:42:45 +08:00
|
|
|
|
}
|
2026-05-13 20:13:02 +08:00
|
|
|
|
.pill {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
padding: 7px 12px;
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
font-size: 0.82rem;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
}
|
|
|
|
|
|
.pill.blue { background: rgba(18,60,99,0.10); color: var(--blue); }
|
|
|
|
|
|
.pill.green { background: rgba(31,122,76,0.12); color: var(--green); }
|
|
|
|
|
|
.pill.amber { background: rgba(173,107,17,0.12); color: var(--amber); }
|
|
|
|
|
|
.pill.red { background: rgba(165,59,42,0.12); color: var(--red); }
|
|
|
|
|
|
.hero-card {
|
|
|
|
|
|
border-radius: 28px;
|
|
|
|
|
|
padding: 24px 20px;
|
|
|
|
|
|
margin-bottom: 16px;
|
|
|
|
|
|
background:
|
|
|
|
|
|
linear-gradient(135deg, rgba(18,60,99,0.96), rgba(34,80,122,0.90)),
|
|
|
|
|
|
#123c63;
|
|
|
|
|
|
color: #f9fafb;
|
|
|
|
|
|
}
|
|
|
|
|
|
.hero-kicker {
|
|
|
|
|
|
font-size: 0.9rem;
|
|
|
|
|
|
letter-spacing: 0.06em;
|
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
|
opacity: 0.82;
|
|
|
|
|
|
margin-bottom: 10px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.hero-title {
|
|
|
|
|
|
font-size: clamp(1.45rem, 4.8vw, 2.05rem);
|
|
|
|
|
|
line-height: 1.28;
|
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
|
margin-bottom: 14px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.hero-evidence {
|
|
|
|
|
|
font-size: 1rem;
|
|
|
|
|
|
opacity: 0.92;
|
|
|
|
|
|
}
|
|
|
|
|
|
.metrics-grid,
|
|
|
|
|
|
.actions-grid,
|
|
|
|
|
|
.headline-grid,
|
|
|
|
|
|
.free-grid,
|
|
|
|
|
|
.scene-grid,
|
|
|
|
|
|
.appendix-grid {
|
2026-05-13 14:42:45 +08:00
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 12px;
|
2026-05-13 20:13:02 +08:00
|
|
|
|
margin-bottom: 16px;
|
2026-05-13 14:42:45 +08:00
|
|
|
|
}
|
2026-05-13 20:13:02 +08:00
|
|
|
|
.metrics-grid {
|
|
|
|
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
|
|
|
|
}
|
|
|
|
|
|
.metric-card {
|
|
|
|
|
|
border-radius: 20px;
|
2026-05-13 14:42:45 +08:00
|
|
|
|
padding: 16px;
|
|
|
|
|
|
}
|
2026-05-13 20:13:02 +08:00
|
|
|
|
.metric-label {
|
|
|
|
|
|
font-size: 0.92rem;
|
|
|
|
|
|
color: var(--ink-soft);
|
|
|
|
|
|
margin-bottom: 6px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.metric-value {
|
|
|
|
|
|
font-size: 1.8rem;
|
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
|
}
|
|
|
|
|
|
.section {
|
|
|
|
|
|
border-radius: 24px;
|
|
|
|
|
|
padding: 20px;
|
|
|
|
|
|
margin-bottom: 16px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.section h2 {
|
|
|
|
|
|
font-size: 1.3rem;
|
|
|
|
|
|
margin-bottom: 14px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.section-intro {
|
|
|
|
|
|
color: var(--ink-soft);
|
|
|
|
|
|
font-size: 0.98rem;
|
|
|
|
|
|
margin-bottom: 14px;
|
2026-05-13 14:42:45 +08:00
|
|
|
|
}
|
2026-05-13 20:13:02 +08:00
|
|
|
|
.actions-grid,
|
|
|
|
|
|
.headline-grid,
|
|
|
|
|
|
.scene-grid,
|
|
|
|
|
|
.appendix-grid {
|
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
|
}
|
|
|
|
|
|
.action-card,
|
|
|
|
|
|
.headline-card,
|
|
|
|
|
|
.scene-card,
|
|
|
|
|
|
.free-card,
|
|
|
|
|
|
.appendix-card {
|
|
|
|
|
|
border-radius: 22px;
|
|
|
|
|
|
padding: 18px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.action-card.primary {
|
|
|
|
|
|
border: 2px solid rgba(18,60,99,0.16);
|
|
|
|
|
|
}
|
|
|
|
|
|
.card-kicker {
|
|
|
|
|
|
font-size: 0.85rem;
|
|
|
|
|
|
color: var(--ink-soft);
|
|
|
|
|
|
margin-bottom: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.card-title {
|
|
|
|
|
|
font-size: 1.18rem;
|
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
|
margin-bottom: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.card-summary,
|
|
|
|
|
|
.card-evidence,
|
|
|
|
|
|
.scene-desc,
|
|
|
|
|
|
.appendix-desc,
|
|
|
|
|
|
.meta-line {
|
|
|
|
|
|
font-size: 0.98rem;
|
|
|
|
|
|
color: var(--ink-soft);
|
|
|
|
|
|
}
|
|
|
|
|
|
.card-evidence {
|
|
|
|
|
|
margin-top: 10px;
|
|
|
|
|
|
color: var(--ink);
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
}
|
|
|
|
|
|
.trust-line,
|
|
|
|
|
|
.baseline-line {
|
|
|
|
|
|
margin-top: 8px;
|
|
|
|
|
|
font-size: 0.9rem;
|
|
|
|
|
|
color: var(--ink-soft);
|
|
|
|
|
|
}
|
|
|
|
|
|
.scene-header {
|
2026-05-13 14:42:45 +08:00
|
|
|
|
display: flex;
|
|
|
|
|
|
justify-content: space-between;
|
2026-05-13 20:13:02 +08:00
|
|
|
|
gap: 12px;
|
|
|
|
|
|
align-items: flex-start;
|
|
|
|
|
|
margin-bottom: 10px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.scene-lead {
|
|
|
|
|
|
border-top: 1px solid var(--line);
|
|
|
|
|
|
padding-top: 12px;
|
|
|
|
|
|
margin-top: 12px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.scene-lead-name {
|
|
|
|
|
|
font-size: 1.12rem;
|
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
|
margin-bottom: 6px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.scene-others {
|
|
|
|
|
|
margin-top: 12px;
|
|
|
|
|
|
display: grid;
|
|
|
|
|
|
gap: 8px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.scene-other {
|
|
|
|
|
|
padding-top: 10px;
|
|
|
|
|
|
border-top: 1px dashed var(--line);
|
|
|
|
|
|
}
|
|
|
|
|
|
.free-grid {
|
|
|
|
|
|
grid-template-columns: 1fr;
|
|
|
|
|
|
}
|
|
|
|
|
|
.free-count {
|
|
|
|
|
|
font-size: 1.9rem;
|
|
|
|
|
|
font-weight: 800;
|
|
|
|
|
|
margin-bottom: 6px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.appendix-links {
|
|
|
|
|
|
display: flex;
|
|
|
|
|
|
flex-wrap: wrap;
|
|
|
|
|
|
gap: 10px;
|
|
|
|
|
|
margin-top: 10px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.appendix-link {
|
|
|
|
|
|
display: inline-flex;
|
|
|
|
|
|
align-items: center;
|
|
|
|
|
|
justify-content: center;
|
|
|
|
|
|
text-decoration: none;
|
|
|
|
|
|
color: var(--blue);
|
|
|
|
|
|
background: rgba(18,60,99,0.08);
|
|
|
|
|
|
border-radius: 999px;
|
|
|
|
|
|
padding: 10px 14px;
|
|
|
|
|
|
font-weight: 700;
|
|
|
|
|
|
}
|
|
|
|
|
|
table {
|
|
|
|
|
|
width: 100%;
|
|
|
|
|
|
border-collapse: collapse;
|
|
|
|
|
|
margin-top: 12px;
|
|
|
|
|
|
font-size: 0.95rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
th, td {
|
|
|
|
|
|
padding: 12px 10px;
|
|
|
|
|
|
text-align: left;
|
|
|
|
|
|
border-bottom: 1px solid var(--line);
|
|
|
|
|
|
vertical-align: top;
|
|
|
|
|
|
}
|
|
|
|
|
|
th {
|
|
|
|
|
|
color: var(--ink-soft);
|
|
|
|
|
|
font-size: 0.82rem;
|
|
|
|
|
|
text-transform: uppercase;
|
|
|
|
|
|
letter-spacing: 0.04em;
|
2026-05-13 14:42:45 +08:00
|
|
|
|
}
|
2026-05-13 20:13:02 +08:00
|
|
|
|
.footer {
|
|
|
|
|
|
padding: 24px 8px 12px;
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
color: var(--ink-soft);
|
|
|
|
|
|
font-size: 0.92rem;
|
|
|
|
|
|
}
|
|
|
|
|
|
@media (min-width: 900px) {
|
|
|
|
|
|
.container {
|
|
|
|
|
|
padding: 24px;
|
|
|
|
|
|
}
|
|
|
|
|
|
.metrics-grid {
|
|
|
|
|
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
|
|
|
|
|
}
|
|
|
|
|
|
.actions-grid,
|
|
|
|
|
|
.headline-grid,
|
|
|
|
|
|
.free-grid,
|
|
|
|
|
|
.appendix-grid {
|
|
|
|
|
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
|
|
|
|
|
}
|
|
|
|
|
|
.scene-grid {
|
|
|
|
|
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
@media (prefers-reduced-motion: no-preference) {
|
|
|
|
|
|
.hero-card,
|
|
|
|
|
|
.section,
|
|
|
|
|
|
.metric-card {
|
|
|
|
|
|
animation: fadeUp 240ms ease-out both;
|
|
|
|
|
|
}
|
|
|
|
|
|
@keyframes fadeUp {
|
|
|
|
|
|
from { opacity: 0; transform: translateY(8px); }
|
|
|
|
|
|
to { opacity: 1; transform: translateY(0); }
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
.tone-success { border-color: rgba(31,122,76,0.18); }
|
|
|
|
|
|
.tone-warning { border-color: rgba(173,107,17,0.18); }
|
|
|
|
|
|
.tone-info { border-color: rgba(18,60,99,0.18); }
|
|
|
|
|
|
.tone-caution { border-color: rgba(165,59,42,0.18); }
|
|
|
|
|
|
.tone-neutral { border-color: rgba(81,101,121,0.16); }
|
2026-05-13 14:42:45 +08:00
|
|
|
|
.footer {
|
|
|
|
|
|
text-align: center;
|
|
|
|
|
|
}
|
|
|
|
|
|
</style>
|
|
|
|
|
|
</head>
|
|
|
|
|
|
<body>
|
|
|
|
|
|
<div class="container">
|
|
|
|
|
|
|
2026-05-13 20:13:02 +08:00
|
|
|
|
<div class="topbar">
|
|
|
|
|
|
<div class="topbar-row">
|
|
|
|
|
|
<div class="topbar-title">AI 模型与价格情报晨报</div>
|
|
|
|
|
|
<div class="topbar-meta">{{.Date}} · {{.GeneratedAt}}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="label-row">
|
|
|
|
|
|
{{range .MarketLabels}}<span class="pill blue">{{.}}</span>{{end}}
|
|
|
|
|
|
</div>
|
2026-05-13 14:42:45 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-13 20:13:02 +08:00
|
|
|
|
<section class="hero-card">
|
|
|
|
|
|
<div class="hero-kicker">今日一句话结论</div>
|
|
|
|
|
|
<div class="hero-title">{{.HeroSummary}}</div>
|
|
|
|
|
|
{{if .HeroEvidence}}<div class="hero-evidence">{{.HeroEvidence}}</div>{{end}}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<div class="metrics-grid">
|
|
|
|
|
|
<div class="metric-card">
|
|
|
|
|
|
<div class="metric-label">模型总数</div>
|
|
|
|
|
|
<div class="metric-value">{{.TotalModels}}</div>
|
2026-05-13 14:42:45 +08:00
|
|
|
|
</div>
|
2026-05-13 20:13:02 +08:00
|
|
|
|
<div class="metric-card">
|
|
|
|
|
|
<div class="metric-label">今日新增模型</div>
|
|
|
|
|
|
<div class="metric-value">{{.DailySignals.NewModels}}</div>
|
2026-05-13 14:42:45 +08:00
|
|
|
|
</div>
|
2026-05-13 20:13:02 +08:00
|
|
|
|
<div class="metric-card">
|
|
|
|
|
|
<div class="metric-label">今日价格变化</div>
|
|
|
|
|
|
<div class="metric-value">{{.DailySignals.PriceChanges}}</div>
|
2026-05-13 14:42:45 +08:00
|
|
|
|
</div>
|
2026-05-13 20:13:02 +08:00
|
|
|
|
<div class="metric-card">
|
|
|
|
|
|
<div class="metric-label">免费样本</div>
|
|
|
|
|
|
<div class="metric-value">{{len .FreeModels}}</div>
|
2026-05-13 14:42:45 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-05-13 20:13:02 +08:00
|
|
|
|
<section class="section">
|
|
|
|
|
|
<h2>三条行动建议</h2>
|
|
|
|
|
|
<p class="section-intro">先给行动,再给证据。每张卡只回答“今天该先看什么”。</p>
|
|
|
|
|
|
<div class="actions-grid">
|
|
|
|
|
|
{{range $i, $item := .ActionItems}}
|
|
|
|
|
|
<article class="action-card {{if eq $i 0}}primary{{end}}">
|
|
|
|
|
|
<div class="card-kicker">行动建议</div>
|
|
|
|
|
|
<div class="card-title">{{$item.Title}}</div>
|
|
|
|
|
|
<div class="card-summary">{{$item.Audience}}</div>
|
|
|
|
|
|
<div class="label-row">
|
|
|
|
|
|
{{range $item.Tags}}<span class="pill amber">{{.}}</span>{{end}}
|
2026-05-13 14:42:45 +08:00
|
|
|
|
</div>
|
2026-05-13 20:13:02 +08:00
|
|
|
|
<div class="card-evidence">{{$item.Evidence}}</div>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section class="section">
|
|
|
|
|
|
<h2>今日头条</h2>
|
|
|
|
|
|
<p class="section-intro">只保留真正影响当天判断的变化事件。</p>
|
|
|
|
|
|
<div class="headline-grid">
|
|
|
|
|
|
{{range .HeadlineItems}}
|
|
|
|
|
|
<article class="headline-card tone-{{.Tone}}">
|
|
|
|
|
|
<div class="card-kicker">{{.Label}}</div>
|
|
|
|
|
|
<div class="card-title">{{.Title}}</div>
|
|
|
|
|
|
<div class="card-summary">{{.Summary}}</div>
|
|
|
|
|
|
<div class="baseline-line">基线:{{.Baseline}}</div>
|
|
|
|
|
|
<div class="trust-line">可信度:{{.TrustLabel}}</div>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section class="section">
|
|
|
|
|
|
<h2>免费来源分层</h2>
|
|
|
|
|
|
<p class="section-intro">免费可用不等于官方长期免费,必须先区分来源。</p>
|
|
|
|
|
|
<div class="free-grid">
|
|
|
|
|
|
{{range .FreeBreakdown}}
|
|
|
|
|
|
<article class="free-card tone-{{.Tone}}">
|
|
|
|
|
|
<div class="card-title">{{.Label}}</div>
|
|
|
|
|
|
<div class="free-count">{{.Count}}</div>
|
|
|
|
|
|
<div class="card-summary">{{.Description}}</div>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section class="section">
|
|
|
|
|
|
<h2>场景推荐</h2>
|
|
|
|
|
|
<p class="section-intro">按场景给出有限候选,优先帮助读者当天做出选择。</p>
|
|
|
|
|
|
<div class="scene-grid">
|
|
|
|
|
|
{{range .SceneSections}}
|
|
|
|
|
|
<article class="scene-card">
|
|
|
|
|
|
<div class="scene-header">
|
|
|
|
|
|
<div>
|
|
|
|
|
|
<div class="card-title">{{.Title}}</div>
|
|
|
|
|
|
<div class="scene-desc">{{.Description}}</div>
|
|
|
|
|
|
</div>
|
2026-05-13 14:42:45 +08:00
|
|
|
|
</div>
|
2026-05-13 20:13:02 +08:00
|
|
|
|
<div class="scene-lead">
|
|
|
|
|
|
<div class="scene-lead-name">{{.Lead.Name}}</div>
|
|
|
|
|
|
<div class="meta-line">{{.Lead.Provider}} · {{.Lead.Operator}} · {{.Lead.Usage}}</div>
|
|
|
|
|
|
<div class="label-row">
|
|
|
|
|
|
{{range .Lead.Tags}}<span class="pill green">{{.}}</span>{{end}}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div class="card-evidence">{{.Lead.Evidence}}</div>
|
|
|
|
|
|
<div class="trust-line">{{.Lead.PriceSummary}} · {{.Lead.TrustLabel}}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{{if .Others}}
|
|
|
|
|
|
<div class="scene-others">
|
|
|
|
|
|
{{range .Others}}
|
|
|
|
|
|
<div class="scene-other">
|
|
|
|
|
|
<div><strong>{{.Name}}</strong> · {{.Provider}}</div>
|
|
|
|
|
|
<div class="meta-line">{{.PriceSummary}} · {{.Evidence}}</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
</article>
|
|
|
|
|
|
{{end}}
|
2026-05-13 14:42:45 +08:00
|
|
|
|
</div>
|
2026-05-13 20:13:02 +08:00
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section class="section">
|
|
|
|
|
|
<h2>完整数据附录</h2>
|
|
|
|
|
|
<p class="section-intro">长表格后置,适合深度比价时再展开。</p>
|
|
|
|
|
|
<div class="appendix-grid">
|
|
|
|
|
|
{{range .AppendixLinks}}
|
|
|
|
|
|
<article class="appendix-card">
|
|
|
|
|
|
<div class="card-title">{{.Title}}</div>
|
|
|
|
|
|
<div class="appendix-desc">{{.Description}}</div>
|
|
|
|
|
|
<div class="appendix-links">
|
|
|
|
|
|
<a class="appendix-link" href="{{.Anchor}}">跳转查看</a>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</article>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
2026-05-13 14:42:45 +08:00
|
|
|
|
|
2026-05-13 20:13:02 +08:00
|
|
|
|
<section class="section" id="appendix-pricing">
|
|
|
|
|
|
<h2>完整价格附录</h2>
|
|
|
|
|
|
{{if .IntlTop5}}
|
2026-05-13 14:42:45 +08:00
|
|
|
|
<table>
|
2026-05-13 20:13:02 +08:00
|
|
|
|
<tr><th>国际候选</th><th>厂商</th><th>输入</th><th>输出</th><th>上下文</th></tr>
|
|
|
|
|
|
{{range .IntlTop5}}
|
2026-05-13 14:42:45 +08:00
|
|
|
|
<tr>
|
2026-05-13 20:13:02 +08:00
|
|
|
|
<td><strong>{{.Name}}</strong></td>
|
|
|
|
|
|
<td>{{.ProviderName}}</td>
|
|
|
|
|
|
<td>{{formatPriceWithCurrency .InputPrice .Currency}}</td>
|
|
|
|
|
|
<td>{{formatPriceWithCurrency .OutputPrice .Currency}}</td>
|
|
|
|
|
|
<td>{{formatContextWindowCompact .ContextLength}}</td>
|
2026-05-13 14:42:45 +08:00
|
|
|
|
</tr>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
</table>
|
2026-05-13 20:13:02 +08:00
|
|
|
|
{{end}}
|
|
|
|
|
|
{{if .DomesticTop10}}
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<tr><th>国内候选</th><th>厂商</th><th>输入(CNY)</th><th>输出(CNY)</th><th>上下文</th></tr>
|
|
|
|
|
|
{{range .DomesticTop10}}
|
|
|
|
|
|
<tr>
|
|
|
|
|
|
<td><strong>{{.Name}}</strong></td>
|
|
|
|
|
|
<td>{{.ProviderName}}</td>
|
|
|
|
|
|
<td>{{formatDomesticPrice .InputPrice .Currency}}</td>
|
|
|
|
|
|
<td>{{formatDomesticPrice .OutputPrice .Currency}}</td>
|
|
|
|
|
|
<td>{{formatContextWindowCompact .ContextLength}}</td>
|
|
|
|
|
|
</tr>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
</table>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
</section>
|
2026-05-13 14:42:45 +08:00
|
|
|
|
|
2026-05-13 20:13:02 +08:00
|
|
|
|
<section class="section" id="appendix-free">
|
|
|
|
|
|
<h2>完整免费附录</h2>
|
2026-05-13 14:42:45 +08:00
|
|
|
|
<table>
|
2026-05-13 20:13:02 +08:00
|
|
|
|
<tr><th>模型</th><th>厂商</th><th>来源类型</th><th>上下文</th></tr>
|
|
|
|
|
|
{{range .FreeTop20}}
|
2026-05-13 14:42:45 +08:00
|
|
|
|
<tr>
|
2026-05-13 20:13:02 +08:00
|
|
|
|
<td><strong>{{.Name}}</strong></td>
|
|
|
|
|
|
<td>{{.ProviderName}}</td>
|
|
|
|
|
|
<td>{{classifyFreeSource .}}</td>
|
|
|
|
|
|
<td>{{formatContextWindowCompact .ContextLength}}</td>
|
2026-05-13 14:42:45 +08:00
|
|
|
|
</tr>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
</table>
|
2026-05-13 20:13:02 +08:00
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
<section class="section" id="appendix-platforms">
|
|
|
|
|
|
<h2>平台覆盖附录</h2>
|
|
|
|
|
|
{{if .Operators}}
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<tr><th>官方/云平台</th><th>模型数</th><th>最低输入价</th><th>平均输入价</th></tr>
|
|
|
|
|
|
{{range .Operators}}
|
|
|
|
|
|
<tr><td><strong>{{.Name}}</strong></td><td>{{.ModelCount}}</td><td>{{formatPrice .MinInputPrice "USD"}}</td><td>{{formatPrice .AvgInputPrice "USD"}}</td></tr>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
</table>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
{{if .Resellers}}
|
|
|
|
|
|
<table>
|
|
|
|
|
|
<tr><th>聚合平台</th><th>模型数</th><th>最低输入价</th><th>平均输入价</th></tr>
|
|
|
|
|
|
{{range .Resellers}}
|
|
|
|
|
|
<tr><td><strong>{{.Name}}</strong></td><td>{{.ModelCount}}</td><td>{{formatPrice .MinInputPrice "USD"}}</td><td>{{formatPrice .AvgInputPrice "USD"}}</td></tr>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
</table>
|
|
|
|
|
|
{{end}}
|
|
|
|
|
|
</section>
|
2026-05-13 14:42:45 +08:00
|
|
|
|
|
|
|
|
|
|
{{if .TencentSubscriptionPlans}}
|
2026-05-13 20:13:02 +08:00
|
|
|
|
<section class="section">
|
2026-05-13 14:42:45 +08:00
|
|
|
|
<h2>💳 腾讯云套餐订阅价</h2>
|
2026-05-13 20:13:02 +08:00
|
|
|
|
<p class="section-intro">以下为套餐订阅价,不参与按模型输入/输出单价排行。</p>
|
2026-05-13 14:42:45 +08:00
|
|
|
|
<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>
|
2026-05-13 20:13:02 +08:00
|
|
|
|
</section>
|
2026-05-13 14:42:45 +08:00
|
|
|
|
{{end}}
|
|
|
|
|
|
|
|
|
|
|
|
<div class="footer">
|
|
|
|
|
|
<p>📌 本报告由 LLM Intelligence Hub 自动生成 · {{.Date}}</p>
|
2026-05-13 20:13:02 +08:00
|
|
|
|
<p style="margin-top:8px;">免费不等于官方永久免费,需结合来源标签判断。</p>
|
2026-05-13 14:42:45 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</body>
|
|
|
|
|
|
</html>`
|
|
|
|
|
|
|
|
|
|
|
|
funcMap := template.FuncMap{
|
2026-05-13 20:13:02 +08:00
|
|
|
|
"add": func(a, b int) int { return a + b },
|
|
|
|
|
|
"classifyFreeSource": classifyFreeSource,
|
|
|
|
|
|
"formatPrice": formatPrice,
|
|
|
|
|
|
"formatPriceWithCurrency": formatPriceWithCurrency,
|
|
|
|
|
|
"formatDomesticPrice": formatDomesticPrice,
|
|
|
|
|
|
"formatSubscriptionPrice": formatSubscriptionPrice,
|
|
|
|
|
|
"formatSubscriptionQuota": formatSubscriptionQuota,
|
2026-05-13 14:42:45 +08:00
|
|
|
|
"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)
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-13 20:13:02 +08:00
|
|
|
|
func saveReportTrackingV3(db *sql.DB, r *ReportV3, mdPath string) error {
|
|
|
|
|
|
summary := r.HeroSummary
|
|
|
|
|
|
if summary == "" {
|
|
|
|
|
|
summary = fmt.Sprintf("models=%d free=%d intl=%d domestic=%d", r.TotalModels, len(r.FreeModels), len(r.IntlTop5), len(r.DomesticTop10))
|
|
|
|
|
|
}
|
|
|
|
|
|
tx, err := db.Begin()
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
defer tx.Rollback()
|
|
|
|
|
|
|
|
|
|
|
|
if _, err := tx.Exec(`
|
2026-05-13 14:42:45 +08:00
|
|
|
|
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,
|
2026-05-13 20:13:02 +08:00
|
|
|
|
error_message = NULL,
|
2026-05-13 14:42:45 +08:00
|
|
|
|
updated_at = NOW()
|
2026-05-13 20:13:02 +08:00
|
|
|
|
`, r.Date, "generated", r.TotalModels, 0, len(r.FreeModels), summary, mdPath); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if _, err := tx.Exec(`
|
|
|
|
|
|
INSERT INTO report_runs (source, report_date, status, summary_md, output_path, error_message)
|
|
|
|
|
|
VALUES ($1, $2, $3, $4, $5, NULL)
|
|
|
|
|
|
`, "generate_daily_report", r.Date, "generated", summary, mdPath); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return tx.Commit()
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func archiveReportArtifacts(date, mdPath, htmlPath string) error {
|
|
|
|
|
|
reportDir := filepath.Dir(mdPath)
|
|
|
|
|
|
archiveDir := filepath.Join(reportDir, date[:4], date[5:7])
|
|
|
|
|
|
archiveMDPath := filepath.Join(archiveDir, filepath.Base(mdPath))
|
|
|
|
|
|
archiveHTMLPath := filepath.Join(archiveDir, filepath.Base(htmlPath))
|
|
|
|
|
|
|
|
|
|
|
|
if err := os.MkdirAll(archiveDir, 0755); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := copyFile(mdPath, archiveMDPath); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
if err := copyFile(htmlPath, archiveHTMLPath); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
func copyFile(src, dst string) error {
|
|
|
|
|
|
sourceFile, err := os.Open(src)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
defer sourceFile.Close()
|
|
|
|
|
|
|
|
|
|
|
|
targetFile, err := os.Create(dst)
|
|
|
|
|
|
if err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
defer targetFile.Close()
|
|
|
|
|
|
|
|
|
|
|
|
if _, err := io.Copy(targetFile, sourceFile); err != nil {
|
|
|
|
|
|
return err
|
|
|
|
|
|
}
|
|
|
|
|
|
return nil
|
2026-05-13 14:42:45 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 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, " ")
|
|
|
|
|
|
}
|