Files
llm-intelligence/scripts/generate_daily_report.go

3471 lines
100 KiB
Go
Raw Normal View History

//go:build llm_script
// generate_daily_report.go v3.0 - 日报生成器(现代化UI版)
// 支持:国家分类、运营商分类、信息图风格HTML
package main
import (
"database/sql"
"encoding/json"
"fmt"
"html/template"
"io"
"log/slog"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
_ "github.com/lib/pq"
)
var logger *slog.Logger
type ReportRunContext struct {
RunKind string
TriggerSource string
IsOfficialDaily bool
RuntimeAudit string
}
func init() {
logger = slog.New(slog.NewJSONHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelInfo}))
}
func main() {
loadProjectEnv()
if err := run(); err != nil {
logger.Error("日报生成失败", "error", err)
os.Exit(1)
}
logger.Info("日报生成完成")
}
func loadProjectEnv() {
for _, path := range []string{".env.local", ".env"} {
loadEnvFile(path)
}
}
func loadEnvFile(path string) {
f, err := os.Open(path)
if err != nil {
return
}
defer f.Close()
buf := make([]byte, 4096)
n, _ := f.Read(buf)
content := string(buf[:n])
for _, line := range strings.Split(content, "\n") {
line = strings.TrimSpace(line)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
value = strings.Trim(value, `"'`)
if key == "" {
continue
}
if _, exists := os.LookupEnv(key); exists {
continue
}
_ = os.Setenv(key, value)
}
}
func run() error {
dbConn := os.Getenv("DATABASE_URL")
if dbConn == "" {
return fmt.Errorf("DATABASE_URL 未设置")
}
db, err := sql.Open("postgres", dbConn)
if err != nil {
return fmt.Errorf("连接数据库失败: %w", err)
}
defer db.Close()
date, err := resolveReportDate(time.Now(), os.Args[1:], os.Getenv("REPORT_DATE"))
if err != nil {
return err
}
runContext := resolveReportRunContext(
date,
time.Now(),
os.Getenv("REPORT_RUN_KIND"),
os.Getenv("REPORT_TRIGGER_SOURCE"),
os.Getenv("REPORT_IS_OFFICIAL_DAILY"),
os.Getenv("REPORT_RUNTIME_AUDIT"),
)
// 1. 获取报告数据(使用新schema)
report, err := generateReportDataV3(db, date)
if err != nil {
return fmt.Errorf("生成报告数据失败: %w", err)
}
// 2. 创建目录
outDir := os.Getenv("REPORT_OUTPUT_DIR")
if outDir == "" {
outDir = "reports/daily"
}
os.MkdirAll(outDir, 0755)
os.MkdirAll(outDir+"/html", 0755)
// 3. 生成 Markdown
mdPath := filepath.Join(outDir, fmt.Sprintf("daily_report_%s.md", date))
if err := generateMarkdownV3(report, mdPath); err != nil {
return err
}
// 4. 生成 HTML(现代化UI)
htmlPath := filepath.Join(outDir, "html", fmt.Sprintf("daily_report_%s.html", date))
if err := generateHTMLV3(report, htmlPath); err != nil {
return err
}
// 5. 归档主产物,确保运行脚本和门禁使用统一路径约定
if err := archiveReportArtifacts(date, mdPath, htmlPath); err != nil {
return fmt.Errorf("归档日报失败: %w", err)
}
// 6. 同步写入日报状态与运行轨迹
if err := saveReportTrackingV3(db, report, mdPath, runContext); err != nil {
logger.Warn("保存日报记录失败", "error", err)
}
logger.Info("日报生成完成",
"models", report.TotalModels,
"free", len(report.FreeModels),
"intl", len(report.IntlTop5),
"domestic", len(report.DomesticTop10),
"md", mdPath,
"html", htmlPath)
return nil
}
func resolveReportDate(now time.Time, args []string, envDate string) (string, error) {
date := strings.TrimSpace(envDate)
for i := 0; i < len(args); i++ {
switch {
case args[i] == "-date" || args[i] == "--date":
if i+1 >= len(args) {
return "", fmt.Errorf("缺少 -date 参数值,期望格式 YYYY-MM-DD")
}
date = strings.TrimSpace(args[i+1])
i++
case strings.HasPrefix(args[i], "-date="):
date = strings.TrimSpace(strings.TrimPrefix(args[i], "-date="))
case strings.HasPrefix(args[i], "--date="):
date = strings.TrimSpace(strings.TrimPrefix(args[i], "--date="))
}
}
if date == "" {
return now.Format("2006-01-02"), nil
}
parsed, err := time.Parse("2006-01-02", date)
if err != nil {
return "", fmt.Errorf("无效报告日期 %q期望格式 YYYY-MM-DD", date)
}
return parsed.Format("2006-01-02"), nil
}
func resolveReportRunContext(reportDate string, now time.Time, envRunKind, envTriggerSource, envOfficialDaily, envRuntimeAudit string) ReportRunContext {
runKind := strings.TrimSpace(envRunKind)
if runKind == "" {
runKind = "manual"
}
triggerSource := strings.TrimSpace(envTriggerSource)
if triggerSource == "" {
triggerSource = "cli"
}
isOfficialDaily := strings.EqualFold(strings.TrimSpace(envOfficialDaily), "true")
if strings.TrimSpace(envOfficialDaily) == "" && reportDate == now.Format("2006-01-02") && runKind == "scheduled" {
isOfficialDaily = true
}
return ReportRunContext{
RunKind: runKind,
TriggerSource: triggerSource,
IsOfficialDaily: isOfficialDaily,
RuntimeAudit: strings.TrimSpace(envRuntimeAudit),
}
}
func resolveSignatureAuditReportConfig() SignatureAuditReportConfig {
return SignatureAuditReportConfig{
Window: positiveEnvIntOrDefault("REPORT_SIGNATURE_AUDIT_WINDOW", 5),
ChangedRunsThreshold: positiveEnvIntOrDefault("REPORT_SIGNATURE_AUDIT_CHANGED_THRESHOLD", 1),
}
}
func positiveEnvIntOrDefault(key string, fallback int) int {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" {
return fallback
}
value, err := strconv.Atoi(raw)
if err != nil || value <= 0 {
return fallback
}
return value
}
func composeTrackedSummary(summary string, runContext ReportRunContext) string {
runtimeAudit := strings.TrimSpace(runContext.RuntimeAudit)
summary = strings.TrimSpace(summary)
if runtimeAudit == "" {
return summary
}
if summary == "" {
return runtimeAudit
}
return runtimeAudit + "\n" + summary
}
// ============ 数据模型 ============
const (
USD_TO_CNY = 7.25 // USD 转 CNY 汇率
)
type ModelInfo struct {
ID, Name, ProviderName string
ProviderCountry string
ContextLength int
InputPrice, OutputPrice float64
Currency string
IsFree bool
OperatorName string
OperatorType string // cloud / reseller / official
Region string
Modality string
SceneTags []SceneTag
}
type ReportV3 struct {
Date string
GeneratedAt string
TotalModels int
AllModels []ModelInfo
FreeModels []ModelInfo
FreeTop20 []ModelInfo // 免费模型前20个(展示用)
IntlTop5 []ModelInfo // 国际前5(付费低价)
DomesticTop10 []ModelInfo // 国内前10(付费低价)
TopContext []ModelInfo // 大上下文TOP10
TencentSubscriptionPlans []SubscriptionPlanInfo
Operators []OperatorInfo
Resellers []OperatorInfo
QualitySummary DataQualitySummary
HasCNYData bool
HasDomesticData bool
DailySignals DailySignals
PageMode string
MarketLabels []string
HeroSummary string
HeroEvidence string
FreeBreakdown []FreeSourceStat
ActionItems []ActionItem
HeadlineItems []HeadlineItem
SceneSections []SceneSection
AppendixLinks []AppendixLink
ModelEvents []ModelEvent
SignatureAuditSummaries []SignatureAuditSourceSummary
SignatureAuditRows []SignatureAuditReportRow
SignatureAuditConfig SignatureAuditReportConfig
}
type DailySignals struct {
NewModels int
PriceChanges int
OfficialFree int
AggregatorFree int
UnknownFree int
}
type SignatureAuditSourceSummary struct {
SourceKey string
SourceLabel string
RunsInWindow int
ChangedRuns int
LatestCheckedAt string
LatestStatus string
LatestStructureState string
}
type SignatureAuditReportRow struct {
SourceKey string
SourceLabel string
RecentRank int
CheckedAt string
StructureState string
StructureChanged bool
Status string
DriftDetected bool
BaselineInitialized bool
StructureSHA256 string
PreviousStructureSHA256 string
SnapshotPath string
SignaturePath string
ErrorMessage string
}
type SignatureAuditReportConfig struct {
Window int
ChangedRunsThreshold 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
Audience string
Baseline string
TrustLabel string
SourceKindLabel string
PrimarySource string
UpdatedAt string
EvidenceDetail string
Tone string
}
type ModelEvent struct {
EventType string
ModelName string
ProviderName string
OperatorName string
Audience string
TrustLabel string
SourceKindLabel string
PrimarySource string
UpdatedAt string
EvidenceDetail string
Baseline string
Summary string
Currency string
OldInputPrice float64
NewInputPrice float64
OldOutputPrice float64
NewOutputPrice float64
PriceChangePct float64
Priority int
}
type PromoCampaignDefinition struct {
Date string `json:"date"`
ModelName string `json:"model_name"`
ProviderName string `json:"provider_name"`
OperatorName string `json:"operator_name"`
Summary string `json:"summary"`
Audience string `json:"audience"`
Baseline string `json:"baseline"`
TrustLabel string `json:"trust_label"`
SourceKindLabel string `json:"source_kind_label"`
PrimarySource string `json:"primary_source"`
EvidenceDetail string `json:"evidence_detail"`
Priority int `json:"priority"`
}
type Recommendation struct {
Name string
Provider string
Operator string
Usage string
PriceSummary string
Evidence string
TrustLabel string
Tags []string
}
type SceneSection struct {
Title string
Description string
Lead Recommendation
Others []Recommendation
}
type AppendixLink struct {
Title string
Description string
Anchor string
}
type OperatorInfo struct {
Name, Type, Country string
ModelCount int
AvgInputPrice float64
MinInputPrice float64
}
type DataQualitySummary struct {
Total, Fresh, Stale, CNY, USD int
}
type SubscriptionPlanInfo struct {
ProviderName string
ProviderCN string
OperatorName string
OperatorCN string
PlanName string
PlanFamily string
Tier string
BillingCycle string
Currency string
ListPrice float64
PriceUnit string
QuotaValue int64
QuotaUnit string
ContextWindow int
ModelCount int
ModelPreview string
SourceURL string
EffectiveDate string
Notes string
}
// ============ 数据查询(新Schema) ============
func generateReportDataV3(db *sql.DB, date string) (*ReportV3, error) {
signatureAuditCfg := resolveSignatureAuditReportConfig()
// 查询模型+厂商+定价+运营商信息
rows, err := db.Query(`
WITH latest_prices AS (
SELECT
rp.model_id,
rp.input_price_per_mtok,
rp.output_price_per_mtok,
rp.currency,
rp.region,
rp.is_free,
o.name as operator_name,
COALESCE(o.name_cn, o.name) as operator_name_cn,
COALESCE(o.type, 'reseller') as operator_type,
ROW_NUMBER() OVER (
PARTITION BY rp.model_id
ORDER BY rp.effective_date DESC NULLS LAST, rp.id DESC
) AS rn
FROM region_pricing rp
LEFT JOIN operator o ON rp.operator_id = o.id
)
SELECT
m.external_id,
COALESCE(NULLIF(m.name, ''), m.external_id) as name,
COALESCE(mp.name, split_part(m.external_id, '/', 1)) as provider_name,
COALESCE(mp.country, 'unknown') as provider_country,
COALESCE(m.context_length, 0),
m.modality,
COALESCE(lp.input_price_per_mtok, 0),
COALESCE(lp.output_price_per_mtok, 0),
COALESCE(lp.currency, 'USD'),
COALESCE(lp.is_free, false),
COALESCE(lp.operator_name, 'OpenRouter'),
COALESCE(lp.operator_type, 'reseller'),
COALESCE(lp.region, 'global')
FROM models m
LEFT JOIN model_provider mp ON m.provider_id = mp.id
LEFT JOIN latest_prices lp ON lp.model_id = m.id AND lp.rn = 1
WHERE m.deleted_at IS NULL
ORDER BY m.id
`)
if err != nil {
return nil, err
}
defer rows.Close()
var allModels []ModelInfo
var freeModels []ModelInfo
var intlModels []ModelInfo // 国际模型(US/EU/unknown)
var domesticModels []ModelInfo // 国内模型(CN)
providerSet := make(map[string]struct{})
operatorSet := make(map[string]OperatorInfo)
for rows.Next() {
var m ModelInfo
if err := rows.Scan(
&m.ID, &m.Name, &m.ProviderName, &m.ProviderCountry,
&m.ContextLength, &m.Modality,
&m.InputPrice, &m.OutputPrice,
&m.Currency, &m.IsFree,
&m.OperatorName, &m.OperatorType, &m.Region,
); err != nil {
logger.Warn("扫描模型数据失败", "error", err)
continue
}
m.SceneTags = deriveSceneTags(m.Name, m.Modality, nil)
allModels = append(allModels, m)
if m.IsFree {
freeModels = append(freeModels, m)
}
// 国家分类 - 国内官方平台 vs OpenRouter上的国内模型
isDomesticOfficial := (m.ProviderCountry == "CN" || m.ProviderCountry == "cn") &&
(m.OperatorType == "official" || m.OperatorType == "cloud")
isDomesticReseller := (m.ProviderCountry == "CN" || m.ProviderCountry == "cn") &&
m.OperatorType == "reseller"
if isDomesticOfficial {
domesticModels = append(domesticModels, m)
} else if isDomesticReseller {
// OpenRouter上的国内模型归入国际分类但标记
intlModels = append(intlModels, m)
} else {
intlModels = append(intlModels, m)
}
providerSet[m.ProviderName] = struct{}{}
// 统计运营商
op := operatorSet[m.OperatorName]
op.Name = m.OperatorName
op.Type = m.OperatorType
op.Country = m.ProviderCountry
op.ModelCount++
if op.MinInputPrice == 0 || m.InputPrice < op.MinInputPrice {
op.MinInputPrice = m.InputPrice
}
op.AvgInputPrice = (op.AvgInputPrice*float64(op.ModelCount-1) + m.InputPrice) / float64(op.ModelCount)
operatorSet[m.OperatorName] = op
}
// 排序
sort.Slice(intlModels, func(i, j int) bool {
if intlModels[i].IsFree != intlModels[j].IsFree {
return intlModels[i].IsFree
}
return intlModels[i].InputPrice < intlModels[j].InputPrice
})
sort.Slice(domesticModels, func(i, j int) bool {
if domesticModels[i].IsFree != domesticModels[j].IsFree {
return domesticModels[i].IsFree
}
return domesticModels[i].InputPrice < domesticModels[j].InputPrice
})
sort.Slice(freeModels, func(i, j int) bool {
return freeModels[i].ContextLength > freeModels[j].ContextLength
})
// 提取TOP - 国际排除免费,国内包含免费(展示真实低价+免费精选)
var intlTop5 []ModelInfo
intlPaid := filterPaid(intlModels)
if len(intlPaid) > 5 {
intlTop5 = intlPaid[:5]
} else {
intlTop5 = intlPaid
}
var domesticTop10 []ModelInfo
// 国内模型:优先展示付费低价,然后补充免费模型
domesticPaid := filterPaid(domesticModels)
domesticTop10 = append(domesticTop10, domesticPaid...)
// 补充免费国内模型(按上下文排序)
var domesticFree []ModelInfo
for _, m := range domesticModels {
if m.IsFree {
domesticFree = append(domesticFree, m)
}
}
sort.Slice(domesticFree, func(i, j int) bool {
return domesticFree[i].ContextLength > domesticFree[j].ContextLength
})
for _, m := range domesticFree {
if len(domesticTop10) >= 10 {
break
}
domesticTop10 = append(domesticTop10, m)
}
// 免费模型只展示前20个 + 分类统计
var freeTop20 []ModelInfo
if len(freeModels) > 20 {
freeTop20 = freeModels[:20]
} else {
freeTop20 = freeModels
}
// 如果付费不足,用免费模型补充"推荐"
if len(intlTop5) == 0 && len(intlModels) > 0 {
if len(intlModels) > 5 {
intlTop5 = intlModels[:5]
} else {
intlTop5 = intlModels
}
}
if len(domesticTop10) == 0 && len(domesticModels) > 0 {
if len(domesticModels) > 10 {
domesticTop10 = domesticModels[:10]
} else {
domesticTop10 = domesticModels
}
}
// 运营商分类
var operators, resellers []OperatorInfo
for _, op := range operatorSet {
if op.Type == "cloud" || op.Type == "official" {
operators = append(operators, op)
} else {
resellers = append(resellers, op)
}
}
// 数据质量统计
var fresh, stale, cny, usd int
for _, m := range allModels {
if m.InputPrice > 0 || m.IsFree {
fresh++
} else {
stale++
}
if m.Currency == "CNY" {
cny++
} else if m.Currency == "USD" {
usd++
}
}
tencentPlans, err := loadTencentSubscriptionPlans(db)
if err != nil {
return nil, err
}
report := &ReportV3{
Date: date,
GeneratedAt: time.Now().Format(time.RFC3339),
TotalModels: len(allModels),
AllModels: allModels,
FreeModels: freeModels,
FreeTop20: freeTop20,
IntlTop5: intlTop5,
DomesticTop10: domesticTop10,
TencentSubscriptionPlans: tencentPlans,
Operators: operators,
Resellers: resellers,
HasCNYData: cny > 0,
HasDomesticData: len(domesticModels) > 0,
QualitySummary: DataQualitySummary{
Total: len(allModels),
Fresh: fresh,
Stale: stale,
CNY: cny,
USD: usd,
},
SignatureAuditConfig: signatureAuditCfg,
}
if signals, events, ok, err := loadMaterializedDailySignalSnapshot(db, date); err != nil {
logger.Warn("加载物化关键信号失败", "error", err)
} else if ok {
report.DailySignals = signals
report.ModelEvents = events
}
if report.DailySignals == (DailySignals{}) {
if signals, err := loadDailySignals(db, date); err != nil {
logger.Warn("加载日报变化信号失败", "error", err)
} else {
report.DailySignals = signals
}
}
if len(report.ModelEvents) == 0 {
if events, err := loadModelEvents(db, date); err != nil {
logger.Warn("加载模型级事件失败", "error", err)
} else {
report.ModelEvents = events
}
}
if summaries, rows, ok, err := loadSignatureAuditSection(db, signatureAuditCfg.Window); err != nil {
logger.Warn("加载结构签名稳定性摘要失败", "error", err)
} else if ok {
report.SignatureAuditSummaries = summaries
report.SignatureAuditRows = rows
}
decorateReportV1(report)
return report, nil
}
func loadMaterializedDailySignalSnapshot(db *sql.DB, date string) (DailySignals, []ModelEvent, bool, error) {
var (
signals DailySignals
rawTopEvents string
)
err := db.QueryRow(`
SELECT
new_models,
price_changes,
official_free,
aggregator_free,
unknown_free,
COALESCE(top_events::text, '[]')
FROM daily_signal_snapshot
WHERE signal_date = $1::date
AND status = 'generated'
`, date).Scan(
&signals.NewModels,
&signals.PriceChanges,
&signals.OfficialFree,
&signals.AggregatorFree,
&signals.UnknownFree,
&rawTopEvents,
)
if err == sql.ErrNoRows {
return DailySignals{}, nil, false, nil
}
if err != nil {
if strings.Contains(err.Error(), `relation "daily_signal_snapshot" does not exist`) {
return DailySignals{}, nil, false, nil
}
return DailySignals{}, nil, false, err
}
var events []ModelEvent
if err := json.Unmarshal([]byte(rawTopEvents), &events); err != nil {
return DailySignals{}, nil, false, fmt.Errorf("unmarshal materialized top_events: %w", err)
}
return signals, events, true, nil
}
func loadSignatureAuditSection(db *sql.DB, limitPerSource int) ([]SignatureAuditSourceSummary, []SignatureAuditReportRow, bool, error) {
summaries, rows, err := queryOfficialImportSignatureAuditWindow(db, limitPerSource, "", false)
if err != nil {
if strings.Contains(err.Error(), `relation "official_import_signature_audit_recent_view" does not exist`) ||
strings.Contains(err.Error(), `relation "official_import_signature_audit" does not exist`) {
return nil, nil, false, nil
}
return nil, nil, false, err
}
if len(summaries) == 0 {
return nil, nil, false, nil
}
reportSummaries := make([]SignatureAuditSourceSummary, 0, len(summaries))
for _, summary := range summaries {
reportSummaries = append(reportSummaries, SignatureAuditSourceSummary{
SourceKey: summary.SourceKey,
SourceLabel: signatureAuditSourceLabel(summary.SourceKey),
RunsInWindow: summary.RunsInWindow,
ChangedRuns: summary.ChangedRuns,
LatestCheckedAt: summary.LatestCheckedAt.Format("2006-01-02 15:04:05"),
LatestStatus: summary.LatestStatus,
LatestStructureState: summary.LatestStructureState,
})
}
reportRows := make([]SignatureAuditReportRow, 0, len(rows))
for _, row := range rows {
reportRows = append(reportRows, SignatureAuditReportRow{
SourceKey: row.SourceKey,
SourceLabel: signatureAuditSourceLabel(row.SourceKey),
RecentRank: row.RecentRank,
CheckedAt: row.CheckedAt.Format("2006-01-02 15:04:05"),
StructureState: row.StructureState,
StructureChanged: row.StructureChanged,
Status: row.Status,
DriftDetected: row.DriftDetected,
BaselineInitialized: row.BaselineInitialized,
StructureSHA256: row.StructureSHA256,
PreviousStructureSHA256: nullStringOrNone(row.PreviousObservedSHA256),
SnapshotPath: nullStringOrNone(row.SnapshotPath),
SignaturePath: nullStringOrNone(row.SignaturePath),
ErrorMessage: nullStringOrNone(row.ErrorMessage),
})
}
return reportSummaries, reportRows, true, nil
}
func loadTencentSubscriptionPlans(db *sql.DB) ([]SubscriptionPlanInfo, error) {
rows, err := db.Query(`
SELECT
COALESCE(mp.name, 'unknown') AS provider_name,
COALESCE(mp.name_cn, mp.name, 'unknown') AS provider_name_cn,
COALESCE(o.name, 'unknown') AS operator_name,
COALESCE(o.name_cn, o.name, 'unknown') AS operator_name_cn,
sp.plan_name,
sp.plan_family,
sp.tier,
COALESCE(sp.billing_cycle, ''),
sp.currency,
sp.list_price,
COALESCE(sp.price_unit, ''),
COALESCE(sp.quota_value, 0),
COALESCE(sp.quota_unit, ''),
COALESCE(sp.context_window, 0),
COALESCE(sp.model_scope, '[]'),
COALESCE(sp.source_url, ''),
COALESCE(TO_CHAR(sp.effective_date, 'YYYY-MM-DD'), ''),
COALESCE(sp.notes, '')
FROM subscription_plan sp
JOIN model_provider mp ON mp.id = sp.provider_id
LEFT JOIN operator o ON o.id = sp.operator_id
ORDER BY
COALESCE(o.name_cn, o.name, 'unknown') ASC,
sp.plan_family ASC,
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.ProviderName,
&plan.ProviderCN,
&plan.OperatorName,
&plan.OperatorCN,
&plan.PlanName,
&plan.PlanFamily,
&plan.Tier,
&plan.BillingCycle,
&plan.Currency,
&plan.ListPrice,
&plan.PriceUnit,
&plan.QuotaValue,
&plan.QuotaUnit,
&plan.ContextWindow,
&modelScopeRaw,
&plan.SourceURL,
&plan.EffectiveDate,
&plan.Notes,
); 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, priceUnit string) string {
unit := strings.ToLower(strings.TrimSpace(priceUnit))
switch {
case currency == "CNY" && unit == "cny/pack":
return fmt.Sprintf("¥%.2f/包", price)
case currency == "CNY":
return fmt.Sprintf("¥%.2f/月", price)
case currency == "USD" && unit == "usd/pack":
return fmt.Sprintf("$%.2f/pack", price)
case currency == "USD":
return fmt.Sprintf("$%.2f/month", price)
default:
if strings.TrimSpace(priceUnit) != "" {
return fmt.Sprintf("%.2f %s", price, priceUnit)
}
return fmt.Sprintf("%.2f %s", price, currency)
}
}
func formatPlanFamily(planFamily string) string {
switch strings.ToLower(strings.TrimSpace(planFamily)) {
case "token_plan":
return "Token Plan"
case "coding_plan":
return "Coding Plan"
case "package_plan":
return "套餐包"
default:
if strings.TrimSpace(planFamily) == "" {
return "-"
}
return planFamily
}
}
func formatBillingCycle(cycle string) string {
switch strings.ToLower(strings.TrimSpace(cycle)) {
case "monthly":
return "包月"
case "quarterly":
return "3个月"
case "":
return "-"
default:
return cycle
}
}
func formatPlanOperator(plan SubscriptionPlanInfo) string {
if strings.TrimSpace(plan.OperatorCN) != "" && strings.TrimSpace(plan.OperatorCN) != "unknown" {
return plan.OperatorCN
}
if strings.TrimSpace(plan.OperatorName) != "" && strings.TrimSpace(plan.OperatorName) != "unknown" {
return plan.OperatorName
}
if strings.TrimSpace(plan.ProviderCN) != "" && strings.TrimSpace(plan.ProviderCN) != "unknown" {
return plan.ProviderCN
}
if strings.TrimSpace(plan.ProviderName) != "" {
return plan.ProviderName
}
return "-"
}
func formatPlanNotes(notes string) string {
notes = strings.TrimSpace(notes)
if notes == "" {
return "-"
}
return notes
}
func formatSubscriptionQuota(value int64, unit string) string {
if value <= 0 {
return "-"
}
if unit == "tokens/month" {
switch {
case value%10000 == 0 && value < 100000000:
return fmt.Sprintf("%d万 Tokens/月", value/10000)
case value%100000000 == 0:
return fmt.Sprintf("%d亿 Tokens/月", value/100000000)
case value >= 10000000:
return fmt.Sprintf("%.1f亿 Tokens/月", float64(value)/100000000)
}
}
return fmt.Sprintf("%d %s", value, unit)
}
func formatContextWindowCompact(value int) string {
if value <= 0 {
return "-"
}
if value%(1024*1024) == 0 {
return fmt.Sprintf("%dM", value/(1024*1024))
}
if value%1024 == 0 {
return fmt.Sprintf("%dK", value/1024)
}
return fmt.Sprintf("%d", value)
}
// 场景标签
type SceneTag string
const (
SceneCode SceneTag = "代码"
SceneReasoning SceneTag = "推理"
SceneWriting SceneTag = "写作"
SceneVision SceneTag = "视觉"
SceneChat SceneTag = "对话"
)
func deriveSceneTags(name, modality string, capabilities []string) []SceneTag {
var tags []SceneTag
lowerName := strings.ToLower(name)
// 代码模型
if strings.Contains(lowerName, "codex") || strings.Contains(lowerName, "coder") ||
strings.Contains(lowerName, "code") || strings.Contains(modality, "code") {
tags = append(tags, SceneCode)
}
// 推理模型
if strings.Contains(lowerName, "o1") || strings.Contains(lowerName, "o3") ||
strings.Contains(lowerName, "o4") || strings.Contains(lowerName, "reasoning") ||
strings.Contains(lowerName, "r1") || strings.Contains(lowerName, "thinking") {
tags = append(tags, SceneReasoning)
}
// 视觉模型
if strings.Contains(modality, "vision") || strings.Contains(modality, "multimodal") ||
strings.Contains(lowerName, "vl") || strings.Contains(lowerName, "vision") {
tags = append(tags, SceneVision)
}
// 写作/对话(兜底)
if len(tags) == 0 {
if strings.Contains(modality, "text") || strings.Contains(modality, "chat") {
tags = append(tags, SceneChat)
}
}
return tags
}
func loadDailySignals(db *sql.DB, date string) (DailySignals, error) {
signals := DailySignals{}
if err := db.QueryRow(`
SELECT COUNT(*)
FROM models
WHERE deleted_at IS NULL
AND DATE(created_at) = $1::date
`, date).Scan(&signals.NewModels); err != nil {
return signals, err
}
if err := db.QueryRow(`
SELECT COUNT(*)
FROM pricing_history
WHERE DATE(changed_at) = $1::date
`, date).Scan(&signals.PriceChanges); err != nil {
return signals, err
}
return signals, nil
}
func 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...)
releaseEvents, err := loadOfficialReleaseEvents(db, date)
if err != nil {
return nil, err
}
events = append(events, releaseEvents...)
promoEvents, err := loadPromoCampaignEvents(date)
if err != nil {
return nil, err
}
events = append(events, promoEvents...)
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 loadPromoCampaignEvents(date string) ([]ModelEvent, error) {
definitions, err := loadPromoCampaignDefinitions()
if err != nil {
return nil, err
}
var events []ModelEvent
for _, definition := range definitions {
if definition.Date != date {
continue
}
events = append(events, ModelEvent{
EventType: "promo_campaign",
ModelName: definition.ModelName,
ProviderName: definition.ProviderName,
OperatorName: definition.OperatorName,
Audience: firstNonEmpty(definition.Audience, "适合计划利用活动窗口压低成本的团队"),
TrustLabel: firstNonEmpty(definition.TrustLabel, "官方来源 / 一级证据"),
SourceKindLabel: firstNonEmpty(definition.SourceKindLabel, "官方活动页"),
PrimarySource: definition.PrimarySource,
UpdatedAt: formatEventUpdatedAt("", definition.Date),
EvidenceDetail: definition.EvidenceDetail,
Baseline: firstNonEmpty(definition.Baseline, "活动窗口开启"),
Summary: definition.Summary,
Priority: maxInt(definition.Priority, 115),
})
}
return events, nil
}
func loadPromoCampaignDefinitions() ([]PromoCampaignDefinition, error) {
path, err := resolvePromoCampaignDataPath()
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, err
}
body, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var definitions []PromoCampaignDefinition
if err := json.Unmarshal(body, &definitions); err != nil {
return nil, err
}
return definitions, nil
}
func resolvePromoCampaignDataPath() (string, error) {
candidates := []string{
filepath.Join("scripts", "testdata", "report_promo_campaigns.json"),
filepath.Join("testdata", "report_promo_campaigns.json"),
}
for _, candidate := range candidates {
if _, err := os.Stat(candidate); err == nil {
return candidate, nil
}
}
return "", os.ErrNotExist
}
func loadOfficialReleaseEvents(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,
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, 'Unknown') AS operator_name,
COALESCE(lp.operator_type, 'reseller') AS operator_type,
COALESCE(m.source_url, '') AS source_url,
COALESCE(m.date_confidence, 'unknown') AS date_confidence,
COALESCE(m.date_source_kind, 'unknown') AS date_source_kind,
COALESCE(mp.country, 'unknown') AS provider_country,
COALESCE(m.release_date, m.created_at::date) AS release_date,
COALESCE(lp.currency, 'USD') AS currency
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 m.release_date = $1::date
AND COALESCE(m.source_url, '') <> ''
AND COALESCE(lp.operator_type, 'reseller') IN ('official', 'cloud')
ORDER BY m.release_date 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
sourceURL string
dateConfidence string
dateSourceKind string
providerCountry string
releaseDate time.Time
currency string
)
if err := rows.Scan(
&modelName,
&providerName,
&operatorName,
&operatorType,
&sourceURL,
&dateConfidence,
&dateSourceKind,
&providerCountry,
&releaseDate,
&currency,
); err != nil {
return nil, err
}
model := ModelInfo{
Name: modelName,
ProviderName: providerName,
ProviderCountry: providerCountry,
Currency: currency,
OperatorName: operatorName,
OperatorType: operatorType,
}
events = append(events, ModelEvent{
EventType: "official_release",
ModelName: modelName,
ProviderName: providerName,
OperatorName: operatorName,
Audience: "适合需要复查默认选型与路线图判断的团队",
TrustLabel: buildReleaseTrustLabel(model, dateConfidence),
SourceKindLabel: buildReleaseSourceKindLabel(dateSourceKind, dateConfidence),
PrimarySource: sourceURL,
UpdatedAt: releaseDate.Format("2006-01-02 15:04"),
EvidenceDetail: buildReleaseEvidenceDetail(dateSourceKind, dateConfidence),
Baseline: "官方首次发布",
Summary: fmt.Sprintf("%s 官方发布新模型,值得优先复查默认选型。", providerName),
Currency: currency,
Priority: 120,
})
}
return events, rows.Err()
}
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,
m.created_at
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
createdAt time.Time
)
if err := rows.Scan(
&modelName,
&providerName,
&operatorName,
&operatorType,
&currency,
&inputPrice,
&outputPrice,
&isFree,
&contextLength,
&providerCountry,
&createdAt,
); 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,
Audience: "适合想尽快验证新模型价值的选型读者",
TrustLabel: buildTrustLabel(model),
SourceKindLabel: "模型快照",
PrimarySource: buildPrimarySource("region_pricing", operatorName),
UpdatedAt: createdAt.Format("2006-01-02 15:04"),
EvidenceDetail: "models.created_at = 今日,且已存在最新价格快照",
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,
ph.changed_at
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
changedAt time.Time
)
if err := rows.Scan(
&modelName,
&providerName,
&operatorName,
&operatorType,
&currency,
&oldInputPrice,
&newInputPrice,
&oldOutputPrice,
&newOutputPrice,
&providerCountry,
&changedAt,
); 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"
summary := "价格上调已足以影响默认成本,需要确认备用模型。"
if changePct < 0 {
eventType = "price_cut"
summary = "价格下降已足以影响默认选型,值得重新评估同类模型。"
}
events = append(events, ModelEvent{
EventType: eventType,
ModelName: modelName,
ProviderName: providerName,
OperatorName: operatorName,
Audience: buildPriceEventAudience(changePct),
TrustLabel: buildTrustLabel(model),
SourceKindLabel: "价格快照",
PrimarySource: "pricing_history",
UpdatedAt: changedAt.Format("2006-01-02 15:04"),
EvidenceDetail: buildPriceEvidenceDetail(changePct, oldInputPrice, newInputPrice, currency),
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),
})
}
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 maxInt(a, b int) int {
if a > b {
return a
}
return b
}
func abs(v float64) float64 {
if v < 0 {
return -v
}
return v
}
func buildPriceEventAudience(changePct float64) string {
if changePct < 0 {
return "适合以成本为先、准备趁降价重排默认选型的团队"
}
return "适合需要提前准备替代模型和预算回退方案的团队"
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return value
}
}
return ""
}
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.ModelEvents = enrichModelEvents(r)
r.PageMode = buildPageModeWithEvents(r.DailySignals, r.ModelEvents)
r.MarketLabels = buildMarketLabels(r)
r.HeroSummary, r.HeroEvidence = buildHeroSummary(r)
r.SceneSections = buildSceneSections(r)
r.ActionItems = buildActionItems(r)
r.HeadlineItems = buildHeadlineItems(r)
r.AppendixLinks = []AppendixLink{
{Title: "完整价格", Description: "查看完整模型价格表", Anchor: "#appendix-pricing"},
{Title: "完整免费", Description: "查看全部免费模型与来源", Anchor: "#appendix-free"},
{Title: "平台覆盖", Description: "查看官方平台与聚合平台覆盖", Anchor: "#appendix-platforms"},
}
}
func 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,
Audience: "适合先试后买、但需要先判断免费来源的读者",
TrustLabel: buildTrustLabel(model),
SourceKindLabel: "免费策略快照",
PrimarySource: buildPrimarySource("free_snapshot", model.OperatorName),
UpdatedAt: formatEventUpdatedAt(r.GeneratedAt, r.Date),
EvidenceDetail: buildFreeEvidenceDetail(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
}
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 {
return buildPageModeWithEvents(signals, nil)
}
func buildPageModeWithEvents(signals DailySignals, events []ModelEvent) string {
if hasEventType(events, "official_release") || hasEventType(events, "promo_campaign") {
return "hot"
}
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 buildPageModeWithEvents(r.DailySignals, r.ModelEvents) {
case "hot":
labels = append(labels, "热点日")
case "calm":
labels = append(labels, "平静日")
default:
labels = append(labels, "常规日")
}
if hasEventType(r.ModelEvents, "official_release") {
labels = append(labels, "官方发布")
}
if hasEventType(r.ModelEvents, "promo_campaign") {
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 hasEventType(events []ModelEvent, eventType string) bool {
for _, event := range events {
if event.EventType == eventType {
return true
}
}
return false
}
func buildHeroSummary(r *ReportV3) (string, string) {
if official := firstEventByType(r.ModelEvents, "official_release"); official != nil {
return fmt.Sprintf("今天最值得关注的是 %s 已出现官方发布信号,优先复查它对默认选型的影响。", official.ModelName),
fmt.Sprintf("主来源:%s", official.PrimarySource)
}
if promo := firstEventByType(r.ModelEvents, "promo_campaign"); promo != nil {
return fmt.Sprintf("今天最值得关注的是 %s 已进入活动窗口,优先判断这次活动是否值得改变默认成本策略。", promo.ModelName),
fmt.Sprintf("主来源:%s", promo.PrimarySource)
}
if summary, changedCount := topChangedSignatureAuditSummary(r.SignatureAuditSummaries, effectiveSignatureAuditReportConfig(r).ChangedRunsThreshold); summary != nil {
return fmt.Sprintf("今天最值得关注的是 %s 的价格页结构开始抖动,优先复查抓取和解析结果是否仍然可信。", summary.SourceLabel),
fmt.Sprintf("最近 %d 次中出现 %d 次结构变化;当前有 %d 个平台处于变化窗口。", summary.RunsInWindow, summary.ChangedRuns, changedCount)
}
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 firstEventByType(events []ModelEvent, eventType string) *ModelEvent {
for i := range events {
if events[i].EventType == eventType {
return &events[i]
}
}
return nil
}
func buildHeadlineItems(r *ReportV3) []HeadlineItem {
var items []HeadlineItem
if auditItem, ok := buildSignatureAuditHeadlineItem(r.SignatureAuditSummaries, effectiveSignatureAuditReportConfig(r).ChangedRunsThreshold); ok {
items = append(items, auditItem)
}
if eventItems := buildHeadlineItemsFromEvents(r.ModelEvents); len(eventItems) > 0 {
items = append(items, eventItems...)
if len(items) > 4 {
return items[:4]
}
return items
}
if r.DailySignals.NewModels > 0 {
items = append(items, HeadlineItem{
Label: "新模型",
Title: fmt.Sprintf("今日新增 %d 个模型进入情报池", r.DailySignals.NewModels),
Summary: "建议优先复查今天的新上架模型是否改变当前低成本或中文场景的最优解。",
Audience: "适合想快速筛出新增机会的读者",
Baseline: "首次出现",
TrustLabel: "数据库追踪",
Tone: "info",
})
}
if r.DailySignals.PriceChanges > 0 {
items = append(items, HeadlineItem{
Label: "价格变化",
Title: fmt.Sprintf("今日检测到 %d 次价格变化", r.DailySignals.PriceChanges),
Summary: "价格变化已足以影响低成本选型,建议重新确认默认模型与备用模型。",
Audience: "适合以成本为先、需要重排默认选型的团队",
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),
Audience: "适合想先试用、但不想误把聚合免费当官方免费的读者",
Baseline: "今日快照",
TrustLabel: "来源已分层",
Tone: "warning",
})
}
if len(items) == 0 {
items = append(items, HeadlineItem{
Label: "观察重点",
Title: "今日无重大上新或显著调价",
Summary: "平静日优先关注稳定商用与长期成本,避免被噪音列表打断判断。",
Audience: "适合更重视稳定性和长期成本的团队",
Baseline: "较昨日",
TrustLabel: "日报编辑规则",
Tone: "neutral",
})
}
if len(items) > 3 {
return items[:3]
}
return items
}
func buildHeadlineItemsFromEvents(events []ModelEvent) []HeadlineItem {
if len(events) == 0 {
return nil
}
limit := 3
if hasEventType(events, "official_release") || hasEventType(events, "promo_campaign") {
limit = 4
}
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) >= limit {
break
}
}
return items
}
func headlineItemFromModelEvent(event ModelEvent) HeadlineItem {
item := HeadlineItem{
Title: event.ModelName,
Summary: event.Summary,
Audience: event.Audience,
Baseline: event.Baseline,
TrustLabel: event.TrustLabel,
SourceKindLabel: event.SourceKindLabel,
PrimarySource: event.PrimarySource,
UpdatedAt: event.UpdatedAt,
EvidenceDetail: event.EvidenceDetail,
Tone: "neutral",
}
switch event.EventType {
case "official_release":
item.Label = "一级官方发布"
item.Title = fmt.Sprintf("%s 官方发布", event.ModelName)
item.Tone = "official-primary"
if event.SourceKindLabel == "二级权威佐证发布" {
item.Label = "二级权威佐证"
item.Title = fmt.Sprintf("%s 进入权威佐证发布时间线", event.ModelName)
item.Tone = "secondary-evidence"
}
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 "promo_campaign":
item.Label = "营销活动"
item.Title = fmt.Sprintf("%s 进入活动窗口", event.ModelName)
item.Tone = "promo"
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
}
func buildPrimarySource(sourceKind, operatorName string) string {
switch sourceKind {
case "region_pricing":
if operatorName == "" {
return "region_pricing"
}
return operatorName + " / region_pricing"
case "free_snapshot":
if operatorName == "" {
return "free snapshot"
}
return operatorName + " / free snapshot"
default:
return sourceKind
}
}
func buildPriceEvidenceDetail(changePct, oldPrice, newPrice float64, currency string) string {
direction := "上涨"
if changePct < 0 {
direction = "下降"
}
return fmt.Sprintf(
"pricing_history 记录到输入价格由 %s 调整为 %s较昨日%s %.0f%%",
formatPrice(oldPrice, currency),
formatPrice(newPrice, currency),
direction,
abs(changePct),
)
}
func buildReleaseSourceKindLabel(dateSourceKind, dateConfidence string) string {
switch {
case dateSourceKind == "secondary_authoritative_report" || dateConfidence == "secondary_authoritative":
return "二级权威佐证发布"
case dateSourceKind == "official_announcement" && dateConfidence == "official_primary":
return "一级官方发布"
case dateSourceKind == "official_product_page":
return "官方产品页"
case dateSourceKind == "catalog_backfill":
return "目录回填"
default:
return "一级官方发布"
}
}
func buildReleaseEvidenceDetail(dateSourceKind, dateConfidence string) string {
switch {
case dateSourceKind == "secondary_authoritative_report" || dateConfidence == "secondary_authoritative":
return "models.release_date = 今日,发布日期采用次级权威报道佐证,模型来源页保留官方文档"
case dateSourceKind == "official_announcement" && dateConfidence == "official_primary":
return "models.release_date = 今日,且 source_url 指向官方发布页"
case dateSourceKind == "official_product_page":
return "models.release_date = 今日,来源页为官方产品页,发布日期置信度待确认"
case dateSourceKind == "catalog_backfill":
return "models.release_date = 今日,发布日期来自目录级元数据回填"
default:
return "models.release_date = 今日,且已记录发布日期证据元数据"
}
}
func buildReleaseTrustLabel(model ModelInfo, dateConfidence string) string {
base := buildTrustLabel(model)
switch dateConfidence {
case "official_primary":
return base + " / 一级证据"
case "secondary_authoritative":
return base + " / 二级佐证"
default:
return base
}
}
func buildFreeEvidenceDetail(model ModelInfo) string {
switch classifyFreeSource(model) {
case "官方免费":
return fmt.Sprintf("%s 当前快照显示为官方免费入口", model.OperatorName)
case "聚合免费":
return fmt.Sprintf("%s 当前快照显示为聚合免费入口", model.OperatorName)
default:
return fmt.Sprintf("%s 当前快照显示免费,但来源仍待确认", model.OperatorName)
}
}
func formatEventUpdatedAt(value, fallbackDate string) string {
if strings.TrimSpace(value) != "" {
return value
}
if fallbackDate != "" {
return fallbackDate + " 00:00"
}
return "-"
}
func buildActionItems(r *ReportV3) []ActionItem {
var actions []ActionItem
if action, ok := buildSignatureAuditActionItem(r.SignatureAuditSummaries, effectiveSignatureAuditReportConfig(r).ChangedRunsThreshold); ok {
actions = append(actions, action)
}
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 topChangedSignatureAuditSummary(summaries []SignatureAuditSourceSummary, changedRunsThreshold int) (*SignatureAuditSourceSummary, int) {
var selected *SignatureAuditSourceSummary
changedCount := 0
for i := range summaries {
summary := &summaries[i]
if summary.ChangedRuns < changedRunsThreshold {
continue
}
changedCount++
if selected == nil {
selected = summary
continue
}
if summary.ChangedRuns > selected.ChangedRuns {
selected = summary
continue
}
if summary.ChangedRuns == selected.ChangedRuns && summary.SourceLabel < selected.SourceLabel {
selected = summary
}
}
return selected, changedCount
}
func buildSignatureAuditHeadlineItem(summaries []SignatureAuditSourceSummary, changedRunsThreshold int) (HeadlineItem, bool) {
summary, changedCount := topChangedSignatureAuditSummary(summaries, changedRunsThreshold)
if summary == nil {
return HeadlineItem{}, false
}
item := HeadlineItem{
Label: "结构波动",
Title: fmt.Sprintf("%s 结构签名开始抖动", summary.SourceLabel),
Summary: fmt.Sprintf("最近 %d 次中出现 %d 次结构变化,当前共有 %d 个平台进入变化窗口。", summary.RunsInWindow, summary.ChangedRuns, changedCount),
Audience: "适合维护官方价格 importer、需要优先确认抓取与解析可信度的团队",
Baseline: "近期结构签名窗口",
TrustLabel: "结构签名巡检",
SourceKindLabel: "官方价格页结构签名",
PrimarySource: "official_import_signature_audit_recent_view",
UpdatedAt: summary.LatestCheckedAt,
EvidenceDetail: fmt.Sprintf("最新状态=%s最新结构状态=%s", summary.LatestStatus, summary.LatestStructureState),
Tone: "caution",
}
return item, true
}
func buildSignatureAuditActionItem(summaries []SignatureAuditSourceSummary, changedRunsThreshold int) (ActionItem, bool) {
summary, changedCount := topChangedSignatureAuditSummary(summaries, changedRunsThreshold)
if summary == nil {
return ActionItem{}, false
}
return ActionItem{
Title: fmt.Sprintf("优先复查 %s 价格 importer", summary.SourceLabel),
Audience: "适合负责官方价格采集、需要先确认页面结构是否漂移的维护者",
Evidence: fmt.Sprintf("最近 %d 次中出现 %d 次结构变化;当前共有 %d 个平台进入变化窗口。", summary.RunsInWindow, summary.ChangedRuns, changedCount),
Tags: []string{"结构稳定性", "官方价格页", summary.SourceLabel},
}, true
}
func effectiveSignatureAuditReportConfig(r *ReportV3) SignatureAuditReportConfig {
cfg := SignatureAuditReportConfig{
Window: 5,
ChangedRunsThreshold: 1,
}
if r == nil {
return cfg
}
if r.SignatureAuditConfig.Window > 0 {
cfg.Window = r.SignatureAuditConfig.Window
}
if r.SignatureAuditConfig.ChangedRunsThreshold > 0 {
cfg.ChangedRunsThreshold = r.SignatureAuditConfig.ChangedRunsThreshold
}
return cfg
}
func findSceneSection(sections []SceneSection, title string) *SceneSection {
for i := range sections {
if sections[i].Title == title {
return &sections[i]
}
}
return nil
}
func buildSceneSections(r *ReportV3) []SceneSection {
allModels := r.AllModels
if len(allModels) == 0 {
allModels = append(allModels, r.IntlTop5...)
allModels = append(allModels, r.DomesticTop10...)
allModels = append(allModels, r.FreeModels...)
}
builders := []struct {
title string
description string
filter func(ModelInfo) bool
sorter func(a, b ModelInfo) bool
}{
{
title: "低成本编码",
description: "优先看能明显降低编码与工具调用成本的模型。",
filter: func(m ModelInfo) bool {
return hasSceneTag(m, SceneCode)
},
sorter: func(a, b ModelInfo) bool {
return compareByCostAndTrust(a, b)
},
},
{
title: "中文通用",
description: "优先看中文业务、写作和稳定对话能力。",
filter: func(m ModelInfo) bool {
return strings.EqualFold(m.ProviderCountry, "CN") || hasSceneTag(m, SceneWriting) || hasSceneTag(m, SceneChat)
},
sorter: func(a, b ModelInfo) bool {
return compareByTrustThenPrice(a, b)
},
},
{
title: "Agent / 工具调用",
description: "优先看推理、代码和长上下文能力。",
filter: func(m ModelInfo) bool {
return hasSceneTag(m, SceneReasoning) || hasSceneTag(m, SceneCode)
},
sorter: func(a, b ModelInfo) bool {
if a.ContextLength != b.ContextLength {
return a.ContextLength > b.ContextLength
}
return compareByTrustThenPrice(a, b)
},
},
{
title: "视觉 / 多模态",
description: "优先看视觉、多模态和图像理解相关模型。",
filter: func(m ModelInfo) bool {
return hasSceneTag(m, SceneVision)
},
sorter: func(a, b ModelInfo) bool {
return compareByTrustThenPrice(a, b)
},
},
}
var sections []SceneSection
for _, builder := range builders {
var matches []ModelInfo
for _, model := range allModels {
if builder.filter(model) {
matches = append(matches, model)
}
}
if len(matches) == 0 {
continue
}
sort.Slice(matches, func(i, j int) bool {
return builder.sorter(matches[i], matches[j])
})
recommendations := buildRecommendations(matches, 3)
if len(recommendations) == 0 {
continue
}
section := SceneSection{
Title: builder.title,
Description: builder.description,
Lead: recommendations[0],
}
if len(recommendations) > 1 {
section.Others = recommendations[1:]
}
sections = append(sections, section)
}
return sections
}
func signatureAuditSourceLabel(sourceKey string) string {
switch strings.TrimSpace(sourceKey) {
case "vertex_pricing_signature":
return "Google Cloud Vertex AI"
case "cloudflare_pricing_signature":
return "Cloudflare Workers AI"
case "perplexity_pricing_signature":
return "Perplexity API"
default:
if strings.TrimSpace(sourceKey) == "" {
return "未知平台"
}
return sourceKey
}
}
func buildSignatureAuditSectionLead(r *ReportV3) string {
if len(r.SignatureAuditSummaries) == 0 {
return ""
}
cfg := effectiveSignatureAuditReportConfig(r)
changedSources := make([]string, 0)
for _, summary := range r.SignatureAuditSummaries {
if summary.ChangedRuns >= cfg.ChangedRunsThreshold {
changedSources = append(changedSources, summary.SourceLabel)
}
}
if len(changedSources) == 0 {
return fmt.Sprintf("最近窗口内未出现达到阈值的结构变化,当前阈值为 %d 次,官方价格页结构整体稳定。", cfg.ChangedRunsThreshold)
}
return fmt.Sprintf("最近窗口内有 %d 个平台达到结构变化阈值(%d 次),优先复查 %s。", len(changedSources), cfg.ChangedRunsThreshold, strings.Join(changedSources, " / "))
}
func signatureAuditSummaryTone(r *ReportV3, summary SignatureAuditSourceSummary) string {
if summary.ChangedRuns >= effectiveSignatureAuditReportConfig(r).ChangedRunsThreshold {
return "warning"
}
return "official"
}
func buildRecommendations(models []ModelInfo, limit int) []Recommendation {
seen := make(map[string]struct{})
var result []Recommendation
for _, model := range models {
key := model.Name + "|" + model.OperatorName
if _, exists := seen[key]; exists {
continue
}
seen[key] = struct{}{}
result = append(result, Recommendation{
Name: model.Name,
Provider: model.ProviderName,
Operator: model.OperatorName,
Usage: buildUsage(model),
PriceSummary: buildPriceSummary(model),
Evidence: buildModelEvidence(model),
TrustLabel: buildTrustLabel(model),
Tags: buildModelTags(model),
})
if len(result) >= limit {
break
}
}
return result
}
func hasSceneTag(model ModelInfo, target SceneTag) bool {
for _, tag := range model.SceneTags {
if tag == target {
return true
}
}
return false
}
func compareByCostAndTrust(a, b ModelInfo) bool {
if a.IsFree != b.IsFree {
return a.IsFree
}
if trustRank(a) != trustRank(b) {
return trustRank(a) < trustRank(b)
}
if normalizePrice(a.InputPrice, a.Currency) != normalizePrice(b.InputPrice, b.Currency) {
return normalizePrice(a.InputPrice, a.Currency) < normalizePrice(b.InputPrice, b.Currency)
}
return a.ContextLength > b.ContextLength
}
func compareByTrustThenPrice(a, b ModelInfo) bool {
if trustRank(a) != trustRank(b) {
return trustRank(a) < trustRank(b)
}
if a.IsFree != b.IsFree {
return a.IsFree
}
if normalizePrice(a.InputPrice, a.Currency) != normalizePrice(b.InputPrice, b.Currency) {
return normalizePrice(a.InputPrice, a.Currency) < normalizePrice(b.InputPrice, b.Currency)
}
return a.ContextLength > b.ContextLength
}
func normalizePrice(price float64, currency string) float64 {
if price <= 0 {
return 0
}
if currency == "USD" {
return price * USD_TO_CNY
}
return price
}
func trustRank(model ModelInfo) int {
switch model.OperatorType {
case "official", "cloud":
return 0
case "reseller":
if isVerifiedAggregator(model.OperatorName) {
return 1
}
}
return 2
}
func buildUsage(model ModelInfo) string {
switch {
case hasSceneTag(model, SceneVision):
return "适合视觉与多模态"
case hasSceneTag(model, SceneCode):
return "适合编码与工具调用"
case hasSceneTag(model, SceneReasoning):
return "适合推理与 Agent"
case hasSceneTag(model, SceneWriting):
return "适合中文写作与通用对话"
default:
return "适合通用场景"
}
}
func buildPriceSummary(model ModelInfo) string {
if model.IsFree || model.InputPrice <= 0 {
return "免费"
}
return fmt.Sprintf("输入 %s/M", formatPrice(model.InputPrice, model.Currency))
}
func buildModelEvidence(model ModelInfo) string {
if model.IsFree {
switch classifyFreeSource(model) {
case "官方免费":
return "官方免费额度已确认"
case "聚合免费":
return "聚合免费,适合尝鲜"
default:
return "免费机制仍待确认"
}
}
if model.ContextLength >= 1024*256 {
return fmt.Sprintf("长上下文 %s", formatContextWindowCompact(model.ContextLength))
}
return fmt.Sprintf("输入 %s/M", formatPrice(model.InputPrice, model.Currency))
}
func buildTrustLabel(model ModelInfo) string {
switch model.OperatorType {
case "official", "cloud":
return "官方来源"
case "reseller":
if isVerifiedAggregator(model.OperatorName) {
return "聚合来源"
}
}
return "待验证来源"
}
func buildModelTags(model ModelInfo) []string {
tags := []string{buildTrustLabel(model)}
if model.IsFree {
tags = append(tags, classifyFreeSource(model))
}
if len(model.SceneTags) > 0 {
tags = append(tags, string(model.SceneTags[0]))
}
if len(tags) > 3 {
return tags[:3]
}
return tags
}
// ============ Markdown生成 ============
func generateMarkdownV3(r *ReportV3, path string) error {
decorateReportV1(r)
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
fmt.Fprintf(f, "# 🤖 LLM Intelligence Hub - 每日情报报告\n\n")
fmt.Fprintf(f, "**报告日期**: %s \n**生成时间**: %s \n**页面状态**: %s \n\n", r.Date, r.GeneratedAt, r.PageMode)
fmt.Fprintf(f, "## 今日结论\n\n")
fmt.Fprintf(f, "> %s\n\n", r.HeroSummary)
if r.HeroEvidence != "" {
fmt.Fprintf(f, "- 证据: %s\n", r.HeroEvidence)
}
if len(r.MarketLabels) > 0 {
fmt.Fprintf(f, "- 市场标签: %s\n", strings.Join(r.MarketLabels, " / "))
}
fmt.Fprintf(f, "\n")
fmt.Fprintf(f, "## 今日行动建议\n\n")
for i, item := range r.ActionItems {
fmt.Fprintf(f, "%d. **%s** \n", i+1, item.Title)
fmt.Fprintf(f, " %s \n", item.Audience)
fmt.Fprintf(f, " 证据: %s \n", item.Evidence)
if len(item.Tags) > 0 {
fmt.Fprintf(f, " 标签: %s\n", strings.Join(item.Tags, " / "))
}
}
fmt.Fprintf(f, "\n")
fmt.Fprintf(f, "## 今日变化\n\n")
fmt.Fprintf(f, "| 指标 | 数值 |\n|------|------|\n")
fmt.Fprintf(f, "| 今日新增模型 | %d |\n", r.DailySignals.NewModels)
fmt.Fprintf(f, "| 今日价格变化 | %d |\n", r.DailySignals.PriceChanges)
fmt.Fprintf(f, "| 官方免费 | %d |\n", r.DailySignals.OfficialFree)
fmt.Fprintf(f, "| 聚合免费 | %d |\n", r.DailySignals.AggregatorFree)
fmt.Fprintf(f, "| 待确认免费 | %d |\n\n", r.DailySignals.UnknownFree)
for _, item := range r.HeadlineItems {
fmt.Fprintf(f, "### %s · %s\n\n", item.Label, item.Title)
fmt.Fprintf(f, "- 影响: %s\n", item.Summary)
if item.Audience != "" {
fmt.Fprintf(f, "- 影响对象: %s\n", item.Audience)
}
if item.SourceKindLabel != "" {
fmt.Fprintf(f, "- 事件来源: %s\n", item.SourceKindLabel)
}
if item.PrimarySource != "" {
fmt.Fprintf(f, "- 主来源: %s\n", item.PrimarySource)
}
if item.UpdatedAt != "" {
fmt.Fprintf(f, "- 更新时间: %s\n", item.UpdatedAt)
}
fmt.Fprintf(f, "- 基线: %s\n", item.Baseline)
if item.EvidenceDetail != "" {
fmt.Fprintf(f, "- 判定依据: %s\n", item.EvidenceDetail)
}
fmt.Fprintf(f, "- 可信度: %s\n\n", item.TrustLabel)
}
if len(r.SignatureAuditSummaries) > 0 {
fmt.Fprintf(f, "## 结构稳定性\n\n")
if lead := buildSignatureAuditSectionLead(r); lead != "" {
fmt.Fprintf(f, "> %s\n\n", lead)
}
fmt.Fprintf(f, "| 平台 | 近期窗口 | 最新状态 | 最新结构状态 | 最近检查 |\n|------|----------|----------|--------------|----------|\n")
for _, item := range r.SignatureAuditSummaries {
fmt.Fprintf(f, "| %s | 最近 %d 次中出现 %d 次结构变化 | %s | %s | %s |\n",
item.SourceLabel, item.RunsInWindow, item.ChangedRuns, item.LatestStatus, item.LatestStructureState, item.LatestCheckedAt)
}
fmt.Fprintf(f, "\n")
if len(r.SignatureAuditRows) > 0 {
fmt.Fprintf(f, "### 近期结构记录\n\n")
fmt.Fprintf(f, "| 平台 | recent_rank | 检查时间 | 结构状态 | 状态 | 结构签名 |\n|------|-------------|----------|----------|------|----------|\n")
for _, item := range r.SignatureAuditRows {
fmt.Fprintf(f, "| %s | %d | %s | %s | %s | %s |\n",
item.SourceLabel, item.RecentRank, item.CheckedAt, item.StructureState, item.Status, item.StructureSHA256)
}
fmt.Fprintf(f, "\n")
}
}
if len(r.FreeBreakdown) > 0 {
fmt.Fprintf(f, "### 免费来源分层\n\n")
fmt.Fprintf(f, "| 类型 | 数量 | 说明 |\n|------|------|------|\n")
for _, item := range r.FreeBreakdown {
fmt.Fprintf(f, "| %s | %d | %s |\n", item.Label, item.Count, item.Description)
}
fmt.Fprintf(f, "\n")
}
fmt.Fprintf(f, "## 场景推荐\n\n")
for _, section := range r.SceneSections {
fmt.Fprintf(f, "### %s\n\n", section.Title)
fmt.Fprintf(f, "- 主推荐: **%s** (%s) · %s · %s\n", section.Lead.Name, section.Lead.Provider, section.Lead.PriceSummary, section.Lead.Evidence)
for _, other := range section.Others {
fmt.Fprintf(f, "- 备选: %s (%s) · %s\n", other.Name, other.Provider, other.PriceSummary)
}
fmt.Fprintf(f, "\n")
}
fmt.Fprintf(f, "## 完整数据附录\n\n")
for _, link := range r.AppendixLinks {
fmt.Fprintf(f, "- **%s**: %s\n", link.Title, link.Description)
}
fmt.Fprintf(f, "\n")
fmt.Fprintf(f, "### 数据质量摘要\n\n")
fmt.Fprintf(f, "| 指标 | 数值 |\n|------|------|\n")
fmt.Fprintf(f, "| 模型总数 | %d |\n", r.QualitySummary.Total)
fmt.Fprintf(f, "| 数据新鲜 | %d |\n", r.QualitySummary.Fresh)
fmt.Fprintf(f, "| CNY定价 | %d |\n", r.QualitySummary.CNY)
fmt.Fprintf(f, "| USD定价 | %d |\n\n", r.QualitySummary.USD)
if len(r.IntlTop5) > 0 {
fmt.Fprintf(f, "### 国际低价模型 TOP 5\n\n")
fmt.Fprintf(f, "| 排名 | 模型 | 厂商 | 输入 | 输出 | 上下文 |\n")
fmt.Fprintf(f, "|------|------|------|------|------|--------|\n")
for i, m := range r.IntlTop5 {
fmt.Fprintf(f, "| %d | %s | %s | %s | %s | %s |\n",
i+1, m.Name, m.ProviderName, formatPriceWithCurrency(m.InputPrice, m.Currency), formatPriceWithCurrency(m.OutputPrice, m.Currency), formatContextWindowCompact(m.ContextLength))
}
fmt.Fprintf(f, "\n")
}
if len(r.DomesticTop10) > 0 {
fmt.Fprintf(f, "### 国内模型 TOP 10\n\n")
fmt.Fprintf(f, "| 排名 | 模型 | 厂商 | 输入(CNY) | 输出(CNY) | 上下文 |\n")
fmt.Fprintf(f, "|------|------|------|-----------|-----------|--------|\n")
for i, m := range r.DomesticTop10 {
fmt.Fprintf(f, "| %d | %s | %s | %s | %s | %s |\n",
i+1, m.Name, m.ProviderName, formatDomesticPrice(m.InputPrice, m.Currency), formatDomesticPrice(m.OutputPrice, m.Currency), formatContextWindowCompact(m.ContextLength))
}
fmt.Fprintf(f, "\n")
}
if len(r.FreeTop20) > 0 {
fmt.Fprintf(f, "### 免费模型代表样本\n\n")
fmt.Fprintf(f, "| 模型 | 厂商 | 来源类型 | 上下文 |\n")
fmt.Fprintf(f, "|------|------|----------|--------|\n")
for _, m := range r.FreeTop20 {
fmt.Fprintf(f, "| %s | %s | %s | %s |\n", m.Name, m.ProviderName, classifyFreeSource(m), formatContextWindowCompact(m.ContextLength))
}
fmt.Fprintf(f, "\n")
}
if len(r.TencentSubscriptionPlans) > 0 {
fmt.Fprintf(f, "## 💳 中转平台套餐订阅价\n\n")
fmt.Fprintf(f, "> 以下为云平台 / 中转平台套餐订阅价,包含标准月套餐与首购活动套餐,不参与按模型输入/输出单价排行。\n\n")
fmt.Fprintf(f, "| 平台 | 套餐类型 | 套餐 | 周期 | 价格 | 套餐额度 | 活动说明 | 覆盖模型 |\n")
fmt.Fprintf(f, "|------|----------|------|------|------|----------|----------|----------|\n")
for _, plan := range r.TencentSubscriptionPlans {
fmt.Fprintf(
f,
"| %s | %s | %s | %s | %s | %s | %s | %d 个(%s |\n",
formatPlanOperator(plan),
formatPlanFamily(plan.PlanFamily),
plan.PlanName,
formatBillingCycle(plan.BillingCycle),
formatSubscriptionPrice(plan.ListPrice, plan.Currency, plan.PriceUnit),
formatSubscriptionQuota(plan.QuotaValue, plan.QuotaUnit),
formatPlanNotes(plan.Notes),
plan.ModelCount,
plan.ModelPreview,
)
}
fmt.Fprintf(f, "\n")
}
fmt.Fprintf(f, "### 平台覆盖\n\n")
for _, op := range r.Operators {
fmt.Fprintf(f, "- **%s**: %d 个模型,最低 %s/M\n", op.Name, op.ModelCount, formatPrice(op.MinInputPrice, "USD"))
}
for _, op := range r.Resellers {
fmt.Fprintf(f, "- **%s**: %d 个模型,最低 %s/M\n", op.Name, op.ModelCount, formatPrice(op.MinInputPrice, "USD"))
}
fmt.Fprintf(f, "\n---\n\n📌 **说明**: 本报告由 LLM Intelligence Hub 自动生成。\n")
fmt.Fprintf(f, "- 免费不等于官方永久免费,需结合来源标签判断。\n")
fmt.Fprintf(f, "- 国际模型价格按 1 USD = 7.25 CNY 换算显示。\n")
fmt.Fprintf(f, "\n_生成时间: %s_\n", r.GeneratedAt)
return nil
}
func filterByScene(models []ModelInfo, tag SceneTag) []ModelInfo {
var result []ModelInfo
for _, m := range models {
for _, t := range m.SceneTags {
if t == tag {
result = append(result, m)
break
}
}
}
return result
}
// ============ HTML生成(现代化UI) ============
func generateHTMLV3(r *ReportV3, path string) error {
decorateReportV1(r)
tmpl := `<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LLM Intelligence Hub - {{.Date}}</title>
<style>
:root {
--ink: #15304b;
--ink-soft: #516579;
--fog: #f6f4ef;
--card: rgba(255,255,255,0.94);
--line: rgba(21,48,75,0.10);
--blue: #123c63;
--green: #1f7a4c;
--amber: #ad6b11;
--red: #a53b2a;
--shadow: 0 20px 40px rgba(17, 38, 58, 0.08);
}
* { margin:0; padding:0; box-sizing:border-box; }
body {
font-family: "SF Pro Display", "PingFang SC", "Hiragino Sans GB", "Microsoft YaHei", sans-serif;
background:
radial-gradient(circle at top left, rgba(18,60,99,0.08), transparent 30%),
linear-gradient(180deg, #fbfaf7 0%, #f4f1ea 100%);
color: var(--ink);
line-height: 1.5;
}
.container {
max-width: 1160px;
margin: 0 auto;
padding: 16px;
}
.topbar,
.hero-card,
.section,
.appendix-card,
.metric-card,
.action-card,
.headline-card,
.scene-card,
.free-card {
background: var(--card);
border: 1px solid var(--line);
box-shadow: var(--shadow);
backdrop-filter: blur(12px);
}
.topbar {
border-radius: 24px;
padding: 20px;
margin-bottom: 14px;
}
.topbar-row {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
flex-wrap: wrap;
}
.topbar-title {
font-size: 1rem;
letter-spacing: 0.04em;
text-transform: uppercase;
color: var(--ink-soft);
}
.topbar-meta {
font-size: 0.95rem;
color: var(--ink-soft);
}
.label-row {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 14px;
}
.pill {
display: inline-flex;
align-items: center;
padding: 7px 12px;
border-radius: 999px;
font-size: 0.82rem;
font-weight: 700;
}
.pill.blue { background: rgba(18,60,99,0.10); color: var(--blue); }
.pill.green { background: rgba(31,122,76,0.12); color: var(--green); }
.pill.amber { background: rgba(173,107,17,0.12); color: var(--amber); }
.pill.red { background: rgba(165,59,42,0.12); color: var(--red); }
.hero-card {
border-radius: 28px;
padding: 24px 20px;
margin-bottom: 16px;
background:
linear-gradient(135deg, rgba(18,60,99,0.96), rgba(34,80,122,0.90)),
#123c63;
color: #f9fafb;
}
.hero-kicker {
font-size: 0.9rem;
letter-spacing: 0.06em;
text-transform: uppercase;
opacity: 0.82;
margin-bottom: 10px;
}
.hero-title {
font-size: clamp(1.45rem, 4.8vw, 2.05rem);
line-height: 1.28;
font-weight: 800;
margin-bottom: 14px;
}
.hero-evidence {
font-size: 1rem;
opacity: 0.92;
}
.metrics-grid,
.actions-grid,
.headline-grid,
.free-grid,
.scene-grid,
.appendix-grid {
display: grid;
gap: 12px;
margin-bottom: 16px;
}
.metrics-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.metric-card {
border-radius: 20px;
padding: 16px;
}
.metric-label {
font-size: 0.92rem;
color: var(--ink-soft);
margin-bottom: 6px;
}
.metric-value {
font-size: 1.8rem;
font-weight: 800;
}
.section {
border-radius: 24px;
padding: 20px;
margin-bottom: 16px;
}
.section h2 {
font-size: 1.3rem;
margin-bottom: 14px;
}
.section-intro {
color: var(--ink-soft);
font-size: 0.98rem;
margin-bottom: 14px;
}
.actions-grid,
.headline-grid,
.scene-grid,
.appendix-grid {
grid-template-columns: 1fr;
}
.action-card,
.headline-card,
.scene-card,
.free-card,
.appendix-card {
border-radius: 22px;
padding: 18px;
}
.action-card.primary {
border: 2px solid rgba(18,60,99,0.16);
}
.card-kicker {
font-size: 0.85rem;
color: var(--ink-soft);
margin-bottom: 8px;
}
.headline-badge {
display: inline-flex;
align-items: center;
padding: 6px 10px;
border-radius: 999px;
font-weight: 800;
letter-spacing: 0.01em;
}
.badge-official-primary {
background: rgba(18,60,99,0.12);
color: var(--blue);
}
.badge-secondary-evidence {
background: rgba(173,107,17,0.14);
color: var(--amber);
}
.badge-info {
background: rgba(18,60,99,0.10);
color: var(--blue);
}
.badge-success {
background: rgba(31,122,76,0.12);
color: var(--green);
}
.badge-warning {
background: rgba(173,107,17,0.12);
color: var(--amber);
}
.badge-caution {
background: rgba(165,59,42,0.12);
color: var(--red);
}
.badge-neutral {
background: rgba(81,101,121,0.12);
color: var(--ink-soft);
}
.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,
.source-line {
margin-top: 8px;
font-size: 0.9rem;
color: var(--ink-soft);
}
.evidence-block {
margin-top: 10px;
padding-top: 10px;
border-top: 1px dashed var(--line);
display: grid;
gap: 6px;
}
.evidence-item {
font-size: 0.92rem;
color: var(--ink-soft);
}
.evidence-item strong {
color: var(--ink);
}
.scene-header {
display: flex;
justify-content: space-between;
gap: 12px;
align-items: flex-start;
margin-bottom: 10px;
}
.scene-lead {
border-top: 1px solid var(--line);
padding-top: 12px;
margin-top: 12px;
}
.scene-lead-name {
font-size: 1.12rem;
font-weight: 800;
margin-bottom: 6px;
}
.scene-others {
margin-top: 12px;
display: grid;
gap: 8px;
}
.scene-other {
padding-top: 10px;
border-top: 1px dashed var(--line);
}
.free-grid {
grid-template-columns: 1fr;
}
.free-count {
font-size: 1.9rem;
font-weight: 800;
margin-bottom: 6px;
}
.appendix-links {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 10px;
}
.appendix-link {
display: inline-flex;
align-items: center;
justify-content: center;
text-decoration: none;
color: var(--blue);
background: rgba(18,60,99,0.08);
border-radius: 999px;
padding: 10px 14px;
font-weight: 700;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 12px;
font-size: 0.95rem;
}
th, td {
padding: 12px 10px;
text-align: left;
border-bottom: 1px solid var(--line);
vertical-align: top;
}
th {
color: var(--ink-soft);
font-size: 0.82rem;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.footer {
padding: 24px 8px 12px;
text-align: center;
color: var(--ink-soft);
font-size: 0.92rem;
}
@media (min-width: 900px) {
.container {
padding: 24px;
}
.metrics-grid {
grid-template-columns: repeat(4, minmax(0, 1fr));
}
.actions-grid,
.headline-grid,
.free-grid,
.appendix-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.scene-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (prefers-reduced-motion: no-preference) {
.hero-card,
.section,
.metric-card {
animation: fadeUp 240ms ease-out both;
}
@keyframes fadeUp {
from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0); }
}
}
.tone-success { border-color: rgba(31,122,76,0.18); }
.tone-warning { border-color: rgba(173,107,17,0.18); }
.tone-info { border-color: rgba(18,60,99,0.18); }
.tone-caution { border-color: rgba(165,59,42,0.18); }
.tone-neutral { border-color: rgba(81,101,121,0.16); }
.tone-official-primary {
border-color: rgba(18,60,99,0.26);
background: linear-gradient(180deg, rgba(18,60,99,0.05), rgba(255,255,255,0.94));
}
.tone-secondary-evidence {
border-color: rgba(173,107,17,0.24);
background: linear-gradient(180deg, rgba(173,107,17,0.06), rgba(255,255,255,0.94));
}
.footer {
text-align: center;
}
</style>
</head>
<body>
<div class="container">
<div class="topbar">
<div class="topbar-row">
<div class="topbar-title">AI 模型与价格情报晨报</div>
<div class="topbar-meta">{{.Date}} · {{.GeneratedAt}}</div>
</div>
<div class="label-row">
{{range .MarketLabels}}<span class="pill blue">{{.}}</span>{{end}}
</div>
</div>
<section class="hero-card">
<div class="hero-kicker">今日一句话结论</div>
<div class="hero-title">{{.HeroSummary}}</div>
{{if .HeroEvidence}}<div class="hero-evidence">{{.HeroEvidence}}</div>{{end}}
</section>
<div class="metrics-grid">
<div class="metric-card">
<div class="metric-label">模型总数</div>
<div class="metric-value">{{.TotalModels}}</div>
</div>
<div class="metric-card">
<div class="metric-label">今日新增模型</div>
<div class="metric-value">{{.DailySignals.NewModels}}</div>
</div>
<div class="metric-card">
<div class="metric-label">今日价格变化</div>
<div class="metric-value">{{.DailySignals.PriceChanges}}</div>
</div>
<div class="metric-card">
<div class="metric-label">免费样本</div>
<div class="metric-value">{{len .FreeModels}}</div>
</div>
</div>
<section class="section">
<h2>三条行动建议</h2>
<p class="section-intro">先给行动再给证据每张卡只回答今天该先看什么</p>
<div class="actions-grid">
{{range $i, $item := .ActionItems}}
<article class="action-card {{if eq $i 0}}primary{{end}}">
<div class="card-kicker">行动建议</div>
<div class="card-title">{{$item.Title}}</div>
<div class="card-summary">{{$item.Audience}}</div>
<div class="label-row">
{{range $item.Tags}}<span class="pill amber">{{.}}</span>{{end}}
</div>
<div class="card-evidence">{{$item.Evidence}}</div>
</article>
{{end}}
</div>
</section>
<section class="section">
<h2>今日头条</h2>
<p class="section-intro">只保留真正影响当天判断的变化事件</p>
<div class="headline-grid">
{{range .HeadlineItems}}
<article class="headline-card tone-{{.Tone}}">
<div class="card-kicker headline-badge badge-{{.Tone}}">{{.Label}}</div>
<div class="card-title">{{.Title}}</div>
<div class="card-summary">{{.Summary}}</div>
{{if .Audience}}<div class="source-line">影响对象{{.Audience}}</div>{{end}}
{{if .SourceKindLabel}}<div class="source-line">事件来源{{.SourceKindLabel}}</div>{{end}}
<div class="baseline-line">基线{{.Baseline}}</div>
<div class="trust-line">可信度{{.TrustLabel}}</div>
<div class="evidence-block">
{{if .PrimarySource}}<div class="evidence-item"><strong>主来源</strong>{{.PrimarySource}}</div>{{end}}
{{if .UpdatedAt}}<div class="evidence-item"><strong>更新时间</strong>{{.UpdatedAt}}</div>{{end}}
{{if .EvidenceDetail}}<div class="evidence-item"><strong>判定依据</strong>{{.EvidenceDetail}}</div>{{end}}
</div>
</article>
{{end}}
</div>
</section>
{{if .SignatureAuditSummaries}}
<section class="section">
<h2>结构稳定性</h2>
<p class="section-intro">{{signatureAuditSectionLead .}}</p>
<div class="headline-grid">
{{range .SignatureAuditSummaries}}
<article class="headline-card tone-{{signatureAuditSummaryTone $ .}}">
<div class="card-kicker headline-badge badge-{{signatureAuditSummaryTone $ .}}">{{.SourceLabel}}</div>
<div class="card-title">最近 {{.RunsInWindow}} 次中出现 {{.ChangedRuns}} 次结构变化</div>
<div class="card-summary">最新状态{{.LatestStatus}} · 最新结构状态{{.LatestStructureState}}</div>
<div class="source-line">最近检查{{.LatestCheckedAt}}</div>
</article>
{{end}}
</div>
{{if .SignatureAuditRows}}
<div style="margin-top:18px;">
<table>
<tr><th>平台</th><th>recent_rank</th><th>检查时间</th><th>结构状态</th><th>状态</th><th>结构签名</th></tr>
{{range .SignatureAuditRows}}
<tr>
<td>{{.SourceLabel}}</td>
<td>{{.RecentRank}}</td>
<td>{{.CheckedAt}}</td>
<td>{{.StructureState}}</td>
<td>{{.Status}}</td>
<td>{{.StructureSHA256}}</td>
</tr>
{{end}}
</table>
</div>
{{end}}
</section>
{{end}}
<section class="section">
<h2>免费来源分层</h2>
<p class="section-intro">免费可用不等于官方长期免费必须先区分来源</p>
<div class="free-grid">
{{range .FreeBreakdown}}
<article class="free-card tone-{{.Tone}}">
<div class="card-title">{{.Label}}</div>
<div class="free-count">{{.Count}}</div>
<div class="card-summary">{{.Description}}</div>
</article>
{{end}}
</div>
</section>
<section class="section">
<h2>场景推荐</h2>
<p class="section-intro">按场景给出有限候选优先帮助读者当天做出选择</p>
<div class="scene-grid">
{{range .SceneSections}}
<article class="scene-card">
<div class="scene-header">
<div>
<div class="card-title">{{.Title}}</div>
<div class="scene-desc">{{.Description}}</div>
</div>
</div>
<div class="scene-lead">
<div class="scene-lead-name">{{.Lead.Name}}</div>
<div class="meta-line">{{.Lead.Provider}} · {{.Lead.Operator}} · {{.Lead.Usage}}</div>
<div class="label-row">
{{range .Lead.Tags}}<span class="pill green">{{.}}</span>{{end}}
</div>
<div class="card-evidence">{{.Lead.Evidence}}</div>
<div class="trust-line">{{.Lead.PriceSummary}} · {{.Lead.TrustLabel}}</div>
</div>
{{if .Others}}
<div class="scene-others">
{{range .Others}}
<div class="scene-other">
<div><strong>{{.Name}}</strong> · {{.Provider}}</div>
<div class="meta-line">{{.PriceSummary}} · {{.Evidence}}</div>
</div>
{{end}}
</div>
{{end}}
</article>
{{end}}
</div>
</section>
<section class="section">
<h2>完整数据附录</h2>
<p class="section-intro">长表格后置适合深度比价时再展开</p>
<div class="appendix-grid">
{{range .AppendixLinks}}
<article class="appendix-card">
<div class="card-title">{{.Title}}</div>
<div class="appendix-desc">{{.Description}}</div>
<div class="appendix-links">
<a class="appendix-link" href="{{.Anchor}}">跳转查看</a>
</div>
</article>
{{end}}
</div>
</section>
<section class="section" id="appendix-pricing">
<h2>完整价格附录</h2>
{{if .IntlTop5}}
<table>
<tr><th>国际候选</th><th>厂商</th><th>输入</th><th>输出</th><th>上下文</th></tr>
{{range .IntlTop5}}
<tr>
<td><strong>{{.Name}}</strong></td>
<td>{{.ProviderName}}</td>
<td>{{formatPriceWithCurrency .InputPrice .Currency}}</td>
<td>{{formatPriceWithCurrency .OutputPrice .Currency}}</td>
<td>{{formatContextWindowCompact .ContextLength}}</td>
</tr>
{{end}}
</table>
{{end}}
{{if .DomesticTop10}}
<table>
<tr><th>国内候选</th><th>厂商</th><th>输入(CNY)</th><th>输出(CNY)</th><th>上下文</th></tr>
{{range .DomesticTop10}}
<tr>
<td><strong>{{.Name}}</strong></td>
<td>{{.ProviderName}}</td>
<td>{{formatDomesticPrice .InputPrice .Currency}}</td>
<td>{{formatDomesticPrice .OutputPrice .Currency}}</td>
<td>{{formatContextWindowCompact .ContextLength}}</td>
</tr>
{{end}}
</table>
{{end}}
</section>
<section class="section" id="appendix-free">
<h2>完整免费附录</h2>
<table>
<tr><th>模型</th><th>厂商</th><th>来源类型</th><th>上下文</th></tr>
{{range .FreeTop20}}
<tr>
<td><strong>{{.Name}}</strong></td>
<td>{{.ProviderName}}</td>
<td>{{classifyFreeSource .}}</td>
<td>{{formatContextWindowCompact .ContextLength}}</td>
</tr>
{{end}}
</table>
</section>
<section class="section" id="appendix-platforms">
<h2>平台覆盖附录</h2>
{{if .Operators}}
<table>
<tr><th>官方/云平台</th><th>模型数</th><th>最低输入价</th><th>平均输入价</th></tr>
{{range .Operators}}
<tr><td><strong>{{.Name}}</strong></td><td>{{.ModelCount}}</td><td>{{formatPrice .MinInputPrice "USD"}}</td><td>{{formatPrice .AvgInputPrice "USD"}}</td></tr>
{{end}}
</table>
{{end}}
{{if .Resellers}}
<table>
<tr><th>聚合平台</th><th>模型数</th><th>最低输入价</th><th>平均输入价</th></tr>
{{range .Resellers}}
<tr><td><strong>{{.Name}}</strong></td><td>{{.ModelCount}}</td><td>{{formatPrice .MinInputPrice "USD"}}</td><td>{{formatPrice .AvgInputPrice "USD"}}</td></tr>
{{end}}
</table>
{{end}}
</section>
{{if .TencentSubscriptionPlans}}
<section class="section">
<h2>💳 中转平台套餐订阅价</h2>
<p class="section-intro">以下为云平台 / 中转平台套餐订阅价包含标准月套餐与首购活动套餐不参与按模型输入/输出单价排行</p>
<table>
<tr><th>平台</th><th>套餐类型</th><th>套餐</th><th>周期</th><th>价格</th><th>套餐额度</th><th>活动说明</th><th>覆盖模型</th></tr>
{{range .TencentSubscriptionPlans}}
<tr>
<td><strong>{{formatPlanOperator .}}</strong></td>
<td>{{formatPlanFamily .PlanFamily}}</td>
<td><strong>{{.PlanName}}</strong></td>
<td>{{formatBillingCycle .BillingCycle}}</td>
<td>{{formatSubscriptionPrice .ListPrice .Currency .PriceUnit}}</td>
<td>{{formatSubscriptionQuota .QuotaValue .QuotaUnit}}</td>
<td>{{formatPlanNotes .Notes}}</td>
<td>{{.ModelCount}} {{if .ModelPreview}}{{.ModelPreview}}{{end}}{{if gt .ContextWindow 0}} · {{formatContextWindowCompact .ContextWindow}}{{end}}</td>
</tr>
{{end}}
</table>
</section>
{{end}}
<div class="footer">
<p>📌 本报告由 LLM Intelligence Hub 自动生成 · {{.Date}}</p>
<p style="margin-top:8px;">免费不等于官方永久免费需结合来源标签判断</p>
</div>
</div>
</body>
</html>`
funcMap := template.FuncMap{
"add": func(a, b int) int { return a + b },
"classifyFreeSource": classifyFreeSource,
"formatPrice": formatPrice,
"formatPriceWithCurrency": formatPriceWithCurrency,
"formatDomesticPrice": formatDomesticPrice,
"formatSubscriptionPrice": formatSubscriptionPrice,
"formatSubscriptionQuota": formatSubscriptionQuota,
"formatContextWindowCompact": formatContextWindowCompact,
"formatPlanFamily": formatPlanFamily,
"formatBillingCycle": formatBillingCycle,
"formatPlanOperator": formatPlanOperator,
"formatPlanNotes": formatPlanNotes,
"signatureAuditSectionLead": buildSignatureAuditSectionLead,
"signatureAuditSummaryTone": signatureAuditSummaryTone,
}
t := template.Must(template.New("report").Funcs(funcMap).Parse(tmpl))
f, err := os.Create(path)
if err != nil {
return err
}
defer f.Close()
return t.Execute(f, r)
}
func saveReportTrackingV3(db *sql.DB, r *ReportV3, mdPath string, runContext ReportRunContext) 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))
}
summary = composeTrackedSummary(summary, runContext)
tx, err := db.Begin()
if err != nil {
return err
}
defer tx.Rollback()
if runContext.IsOfficialDaily {
if _, err := tx.Exec(`
INSERT INTO daily_report (report_date, status, model_count, new_models, free_models, summary_md, output_path, run_kind, trigger_source, is_official_daily, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, 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,
run_kind = EXCLUDED.run_kind,
trigger_source = EXCLUDED.trigger_source,
is_official_daily = TRUE,
error_message = NULL,
updated_at = NOW()
`, r.Date, "generated", r.TotalModels, 0, len(r.FreeModels), summary, mdPath, runContext.RunKind, runContext.TriggerSource, runContext.IsOfficialDaily); err != nil {
return err
}
}
if _, err := tx.Exec(`
INSERT INTO report_runs (source, report_date, status, summary_md, output_path, error_message, run_kind, trigger_source, is_official_daily)
VALUES ($1, $2, $3, $4, $5, NULL, $6, $7, $8)
`, "generate_daily_report", r.Date, "generated", summary, mdPath, runContext.RunKind, runContext.TriggerSource, runContext.IsOfficialDaily); err != nil {
return err
}
return tx.Commit()
}
func archiveReportArtifacts(date, mdPath, htmlPath string) error {
reportDir := filepath.Dir(mdPath)
archiveDir := filepath.Join(reportDir, date[:4], date[5:7])
archiveMDPath := filepath.Join(archiveDir, filepath.Base(mdPath))
archiveHTMLPath := filepath.Join(archiveDir, filepath.Base(htmlPath))
if err := os.MkdirAll(archiveDir, 0755); err != nil {
return err
}
if err := copyFile(mdPath, archiveMDPath); err != nil {
return err
}
if err := copyFile(htmlPath, archiveHTMLPath); err != nil {
return err
}
return nil
}
func copyFile(src, dst string) error {
sourceFile, err := os.Open(src)
if err != nil {
return err
}
defer sourceFile.Close()
targetFile, err := os.Create(dst)
if err != nil {
return err
}
defer targetFile.Close()
if _, err := io.Copy(targetFile, sourceFile); err != nil {
return err
}
return nil
}
// deriveProviderName 从 modelID 中提取厂商名
func deriveProviderName(modelID string) string {
parts := strings.SplitN(modelID, "/", 2)
if len(parts) == 0 || parts[0] == "" {
return "Unknown"
}
raw := parts[0]
raw = strings.ReplaceAll(raw, "-", " ")
raw = strings.ReplaceAll(raw, "_", " ")
words := strings.Fields(raw)
for i, word := range words {
if len(word) == 0 {
continue
}
words[i] = strings.ToUpper(word[:1]) + strings.ToLower(word[1:])
}
if len(words) == 0 {
return "Unknown"
}
return strings.Join(words, " ")
}