4210 lines
126 KiB
Go
4210 lines
126 KiB
Go
//go:build llm_script && !scripts_pkg
|
||
|
||
// 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)
|
||
}
|
||
}
|
||
type AppendixExport struct {
|
||
Date string `json:"date"`
|
||
GeneratedAt string `json:"generatedAt"`
|
||
IntlAppendixList []ModelInfo `json:"intlAppendixList"`
|
||
DomesticAppendixList []ModelInfo `json:"domesticAppendixList"`
|
||
FreeTop20 []ModelInfo `json:"freeTop20"`
|
||
Operators []OperatorInfo `json:"operators"`
|
||
Resellers []OperatorInfo `json:"resellers"`
|
||
}
|
||
|
||
|
||
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. 创建目录与输出路径
|
||
outputPaths, err := resolveReportOutputPaths(date, runContext)
|
||
if err != nil {
|
||
return err
|
||
}
|
||
if err := os.MkdirAll(outputPaths.OutputDir, 0755); err != nil {
|
||
return err
|
||
}
|
||
if err := os.MkdirAll(outputPaths.HTMLDir, 0755); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 3. 生成 Markdown
|
||
mdPath := outputPaths.MarkdownPath
|
||
if err := generateMarkdownV3(report, mdPath); err != nil {
|
||
return err
|
||
}
|
||
|
||
// 4. 生成 HTML(现代化UI)
|
||
htmlPath := outputPaths.HTMLPath
|
||
if err := generateHTMLV3(report, htmlPath); err != nil {
|
||
return err
|
||
}
|
||
appendixExportPath, err := writeAppendixExport(report, outputPaths.OutputDir)
|
||
if err != nil {
|
||
return fmt.Errorf("导出附录数据失败: %w", err)
|
||
}
|
||
|
||
// 5. 仅正式日报归档到统一产物路径
|
||
if runContext.IsOfficialDaily {
|
||
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_top5", len(report.IntlTop5),
|
||
"domestic_top10", len(report.DomesticTop10),
|
||
"intl_appendix", len(report.IntlAppendixList),
|
||
"domestic_appendix", len(report.DomesticAppendixList),
|
||
"appendix_export", appendixExportPath,
|
||
"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),
|
||
}
|
||
}
|
||
|
||
type ReportOutputPaths struct {
|
||
OutputDir string
|
||
HTMLDir string
|
||
MarkdownPath string
|
||
HTMLPath string
|
||
}
|
||
|
||
func resolveReportOutputPaths(reportDate string, runContext ReportRunContext) (ReportOutputPaths, error) {
|
||
baseDir := strings.TrimSpace(os.Getenv("REPORT_OUTPUT_DIR"))
|
||
if baseDir == "" {
|
||
baseDir = "reports/daily"
|
||
}
|
||
baseDir = filepath.Clean(baseDir)
|
||
|
||
outputDir := baseDir
|
||
if !runContext.IsOfficialDaily {
|
||
outputDir = filepath.Join("reports", "ad_hoc", reportDate, sanitizeReportPathSegment(runContext.RunKind), sanitizeReportPathSegment(runContext.TriggerSource))
|
||
}
|
||
htmlDir := filepath.Join(outputDir, "html")
|
||
|
||
return ReportOutputPaths{
|
||
OutputDir: outputDir,
|
||
HTMLDir: htmlDir,
|
||
MarkdownPath: filepath.Join(outputDir, fmt.Sprintf("daily_report_%s.md", reportDate)),
|
||
HTMLPath: filepath.Join(htmlDir, fmt.Sprintf("daily_report_%s.html", reportDate)),
|
||
}, nil
|
||
}
|
||
|
||
func sanitizeReportPathSegment(value string) string {
|
||
value = strings.TrimSpace(strings.ToLower(value))
|
||
if value == "" {
|
||
return "unknown"
|
||
}
|
||
var b strings.Builder
|
||
for _, r := range value {
|
||
switch {
|
||
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
|
||
b.WriteRune(r)
|
||
case r == '-' || r == '_':
|
||
b.WriteRune(r)
|
||
default:
|
||
b.WriteRune('-')
|
||
}
|
||
}
|
||
result := strings.Trim(b.String(), "-")
|
||
if result == "" {
|
||
return "unknown"
|
||
}
|
||
return result
|
||
}
|
||
|
||
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(推荐列表)
|
||
IntlAppendixList []ModelInfo // 国际价格附录全量列表
|
||
DomesticAppendixList []ModelInfo // 国内价格附录全量列表
|
||
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
|
||
PriceNewsSections []PriceNewsSection
|
||
|
||
AppendixPagination AppendixPagination
|
||
|
||
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
|
||
ModelName string
|
||
ProviderName string
|
||
ProviderCountry string
|
||
OperatorName string
|
||
SourceURL 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
|
||
ModelName string
|
||
ProviderName string
|
||
ProviderCountry string
|
||
OperatorName string
|
||
SourceURL string
|
||
}
|
||
|
||
type ModelEvent struct {
|
||
EventType string
|
||
ModelName string
|
||
ProviderName string
|
||
ProviderCountry string
|
||
OperatorName string
|
||
Audience string
|
||
TrustLabel string
|
||
SourceKindLabel string
|
||
PrimarySource string
|
||
SourceURL 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 PriceNewsSection struct {
|
||
Title string
|
||
Items []HeadlineItem
|
||
}
|
||
|
||
type AppendixPagination struct {
|
||
Pages int
|
||
PageSize int
|
||
TotalItems int
|
||
}
|
||
|
||
type ModelSelections struct {
|
||
IntlTop5 []ModelInfo
|
||
DomesticTop10 []ModelInfo
|
||
IntlAppendixList []ModelInfo
|
||
DomesticAppendixList []ModelInfo
|
||
}
|
||
|
||
|
||
|
||
|
||
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
|
||
}
|
||
|
||
type PlanDisplayInfo struct {
|
||
Index int
|
||
IsLowest bool
|
||
}
|
||
|
||
// ============ 数据查询(新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
|
||
})
|
||
|
||
selections := buildModelSelections(intlModels, domesticModels, freeModels)
|
||
intlTop5 := selections.IntlTop5
|
||
domesticTop10 := selections.DomesticTop10
|
||
intlAppendixList := selections.IntlAppendixList
|
||
domesticAppendixList := selections.DomesticAppendixList
|
||
|
||
// 免费模型只展示前20个 + 分类统计
|
||
var freeTop20 []ModelInfo
|
||
if len(freeModels) > 20 {
|
||
freeTop20 = freeModels[:20]
|
||
} else {
|
||
freeTop20 = freeModels
|
||
}
|
||
|
||
// 运营商分类
|
||
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,
|
||
IntlAppendixList: intlAppendixList,
|
||
DomesticAppendixList: domesticAppendixList,
|
||
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 planDisplayKey(plan SubscriptionPlanInfo) string {
|
||
operator := formatPlanOperator(plan)
|
||
return operator + "|" + plan.PlanName + "|" + plan.PriceUnit + "|" + plan.BillingCycle
|
||
}
|
||
|
||
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,
|
||
ProviderCountry: "US",
|
||
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,
|
||
SourceURL: definition.PrimarySource,
|
||
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,
|
||
¤cy,
|
||
); 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,
|
||
ProviderCountry: providerCountry,
|
||
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,
|
||
SourceURL: sourceURL,
|
||
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,
|
||
¤cy,
|
||
&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,
|
||
ProviderCountry: providerCountry,
|
||
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,
|
||
SourceURL: firstNonEmpty(sourceURLByModelName(db, modelName), buildPrimarySource("region_pricing", operatorName)),
|
||
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,
|
||
¤cy,
|
||
&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,
|
||
ProviderCountry: providerCountry,
|
||
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,
|
||
SourceURL: firstNonEmpty(sourceURLByModelName(db, modelName), buildPrimarySource("region_pricing", operatorName)),
|
||
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 modelCountryByName(models []ModelInfo, modelName string) string {
|
||
for _, model := range models {
|
||
if model.Name == modelName {
|
||
return strings.ToUpper(strings.TrimSpace(model.ProviderCountry))
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func modelSourceURLByName(events []ModelEvent, modelName string) string {
|
||
for _, event := range events {
|
||
if event.ModelName == modelName && strings.TrimSpace(event.SourceURL) != "" {
|
||
return event.SourceURL
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
func sourceURLByModelName(db *sql.DB, modelName string) string {
|
||
if db == nil || strings.TrimSpace(modelName) == "" {
|
||
return ""
|
||
}
|
||
var sourceURL string
|
||
err := db.QueryRow(`
|
||
SELECT COALESCE(source_url, '')
|
||
FROM models
|
||
WHERE deleted_at IS NULL
|
||
AND COALESCE(NULLIF(name, ''), external_id) = $1
|
||
ORDER BY id DESC
|
||
LIMIT 1
|
||
`, modelName).Scan(&sourceURL)
|
||
if err != nil {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(sourceURL)
|
||
}
|
||
|
||
func formatModelOrganization(providerName, operatorName string) string {
|
||
providerName = strings.TrimSpace(providerName)
|
||
operatorName = strings.TrimSpace(operatorName)
|
||
if providerName == "" && operatorName == "" {
|
||
return ""
|
||
}
|
||
if providerName == operatorName || operatorName == "" {
|
||
return providerName
|
||
}
|
||
if providerName == "" {
|
||
return operatorName
|
||
}
|
||
return providerName + " / " + operatorName
|
||
}
|
||
|
||
func subscriptionPlanDisplayInfo(plans []SubscriptionPlanInfo) map[string]PlanDisplayInfo {
|
||
lowestByOperator := make(map[string]float64)
|
||
for _, plan := range plans {
|
||
operator := formatPlanOperator(plan)
|
||
if current, ok := lowestByOperator[operator]; !ok || plan.ListPrice < current {
|
||
lowestByOperator[operator] = plan.ListPrice
|
||
}
|
||
}
|
||
result := make(map[string]PlanDisplayInfo, len(plans))
|
||
for idx, plan := range plans {
|
||
operator := formatPlanOperator(plan)
|
||
key := operator + "|" + plan.PlanName + "|" + plan.PriceUnit + "|" + plan.BillingCycle
|
||
result[key] = PlanDisplayInfo{Index: idx + 1, IsLowest: plan.ListPrice == lowestByOperator[operator]}
|
||
}
|
||
return result
|
||
}
|
||
|
||
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.AppendixLinks = []AppendixLink{
|
||
{Title: "国际低价", Description: "查看国际低价模型附录(日报仅保留前几页)", Anchor: "#appendix-pricing-intl"},
|
||
{Title: "国内低价", Description: "查看国内低价模型附录(日报仅保留前几页)", Anchor: "#appendix-pricing-domestic"},
|
||
{Title: "免费样本", Description: "查看免费模型代表样本附录", Anchor: "#appendix-free"},
|
||
{Title: "平台覆盖", Description: "查看官方平台与聚合平台覆盖", Anchor: "#appendix-platforms"},
|
||
{Title: "全量导出 JSON", Description: "其余完整数据请下载独立导出文件或转到查询页查看", Anchor: "/reports/daily/appendix/" + r.Date + "/full_appendix.json"},
|
||
}
|
||
|
||
r.ActionItems = buildActionItems(r)
|
||
r.HeadlineItems = buildHeadlineItems(r)
|
||
r.PriceNewsSections = buildPriceNewsSections(r.ModelEvents)
|
||
r.AppendixPagination = buildAppendixPagination(r)
|
||
|
||
|
||
}
|
||
|
||
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 priceEvent := firstPriceEvent(r.ModelEvents); priceEvent != nil {
|
||
direction := "上涨"
|
||
if priceEvent.EventType == "price_cut" {
|
||
direction = "下降"
|
||
}
|
||
org := formatModelOrganization(priceEvent.ProviderName, priceEvent.OperatorName)
|
||
country := firstNonEmpty(priceEvent.ProviderCountry, modelCountryByName(r.AllModels, priceEvent.ModelName), "unknown")
|
||
return fmt.Sprintf("今天最值得关注的是 %s(%s / %s)价格%s %.0f%%,优先复查它是否改变默认选型与预算策略。", priceEvent.ModelName, country, org, direction, abs(priceEvent.PriceChangePct)),
|
||
fmt.Sprintf("主来源:%s;%s", priceEvent.PrimarySource, priceEvent.EvidenceDetail)
|
||
}
|
||
if official := firstEventByType(r.ModelEvents, "official_release"); official != nil {
|
||
org := formatModelOrganization(official.ProviderName, official.OperatorName)
|
||
country := firstNonEmpty(official.ProviderCountry, modelCountryByName(r.AllModels, official.ModelName), "unknown")
|
||
return fmt.Sprintf("今天最值得关注的是 %s(%s / %s)已出现官方发布信号,优先复查它对默认选型的影响。", official.ModelName, country, org),
|
||
fmt.Sprintf("主来源:%s", official.PrimarySource)
|
||
}
|
||
if promo := firstEventByType(r.ModelEvents, "promo_campaign"); promo != nil {
|
||
org := formatModelOrganization(promo.ProviderName, promo.OperatorName)
|
||
country := firstNonEmpty(promo.ProviderCountry, modelCountryByName(r.AllModels, promo.ModelName), "unknown")
|
||
return fmt.Sprintf("今天最值得关注的是 %s(%s / %s)已进入活动窗口,优先判断这次活动是否值得改变默认成本策略。", promo.ModelName, country, org),
|
||
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 firstPriceEvent(events []ModelEvent) *ModelEvent {
|
||
var selected *ModelEvent
|
||
for i := range events {
|
||
event := &events[i]
|
||
if event.EventType != "price_cut" && event.EventType != "price_increase" {
|
||
continue
|
||
}
|
||
if selected == nil || event.Priority > selected.Priority {
|
||
selected = event
|
||
}
|
||
}
|
||
return selected
|
||
}
|
||
|
||
|
||
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 priceEvent := firstPriceEvent(r.ModelEvents); priceEvent != nil {
|
||
items = append(items, headlineItemFromModelEvent(*priceEvent))
|
||
}
|
||
if auditItem, ok := buildSignatureAuditHeadlineItem(r.SignatureAuditSummaries, effectiveSignatureAuditReportConfig(r).ChangedRunsThreshold); ok {
|
||
items = append(items, auditItem)
|
||
}
|
||
if eventItems := buildHeadlineItemsFromEvents(r.ModelEvents); len(eventItems) > 0 {
|
||
for _, item := range eventItems {
|
||
if item.Label == "价格下调" || item.Label == "价格上调" {
|
||
continue
|
||
}
|
||
items = append(items, item)
|
||
}
|
||
if len(items) > 4 {
|
||
return items[:4]
|
||
}
|
||
if len(items) > 0 {
|
||
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 && firstPriceEvent(r.ModelEvents) == nil {
|
||
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 buildPriceNewsSections(events []ModelEvent) []PriceNewsSection {
|
||
groups := []struct {
|
||
title string
|
||
filter func(ModelEvent) bool
|
||
}{
|
||
{title: "降价机会", filter: func(event ModelEvent) bool { return event.EventType == "price_cut" }},
|
||
{title: "涨价预警", filter: func(event ModelEvent) bool { return event.EventType == "price_increase" }},
|
||
{title: "平台活动", filter: func(event ModelEvent) bool { return event.EventType == "promo_campaign" }},
|
||
}
|
||
|
||
var sections []PriceNewsSection
|
||
for _, group := range groups {
|
||
var items []HeadlineItem
|
||
for _, event := range dedupeModelEvents(events) {
|
||
if !group.filter(event) {
|
||
continue
|
||
}
|
||
items = append(items, headlineItemFromModelEvent(event))
|
||
}
|
||
if len(items) == 0 {
|
||
continue
|
||
}
|
||
sections = append(sections, PriceNewsSection{Title: group.title, Items: items})
|
||
}
|
||
return sections
|
||
}
|
||
|
||
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",
|
||
ModelName: event.ModelName,
|
||
ProviderName: event.ProviderName,
|
||
ProviderCountry: event.ProviderCountry,
|
||
OperatorName: event.OperatorName,
|
||
SourceURL: event.SourceURL,
|
||
}
|
||
|
||
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},
|
||
ModelName: section.Lead.Name,
|
||
ProviderName: section.Lead.Provider,
|
||
OperatorName: section.Lead.Operator,
|
||
ProviderCountry: modelCountryByName(r.AllModels, section.Lead.Name),
|
||
SourceURL: modelSourceURLByName(r.ModelEvents, section.Lead.Name),
|
||
})
|
||
}
|
||
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},
|
||
ModelName: section.Lead.Name,
|
||
ProviderName: section.Lead.Provider,
|
||
OperatorName: section.Lead.Operator,
|
||
ProviderCountry: modelCountryByName(r.AllModels, section.Lead.Name),
|
||
SourceURL: modelSourceURLByName(r.ModelEvents, section.Lead.Name),
|
||
})
|
||
}
|
||
|
||
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",
|
||
ModelName: summary.SourceLabel,
|
||
ProviderName: summary.SourceLabel,
|
||
OperatorName: summary.SourceLabel,
|
||
}
|
||
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 §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 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
|
||
}
|
||
|
||
func themedNewsBadgeTitle(title string) string {
|
||
switch strings.TrimSpace(title) {
|
||
case "降价机会":
|
||
return "Opportunity"
|
||
case "涨价预警":
|
||
return "Warning"
|
||
case "平台活动":
|
||
return "Campaign"
|
||
default:
|
||
return "Signal"
|
||
}
|
||
}
|
||
|
||
func themedNewsBadgeIcon(title string) string {
|
||
switch strings.TrimSpace(title) {
|
||
case "降价机会":
|
||
return "↓"
|
||
case "涨价预警":
|
||
return "↑"
|
||
case "平台活动":
|
||
return "✦"
|
||
default:
|
||
return "•"
|
||
}
|
||
}
|
||
|
||
func themedNewsMarkdownHeading(title string) string {
|
||
return fmt.Sprintf("%s %s · %s", themedNewsBadgeIcon(title), themedNewsBadgeTitle(title), strings.TrimSpace(title))
|
||
}
|
||
|
||
|
||
func buildAppendixPagination(r *ReportV3) AppendixPagination {
|
||
const pageSize = 20
|
||
total := len(r.IntlTop5) + len(r.DomesticTop10)
|
||
if len(r.FreeTop20) > total {
|
||
total = len(r.FreeTop20)
|
||
}
|
||
platformTotal := len(r.Operators) + len(r.Resellers)
|
||
if platformTotal > total {
|
||
total = platformTotal
|
||
}
|
||
pages := total / pageSize
|
||
if total%pageSize != 0 {
|
||
pages++
|
||
}
|
||
if pages == 0 {
|
||
pages = 1
|
||
}
|
||
return AppendixPagination{Pages: pages, PageSize: pageSize, TotalItems: total}
|
||
}
|
||
|
||
func sliceModelsPage(items []ModelInfo, page, pageSize int) []ModelInfo {
|
||
if len(items) == 0 || page <= 0 || pageSize <= 0 {
|
||
return nil
|
||
}
|
||
start := (page - 1) * pageSize
|
||
if start >= len(items) {
|
||
return nil
|
||
}
|
||
end := start + pageSize
|
||
if end > len(items) {
|
||
end = len(items)
|
||
}
|
||
return items[start:end]
|
||
}
|
||
|
||
func sliceOperatorsPage(items []OperatorInfo, page, pageSize int) []OperatorInfo {
|
||
if len(items) == 0 || page <= 0 || pageSize <= 0 {
|
||
return nil
|
||
}
|
||
start := (page - 1) * pageSize
|
||
if start >= len(items) {
|
||
return nil
|
||
}
|
||
end := start + pageSize
|
||
if end > len(items) {
|
||
end = len(items)
|
||
}
|
||
return items[start:end]
|
||
}
|
||
|
||
func pageCount(totalItems, pageSize int) int {
|
||
if pageSize <= 0 {
|
||
return 1
|
||
}
|
||
pages := totalItems / pageSize
|
||
if totalItems%pageSize != 0 {
|
||
pages++
|
||
}
|
||
if pages == 0 {
|
||
pages = 1
|
||
}
|
||
return pages
|
||
}
|
||
|
||
func buildModelSelections(intlModels, domesticModels, freeModels []ModelInfo) ModelSelections {
|
||
intlAppendixList := append([]ModelInfo(nil), intlModels...)
|
||
domesticAppendixList := append([]ModelInfo(nil), domesticModels...)
|
||
|
||
intlPaid := filterPaid(intlModels)
|
||
intlTop5 := intlPaid
|
||
if len(intlTop5) > 5 {
|
||
intlTop5 = intlTop5[:5]
|
||
}
|
||
if len(intlTop5) == 0 && len(intlModels) > 0 {
|
||
intlTop5 = intlModels
|
||
if len(intlTop5) > 5 {
|
||
intlTop5 = intlTop5[:5]
|
||
}
|
||
}
|
||
|
||
domesticPaid := filterPaid(domesticModels)
|
||
domesticTop10 := domesticPaid
|
||
if len(domesticTop10) > 10 {
|
||
domesticTop10 = domesticTop10[:10]
|
||
}
|
||
|
||
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)
|
||
}
|
||
if len(domesticTop10) == 0 && len(domesticModels) > 0 {
|
||
domesticTop10 = domesticModels
|
||
if len(domesticTop10) > 10 {
|
||
domesticTop10 = domesticTop10[:10]
|
||
}
|
||
}
|
||
_ = freeModels
|
||
return ModelSelections{
|
||
IntlTop5: intlTop5,
|
||
DomesticTop10: domesticTop10,
|
||
IntlAppendixList: intlAppendixList,
|
||
DomesticAppendixList: domesticAppendixList,
|
||
}
|
||
}
|
||
|
||
func markdownLink(label, url string) string {
|
||
label = strings.TrimSpace(label)
|
||
url = strings.TrimSpace(url)
|
||
if label == "" {
|
||
return ""
|
||
}
|
||
if url == "" {
|
||
return label
|
||
}
|
||
return fmt.Sprintf("[%s](%s)", label, url)
|
||
}
|
||
|
||
func heroSourceURL(events []ModelEvent) string {
|
||
if event := firstPriceEvent(events); event != nil && strings.TrimSpace(event.SourceURL) != "" {
|
||
return event.SourceURL
|
||
}
|
||
if event := firstEventByType(events, "official_release"); event != nil && strings.TrimSpace(event.SourceURL) != "" {
|
||
return event.SourceURL
|
||
}
|
||
if event := firstEventByType(events, "promo_campaign"); event != nil && strings.TrimSpace(event.SourceURL) != "" {
|
||
return event.SourceURL
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// ============ 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", markdownLink(r.HeroSummary, heroSourceURL(r.ModelEvents)))
|
||
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 _, section := range r.PriceNewsSections {
|
||
fmt.Fprintf(f, "### %s\n\n", themedNewsMarkdownHeading(section.Title))
|
||
for _, item := range section.Items {
|
||
fmt.Fprintf(f, "#### %s\n\n", markdownLink(item.Title, item.SourceURL))
|
||
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)
|
||
}
|
||
}
|
||
|
||
fmt.Fprintf(f, "## 今日头条\n\n")
|
||
for _, item := range r.HeadlineItems {
|
||
fmt.Fprintf(f, "### %s · %s\n\n", item.Label, markdownLink(item.Title, item.SourceURL))
|
||
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")
|
||
planDisplay := subscriptionPlanDisplayInfo(r.TencentSubscriptionPlans)
|
||
for _, plan := range r.TencentSubscriptionPlans {
|
||
marker := ""
|
||
if info := planDisplay[planDisplayKey(plan)]; info.IsLowest {
|
||
marker = " 🏷 最低价"
|
||
}
|
||
fmt.Fprintf(
|
||
f,
|
||
"| %s | %s | %s%s | %s | %s | %s | %s | %d 个(%s) |\n",
|
||
formatPlanOperator(plan),
|
||
formatPlanFamily(plan.PlanFamily),
|
||
plan.PlanName,
|
||
marker,
|
||
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);
|
||
}
|
||
|
||
.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);
|
||
}
|
||
.theme-news-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 12px;
|
||
}
|
||
.theme-news-badge-icon {
|
||
width: 24px;
|
||
height: 24px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 999px;
|
||
background: rgba(15,23,42,0.08);
|
||
font-weight: 800;
|
||
}
|
||
.theme-news-badge-label {
|
||
font-size: 0.72rem;
|
||
letter-spacing: 0.12em;
|
||
text-transform: uppercase;
|
||
}
|
||
.theme-news-item.tone-success .theme-news-badge-icon {
|
||
background: rgba(31,122,76,0.14);
|
||
color: var(--green);
|
||
}
|
||
.theme-news-item.tone-caution .theme-news-badge-icon {
|
||
background: rgba(165,59,42,0.14);
|
||
color: var(--red);
|
||
}
|
||
.theme-news-item.tone-promo .theme-news-badge-icon {
|
||
background: rgba(173,107,17,0.14);
|
||
color: var(--amber);
|
||
}
|
||
|
||
.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);
|
||
}
|
||
.model-link,
|
||
.source-link {
|
||
color: var(--blue);
|
||
text-decoration: none;
|
||
}
|
||
.model-link:hover,
|
||
.source-link:hover {
|
||
text-decoration: underline;
|
||
}
|
||
|
||
.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);
|
||
}
|
||
.appendix-page[hidden] {
|
||
display: none !important;
|
||
}
|
||
.appendix-page-header {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: 12px;
|
||
margin: 8px 0 12px;
|
||
}
|
||
.appendix-page-nav {
|
||
display: inline-flex;
|
||
gap: 8px;
|
||
}
|
||
.appendix-page-button {
|
||
border: 1px solid var(--line);
|
||
background: rgba(18,60,99,0.06);
|
||
color: var(--ink);
|
||
border-radius: 999px;
|
||
padding: 8px 12px;
|
||
font-weight: 700;
|
||
cursor: pointer;
|
||
}
|
||
.appendix-page-button[disabled] {
|
||
opacity: 0.45;
|
||
cursor: not-allowed;
|
||
}
|
||
|
||
.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">{{if $item.SourceURL}}<a class="model-link" href="{{$item.SourceURL}}" target="_blank" rel="noreferrer">{{$item.Title}}</a>{{else}}{{$item.Title}}{{end}}</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>
|
||
{{if $item.ProviderName}}<div class="source-line">模型信息:{{$item.ModelName}} · {{$item.ProviderCountry}} · {{formatModelOrganization $item.ProviderName $item.OperatorName}}</div>{{end}}
|
||
</article>
|
||
{{end}}
|
||
</div>
|
||
</section>
|
||
|
||
<section class="section">
|
||
<h2>今日价格新闻</h2>
|
||
<p class="section-intro">先按价格信号主题分组,再看单条事件证据,减少当天决策噪音。</p>
|
||
<div class="headline-grid">
|
||
{{range .PriceNewsSections}}
|
||
{{$section := .}}
|
||
<article class="headline-card tone-neutral">
|
||
<div class="card-kicker headline-badge badge-info">{{$section.Title}}</div>
|
||
<div class="theme-news-list">
|
||
{{range $section.Items}}
|
||
<div class="theme-news-item tone-{{.Tone}}">
|
||
<div class="theme-news-badge">
|
||
<span class="theme-news-badge-icon">{{themedNewsBadgeIcon $section.Title}}</span>
|
||
<span class="theme-news-badge-label">{{themedNewsBadgeTitle $section.Title}}</span>
|
||
</div>
|
||
<div class="card-title">{{if .SourceURL}}<a class="model-link" href="{{.SourceURL}}" target="_blank" rel="noreferrer">{{.Title}}</a>{{else}}{{.Title}}{{end}}</div>
|
||
<div class="card-summary">{{.Summary}}</div>
|
||
{{if .Audience}}<div class="source-line">影响对象:{{.Audience}}</div>{{end}}
|
||
{{if .ProviderName}}<div class="source-line">模型信息:{{.ModelName}} · {{.ProviderCountry}} · {{formatModelOrganization .ProviderName .OperatorName}}</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>:{{if .SourceURL}}<a class="source-link" href="{{.SourceURL}}" target="_blank" rel="noreferrer">{{.PrimarySource}}</a>{{else}}{{.PrimarySource}}{{end}}</div>{{end}}
|
||
</div>
|
||
</div>
|
||
{{end}}
|
||
</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">{{if .SourceURL}}<a class="model-link" href="{{.SourceURL}}" target="_blank" rel="noreferrer">{{.Title}}</a>{{else}}{{.Title}}{{end}}</div>
|
||
<div class="card-summary">{{.Summary}}</div>
|
||
{{if .Audience}}<div class="source-line">影响对象:{{.Audience}}</div>{{end}}
|
||
{{if .ProviderName}}<div class="source-line">模型信息:{{.ModelName}} · {{.ProviderCountry}} · {{formatModelOrganization .ProviderName .OperatorName}}</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>:{{if .SourceURL}}<a class="source-link" href="{{.SourceURL}}" target="_blank" rel="noreferrer">{{.PrimarySource}}</a>{{else}}{{.PrimarySource}}{{end}}</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-intl">
|
||
<h2>完整价格附录(国际低价)</h2>
|
||
{{range $page := seqPages (pageCount (len .IntlAppendixList) .AppendixPagination.PageSize)}}{{if le $page 3}}
|
||
<div class="appendix-page" data-appendix-page="{{$page}}" data-appendix-total-pages="{{pageCount (len $.IntlAppendixList) $.AppendixPagination.PageSize}}" {{if ne $page 1}}hidden{{end}}>
|
||
<div class="appendix-page-header">
|
||
<strong>附录第 {{$page}} / {{pageCount (len $.IntlAppendixList) $.AppendixPagination.PageSize}} 页</strong>
|
||
<div class="appendix-page-nav">
|
||
<button class="appendix-page-button" data-appendix-prev {{if le $page 1}}disabled{{end}}>上一页</button>
|
||
<button class="appendix-page-button" data-appendix-next {{if ge $page 3}}disabled{{end}}>下一页</button>
|
||
</div>
|
||
</div>
|
||
{{with sliceModelsPage $.IntlAppendixList $page $.AppendixPagination.PageSize}}
|
||
<table>
|
||
<tr><th>国际候选</th><th>厂商</th><th>输入</th><th>输出</th><th>上下文</th></tr>
|
||
{{range .}}
|
||
<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}}
|
||
|
||
</div>{{end}}
|
||
{{end}}
|
||
{{if gt (pageCount (len .IntlAppendixList) .AppendixPagination.PageSize) 3}}
|
||
<div class="data-empty">国际低价附录仅展示前 3 页,剩余完整数据请前往查询页或下载全量导出 JSON。</div>
|
||
{{end}}
|
||
|
||
</section>
|
||
|
||
<section class="section" id="appendix-pricing-domestic">
|
||
<h2>完整价格附录(国内低价)</h2>
|
||
{{range $page := seqPages (pageCount (len .DomesticAppendixList) .AppendixPagination.PageSize)}}{{if le $page 3}}
|
||
<div class="appendix-page" data-appendix-page="{{$page}}" data-appendix-total-pages="{{pageCount (len $.DomesticAppendixList) $.AppendixPagination.PageSize}}" {{if ne $page 1}}hidden{{end}}>
|
||
<div class="appendix-page-header">
|
||
<strong>附录第 {{$page}} / {{pageCount (len $.DomesticAppendixList) $.AppendixPagination.PageSize}} 页</strong>
|
||
<div class="appendix-page-nav">
|
||
<button class="appendix-page-button" data-appendix-prev {{if le $page 1}}disabled{{end}}>上一页</button>
|
||
<button class="appendix-page-button" data-appendix-next {{if ge $page 3}}disabled{{end}}>下一页</button>
|
||
</div>
|
||
</div>
|
||
{{with sliceModelsPage $.DomesticAppendixList $page $.AppendixPagination.PageSize}}
|
||
<table>
|
||
<tr><th>国内候选</th><th>厂商</th><th>输入(CNY)</th><th>输出(CNY)</th><th>上下文</th></tr>
|
||
{{range .}}
|
||
<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}}
|
||
</div>{{end}}
|
||
{{end}}
|
||
{{if gt (pageCount (len .DomesticAppendixList) .AppendixPagination.PageSize) 3}}
|
||
<div class="data-empty">国内低价附录仅展示前 3 页,剩余完整数据请前往查询页或下载全量导出 JSON。</div>
|
||
{{end}}
|
||
</section>
|
||
|
||
<section class="section" id="appendix-free">
|
||
<h2>完整免费附录</h2>
|
||
{{range $page := seqPages (pageCount (len .FreeTop20) .AppendixPagination.PageSize)}}
|
||
<div class="appendix-page" data-appendix-page="{{$page}}" data-appendix-total-pages="{{pageCount (len $.FreeTop20) $.AppendixPagination.PageSize}}" {{if ne $page 1}}hidden{{end}}>
|
||
<div class="appendix-page-header">
|
||
<strong>附录第 {{$page}} / {{pageCount (len $.FreeTop20) $.AppendixPagination.PageSize}} 页</strong>
|
||
<div class="appendix-page-nav">
|
||
<button class="appendix-page-button" data-appendix-prev {{if le $page 1}}disabled{{end}}>上一页</button>
|
||
<button class="appendix-page-button" data-appendix-next {{if ge $page (pageCount (len $.FreeTop20) $.AppendixPagination.PageSize)}}disabled{{end}}>下一页</button>
|
||
</div>
|
||
</div>
|
||
<table>
|
||
<tr><th>模型</th><th>厂商</th><th>来源类型</th><th>上下文</th></tr>
|
||
{{range sliceModelsPage $.FreeTop20 $page $.AppendixPagination.PageSize}}
|
||
<tr>
|
||
<td><strong>{{.Name}}</strong></td>
|
||
<td>{{.ProviderName}}</td>
|
||
<td>{{classifyFreeSource .}}</td>
|
||
<td>{{formatContextWindowCompact .ContextLength}}</td>
|
||
</tr>
|
||
{{end}}
|
||
</table>
|
||
</div>
|
||
{{end}}
|
||
|
||
</section>
|
||
|
||
<section class="section" id="appendix-platforms">
|
||
<h2>平台覆盖附录</h2>
|
||
{{range $page := seqPages (pageCount (add (len .Operators) (len .Resellers)) .AppendixPagination.PageSize)}}
|
||
<div class="appendix-page" data-appendix-page="{{$page}}" data-appendix-total-pages="{{pageCount (add (len $.Operators) (len $.Resellers)) $.AppendixPagination.PageSize}}" {{if ne $page 1}}hidden{{end}}>
|
||
<div class="appendix-page-header">
|
||
<strong>附录第 {{$page}} / {{pageCount (add (len $.Operators) (len $.Resellers)) $.AppendixPagination.PageSize}} 页</strong>
|
||
<div class="appendix-page-nav">
|
||
<button class="appendix-page-button" data-appendix-prev {{if le $page 1}}disabled{{end}}>上一页</button>
|
||
<button class="appendix-page-button" data-appendix-next {{if ge $page (pageCount (add (len $.Operators) (len $.Resellers)) $.AppendixPagination.PageSize)}}disabled{{end}}>下一页</button>
|
||
</div>
|
||
</div>
|
||
{{with sliceOperatorsPage $.Operators $page $.AppendixPagination.PageSize}}
|
||
<table>
|
||
<tr><th>官方/云平台</th><th>模型数</th><th>最低输入价</th><th>平均输入价</th></tr>
|
||
{{range .}}
|
||
<tr><td><strong>{{.Name}}</strong></td><td>{{.ModelCount}}</td><td>{{formatPrice .MinInputPrice "USD"}}</td><td>{{formatPrice .AvgInputPrice "USD"}}</td></tr>
|
||
{{end}}
|
||
</table>
|
||
{{end}}
|
||
{{with sliceOperatorsPage $.Resellers $page $.AppendixPagination.PageSize}}
|
||
<table>
|
||
<tr><th>聚合平台</th><th>模型数</th><th>最低输入价</th><th>平均输入价</th></tr>
|
||
{{range .}}
|
||
<tr><td><strong>{{.Name}}</strong></td><td>{{.ModelCount}}</td><td>{{formatPrice .MinInputPrice "USD"}}</td><td>{{formatPrice .AvgInputPrice "USD"}}</td></tr>
|
||
{{end}}
|
||
</table>
|
||
{{end}}
|
||
</div>
|
||
{{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>
|
||
{{$planDisplay := subscriptionPlanDisplayInfo .TencentSubscriptionPlans}}
|
||
{{range .TencentSubscriptionPlans}}
|
||
{{$info := index $planDisplay (planDisplayKey .)}}
|
||
<tr class="{{if $info.IsLowest}}tone-success{{end}}">
|
||
<td><strong>{{formatPlanOperator .}}</strong></td>
|
||
<td>{{formatPlanFamily .PlanFamily}}</td>
|
||
<td><strong>{{.PlanName}}{{if $info.IsLowest}} 🏷 最低价{{end}}</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>
|
||
<script>
|
||
(function () {
|
||
const appendices = document.querySelectorAll('.appendix-page[data-appendix-page]');
|
||
if (!appendices.length) return;
|
||
|
||
const groups = new Map();
|
||
appendices.forEach((node) => {
|
||
const section = node.closest('section');
|
||
if (!section) return;
|
||
const key = section.id || section.querySelector('h2')?.textContent || 'appendix';
|
||
if (!groups.has(key)) groups.set(key, []);
|
||
groups.get(key).push(node);
|
||
});
|
||
|
||
groups.forEach((pages) => {
|
||
const total = pages.length;
|
||
let active = 0;
|
||
const render = () => {
|
||
pages.forEach((page, index) => {
|
||
if (index === active) {
|
||
page.removeAttribute('hidden');
|
||
} else {
|
||
page.setAttribute('hidden', 'hidden');
|
||
}
|
||
const prev = page.querySelector('[data-appendix-prev]');
|
||
const next = page.querySelector('[data-appendix-next]');
|
||
if (prev) prev.disabled = active === 0;
|
||
if (next) next.disabled = active === total - 1;
|
||
});
|
||
};
|
||
|
||
pages.forEach((page) => {
|
||
const prev = page.querySelector('[data-appendix-prev]');
|
||
const next = page.querySelector('[data-appendix-next]');
|
||
if (prev) {
|
||
prev.addEventListener('click', () => {
|
||
if (active > 0) {
|
||
active -= 1;
|
||
render();
|
||
}
|
||
});
|
||
}
|
||
if (next) {
|
||
next.addEventListener('click', () => {
|
||
if (active < total - 1) {
|
||
active += 1;
|
||
render();
|
||
}
|
||
});
|
||
}
|
||
});
|
||
|
||
render();
|
||
});
|
||
})();
|
||
</script>
|
||
</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,
|
||
"formatModelOrganization": formatModelOrganization,
|
||
"signatureAuditSectionLead": buildSignatureAuditSectionLead,
|
||
"signatureAuditSummaryTone": signatureAuditSummaryTone,
|
||
"themedNewsBadgeTitle": themedNewsBadgeTitle,
|
||
"themedNewsBadgeIcon": themedNewsBadgeIcon,
|
||
"sliceModelsPage": sliceModelsPage,
|
||
"sliceOperatorsPage": sliceOperatorsPage,
|
||
"seqPages": func(pages int) []int { out := make([]int, pages); for i := 0; i < pages; i++ { out[i] = i + 1 }; return out },
|
||
"prevDisabled": func(page int) string { if page <= 1 { return "disabled" }; return "" },
|
||
"nextDisabled": func(page, total int) string { if page >= total { return "disabled" }; return "" },
|
||
"pageCount": pageCount,
|
||
"subscriptionPlanDisplayInfo": subscriptionPlanDisplayInfo,
|
||
"planDisplayKey": planDisplayKey,
|
||
}
|
||
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_top5=%d domestic_top10=%d intl_appendix=%d domestic_appendix=%d", r.TotalModels, len(r.FreeModels), len(r.IntlTop5), len(r.DomesticTop10), len(r.IntlAppendixList), len(r.DomesticAppendixList))
|
||
}
|
||
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, " ")
|
||
}
|
||
|
||
func writeAppendixExport(report *ReportV3, outDir string) (string, error) {
|
||
appendixDir := filepath.Join(outDir, "appendix", report.Date)
|
||
if err := os.MkdirAll(appendixDir, 0o755); err != nil {
|
||
return "", err
|
||
}
|
||
path := filepath.Join(appendixDir, "full_appendix.json")
|
||
payload := AppendixExport{
|
||
Date: report.Date,
|
||
GeneratedAt: report.GeneratedAt,
|
||
IntlAppendixList: report.IntlAppendixList,
|
||
DomesticAppendixList: report.DomesticAppendixList,
|
||
FreeTop20: report.FreeTop20,
|
||
Operators: report.Operators,
|
||
Resellers: report.Resellers,
|
||
}
|
||
data, err := json.MarshalIndent(payload, "", " ")
|
||
if err != nil {
|
||
return "", err
|
||
}
|
||
if err := os.WriteFile(path, data, 0o644); err != nil {
|
||
return "", err
|
||
}
|
||
return path, nil
|
||
}
|